refactor: fold machine-remote-data into remote-data.ts, PascalCase load lifecycle (RD-11)

`machine-remote-data.ts` defined a third encoding of an in-flight fetch:
`LoadLifecycle`. It had three call sites, all one identical line, and the type
was never imported by name. Move the mapping into `remote-data.ts` as
`fromLoadLifecycle`, beside its neighbour `fromResource` — a `RemoteData`
constructor, not a sixth encoding.

The lowercase `loading`/`failed`/`loaded` tags on `BriefState`,
`OrgTemplateState` and `StamdataEditorState` existed only because
`LoadLifecycle` required them. Now that the constraint is inline and
PascalCase, the three machines' load-lifecycle tags become `Loading`,
`Failed` and `Loaded` — matching their own PascalCase message tags in the
same file. `stamdata-editor.machine.spec.ts` no longer asserts a PascalCase
message producing a lowercase state.

`BriefStatus` (the letter's draft/submitted/approved/rejected/sent status,
parsed off the wire from `BriefViewDto`) is a separate tag family and is
untouched — its tag count stays 54 before and after this change.

Delete `machine-remote-data.ts` and merge its spec into `remote-data.spec.ts`.
Regenerate `behaviour-spec.mdx` (the `machineRemoteData` section heading
becomes `fromLoadLifecycle`) and confirm `gen:snippets` reports no drift, since
`remote-data.ts` carries a showcase region.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 18:07:00 +02:00
co-authored by Claude Sonnet 5
parent 11664d2efa
commit 827c655c1b
19 changed files with 303 additions and 139 deletions
@@ -179,7 +179,7 @@ const filledView: BriefView = { ...view, brief: filledBrief };
function loadedBrief(store: BriefStore): Brief {
const s = store.model();
if (s.tag !== 'loaded') throw new Error('not loaded');
if (s.tag !== 'Loaded') throw new Error('not loaded');
return s.brief;
}
@@ -431,7 +431,7 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
// Then reset() ran exactly once, and the store ends up loaded from its result.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model().tag).toBe('loaded');
expect(store.model().tag).toBe('Loaded');
});
it('a second 404 does not drive a second reset()', async () => {
@@ -447,6 +447,6 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
// Then reset() ran exactly once — the once-only bound holds across calls, not
// just within one — and the second 404 surfaces as an ordinary load failure.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED });
expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
});
});
@@ -4,7 +4,7 @@ import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { fromLoadLifecycle } from '@shared/application/remote-data';
import {
Brief,
CaseContext,
@@ -29,7 +29,7 @@ import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
* P1) via `BriefState.Loaded.decisions` — this store never computes them itself.
*/
@Injectable({ providedIn: 'root' })
export class BriefStore implements PendingSave {
@@ -95,11 +95,11 @@ export class BriefStore implements PendingSave {
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */
readonly remoteData = computed(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private brief = computed<Brief | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s.brief : null;
return s.tag === 'Loaded' ? s.brief : null;
});
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
@@ -111,7 +111,7 @@ export class BriefStore implements PendingSave {
private decisions = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s.decisions : null;
return s.tag === 'Loaded' ? s.decisions : null;
});
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -182,7 +182,7 @@ export class BriefStore implements PendingSave {
}
private restore(step: (current: Brief) => Brief | undefined) {
const s = this.model();
if (s.tag !== 'loaded') return;
if (s.tag !== 'Loaded') return;
const target = step(s.brief);
if (target === undefined) return;
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
@@ -2,7 +2,7 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { fromLoadLifecycle } from '@shared/application/remote-data';
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/application/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
@@ -22,7 +22,7 @@ import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
type LoadedState = Extract<OrgTemplateState, { tag: 'Loaded' }>;
const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
@@ -58,11 +58,11 @@ export class OrgTemplateStore implements PendingSave {
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
readonly pendingPublish = signal(false);
readonly remoteData = computed(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
return s.tag === 'Loaded' ? s : null;
});
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
@@ -101,7 +101,7 @@ export class OrgTemplateStore implements PendingSave {
// the length guard makes it idempotent (no dispatch loop).
effect(() => {
const s = this.model();
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local')
this.dispatchUpload({
@@ -76,7 +76,7 @@ const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sectio
});
const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
const passageIds = (s: BriefState, key: string) =>
sectionBlocks(s, key)
@@ -92,12 +92,12 @@ describe('brief.machine reduce', () => {
availablePassages: [],
decisions,
}).tag,
).toBe('loaded');
).toBe('Loaded');
});
it('BriefLoadFailed moves loading to failed with the reason', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed',
tag: 'Failed',
reason: 'x',
});
});
@@ -210,7 +210,7 @@ describe('brief.machine reduce', () => {
comments: 'graag aanpassen',
});
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft');
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
});
@@ -220,7 +220,7 @@ describe('brief.machine reduce', () => {
// fill the required section via the besluit, then submit
const filled = reduce(loaded(), besluit('positief'));
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({
tag: 'submitted',
submittedBy: 'u1',
submittedAt: 't',
@@ -232,7 +232,7 @@ describe('brief.machine reduce', () => {
// approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({
tag: 'approved',
approvedBy: 'u2',
approvedAt: 't2',
@@ -248,7 +248,7 @@ describe('brief.machine reduce', () => {
comments: 'nee',
decisions,
});
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't2',
@@ -262,7 +262,7 @@ describe('brief.machine reduce', () => {
// send from submitted is a no-op
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
});
it('a status transition replaces decisions with the fresh server value', () => {
@@ -280,10 +280,10 @@ describe('brief.machine reduce', () => {
at: 't2',
decisions: staleApprover,
});
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
});
});
function initialLoading(): BriefState {
return { tag: 'loading' };
return { tag: 'Loading' };
}
@@ -37,16 +37,16 @@ import { passagesForBesluit } from './besluit';
*/
export type BriefState =
| { tag: 'loading' }
| { tag: 'Loading' }
| {
tag: 'loaded';
tag: 'Loaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'failed'; reason: string };
| { tag: 'Failed'; reason: string };
export const initial: BriefState = { tag: 'loading' };
export const initial: BriefState = { tag: 'Loading' };
export type BriefMsg =
| {
@@ -110,7 +110,7 @@ function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBl
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
let brief = f(s.brief);
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
return { ...s, brief };
@@ -189,13 +189,13 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) {
case 'BriefLoaded':
return {
tag: 'loaded',
tag: 'Loaded',
brief: m.brief,
availablePassages: m.availablePassages,
decisions: m.decisions,
};
case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason };
return { tag: 'Failed', reason: m.reason };
case 'Seed':
return m.state;
@@ -203,7 +203,7 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'BesluitSelected':
return withEdit(s, (b) =>
s.tag === 'loaded' && isSectionEditable(b, 'kern')
s.tag === 'Loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b,
);
@@ -275,6 +275,6 @@ function transition(
decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true,
): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
return { ...s, brief: { ...s.brief, status: next() }, decisions };
}
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
});
const loaded = (): OrgTemplateState =>
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = {
categoryId: 'org-logo',
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = expectTag(loaded(), 'loaded');
const s = expectTag(loaded(), 'Loaded');
expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2);
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
});
it('LoadFailed carries the reason', () => {
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
});
it('FieldEdited edits the draft and marks dirty', () => {
const s = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
'loaded',
'Loaded',
);
expect(s.draft.orgName).toBe('CIBG Nieuw');
expect(s.dirty).toBe(true);
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
it('MarginEdited edits one edge and marks dirty', () => {
const s = expectTag(
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
'loaded',
'Loaded',
);
expect(s.draft.margins.topMm).toBe(40);
expect(s.draft.margins.leftMm).toBe(20);
@@ -75,9 +75,9 @@ describe('org-template.machine', () => {
it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded',
'Loaded',
);
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded');
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
expect(s.dirty).toBe(false);
expect(s.draft.orgName).toBe('X');
});
@@ -85,20 +85,20 @@ describe('org-template.machine', () => {
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded',
'Loaded',
);
const savedDraft = editing.draft;
// a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded');
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
expect(s.dirty).toBe(true);
});
it('edits are no-ops in non-loaded states', () => {
expect(
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
).toEqual({
tag: 'loading',
tag: 'Loading',
});
});
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
}),
'loaded',
'Loaded',
);
expect(done.draft.logoDocumentId).toBe('doc-1');
expect(done.dirty).toBe(true);
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' },
}),
'loaded',
'Loaded',
);
expect(removed.draft.logoDocumentId).toBeUndefined();
expect(removed.dirty).toBe(true);
@@ -154,7 +154,7 @@ describe('org-template.machine', () => {
tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
}),
'loaded',
'Loaded',
);
expect(switched.upload.categories).toHaveLength(1);
expect(switched.upload.uploads).toHaveLength(0);
@@ -22,10 +22,10 @@ export type OrgTemplateTextField =
| 'signatureClosing';
export type OrgTemplateState =
| { tag: 'loading' }
| { tag: 'failed'; reason: string }
| { tag: 'Loading' }
| { tag: 'Failed'; reason: string }
| {
tag: 'loaded';
tag: 'Loaded';
subOrgId: string;
draft: OrgTemplate;
publishedVersion: number;
@@ -36,7 +36,7 @@ export type OrgTemplateState =
upload: UploadState;
};
export const initial: OrgTemplateState = { tag: 'loading' };
export const initial: OrgTemplateState = { tag: 'Loading' };
export type OrgTemplateMsg =
| { tag: 'Loading' }
@@ -51,18 +51,18 @@ export type OrgTemplateMsg =
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
return s.tag === 'Loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
}
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
switch (m.tag) {
case 'Loading':
return { tag: 'loading' };
return { tag: 'Loading' };
case 'LoadFailed':
return { tag: 'failed', reason: m.reason };
return { tag: 'Failed', reason: m.reason };
case 'DraftLoaded':
return {
tag: 'loaded',
tag: 'Loaded',
subOrgId: m.view.draft.subOrgId,
draft: m.view.draft,
publishedVersion: m.view.publishedVersion,
@@ -71,16 +71,16 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
dirty: false,
// Keep the loaded logo category across sub-org switches (it's the same
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
};
case 'FieldEdited':
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
case 'MarginEdited':
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
case 'DraftSaved':
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
case 'Upload': {
if (s.tag !== 'loaded') return s;
if (s.tag !== 'Loaded') return s;
const upload = reduceUpload(s.upload, m.msg);
// A completed/removed logo upload also updates the draft's logoDocumentId.
if (m.msg.type === 'UploadComplete')
+1 -1
View File
@@ -172,7 +172,7 @@ export class BriefPage {
Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s : undefined;
return s.tag === 'Loaded' ? s : undefined;
});
protected reload() {
@@ -0,0 +1,171 @@
# RD-11 — Fold the lifecycle projection into `remote-data.ts`, and PascalCase the 3 machines
Status: done
Source: PLAN.md 1b#3
## Why
`machine-remote-data.ts` is 24 lines defining a **third** encoding of "in flight / ok /
failed": `LoadLifecycle = { tag: 'loading' } | { tag: 'failed'; reason } | { tag: 'loaded' }`.
It has three call sites, all the identical line, and `LoadLifecycle` is never imported by
name anywhere — it is a purely structural constraint.
That constraint is the **only** reason three machines carry lowercase state tags while their
message tags are PascalCase in the same file. `stamdata-editor.machine.spec.ts:61` shows the
confusion in one line today:
```ts
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' });
```
A PascalCase message producing a lowercase state. Relocate the projection with PascalCase
keys and the dialect drift resolves itself — no separate renaming pass, and one named concept
disappears.
## Read first
- `libs/shared/src/application/machine-remote-data.ts` — all 24 lines
- `libs/shared/src/application/machine-remote-data.spec.ts` — 20 lines, to be merged
- `libs/shared/src/application/remote-data.ts` — note `fromResource`, the neighbour and
precedent for the new function
- `libs/shared/docs/remote-data.mdx:72` — teaches `s.tag === 'loaded'`, so it must change too
- `apps/ssp/src/app/brief/domain/brief.ts:68`**`BriefStatus`. Read this before renaming
anything.** See decision 4.
## Decisions (pre-made, don't relitigate)
1. **Relocate, do not simply delete.** The mapping has to exist somewhere, because
`<app-async>` takes a `RemoteData`. Deleting the module re-inlines a 6-line switch in three
stores, recreating the duplication WP-31 removed. Move it into `remote-data.ts` as
`fromLoadLifecycle`, beside `fromResource`, where it reads as what it is: **a `RemoteData`
constructor, not a sixth encoding.** Keep the `Extract<S, { tag: 'Loaded' }>` Success
payload so all three call sites stay one line.
2. **Key it PascalCase**: `Loading | Failed{reason} | Loaded`. Merge
`machine-remote-data.spec.ts` into `remote-data.spec.ts` and delete both old files.
3. **Rename only the three load-lifecycle tags, and catch all four syntactic forms.** Measured
counts of the construction form alone (39 across 11 files) understate it. The forms are:
| Form | Example |
| ------------- | ---------------------------------------------- |
| construction | `tag: 'loading'` |
| comparison | `s.tag === 'loaded'`, `s.tag !== 'loaded'` |
| type-level | `Extract<OrgTemplateState, { tag: 'loaded' }>` |
| documentation | `remote-data.mdx:72` |
Files in scope: the three machines (`brief.machine.ts`, `org-template.machine.ts`,
`stamdata-editor.machine.ts`), their three specs, `brief.store.ts`, `brief.store.spec.ts`,
`org-template.store.ts`, `stamdata.store.ts`, `brief.page.ts`, and `remote-data.mdx`.
4. **`BriefStatus` IS NOT IN SCOPE. This is the one way to break this ticket.**
`brief.machine.ts` contains **two** independent lowercase tag families:
- `BriefState`'s load lifecycle — `loading`/`failed`/`loaded`**rename these**
- `BriefStatus`'s letter status — `draft`/`submitted`/`approved`/`rejected`/`sent`, defined
in `brief.ts:68`**leave these alone**
`brief.machine.ts:278` has both in one line:
`if (s.tag !== 'loaded' || s.brief.status.tag !== from …)`. The first is in scope, the
second is not. `BriefStatus` is parsed off the wire from `BriefViewDto`, so renaming its
tags breaks the parse boundary and the backend contract. **Never rename by "all lowercase
tags in this file".**
5. **Three more collision sites must not be touched.** They use the same words for unrelated
things, and anchoring on `tag: '` already excludes them — but verify rather than assume:
- `scenario.ts` / `scenario.interceptor.ts``'loading'` is a `?scenario=` **URL param
value**, not a state tag
- `upload.machine.ts` and the four upload UI components — `UploadStatus` discriminates on
**`type:`**, not `tag:`, with `'failed'`/`'complete'`/`'uploading'`
- `registratie-lookup.store.ts``'loading'` is an Angular `resource()` status
6. **Do not touch `ActionState`, `SaveState`, or `pendingPublish`.** RD-12, RD-13 and RD-14
own those, and they must follow this ticket or the same tags get renamed twice.
## Files
Add to / edit: `libs/shared/src/application/remote-data.ts` (+ `.spec.ts`),
`libs/shared/docs/remote-data.mdx`.
Delete: `libs/shared/src/application/machine-remote-data.ts` (+ `.spec.ts`).
Rename tags in: `brief.machine.ts` (+ spec), `org-template.machine.ts` (+ spec),
`stamdata-editor.machine.ts` (+ spec), `brief.store.ts` (+ spec), `org-template.store.ts`,
`stamdata.store.ts`, `brief.page.ts`.
## Steps
1. Add `fromLoadLifecycle` to `remote-data.ts` with PascalCase keys (decisions 1-2).
2. Rename the load-lifecycle tags across the files in decision 3, one file at a time, letting
the type-checker find the next site. **Do not blanket-sed.**
3. Point the three stores at `fromLoadLifecycle`; delete `machine-remote-data.ts` and merge
its spec cases into `remote-data.spec.ts`.
4. Update `remote-data.mdx:72`.
5. Run `npm run gen:behaviour-spec``behaviour-spec.mdx:839` has a `machineRemoteData`
section that must become the new name.
6. Update this ticket's `Status:` to `done` and the README's RD-11 row to `done`.
7. Commit all of it together.
## Acceptance criteria
The third encoding is gone and nothing lowercase survives in the three machines:
```bash
git grep -n "machineRemoteData\|LoadLifecycle" -- apps libs # MUST return nothing
ls libs/shared/src/application/machine-remote-data* # MUST be "No such file"
M="apps/ssp/src/app/brief/domain apps/ssp/src/app/brief/application \
apps/ssp/src/app/brief/ui libs/beheer/src/domain libs/beheer/src/application"
git grep -n "tag: 'loading'\|tag: 'failed'\|tag: 'loaded'" -- $M # MUST return nothing
git grep -n "tag === 'loaded'\|tag !== 'loaded'" -- $M # MUST return nothing
```
`BriefStatus` is untouched — this is the check that matters most (decision 4):
```bash
# Measured before this ticket was written: the total is exactly 54. It MUST still be 54.
git grep -c "tag: 'draft'\|tag: 'submitted'\|tag: 'approved'\|tag: 'rejected'\|tag: 'sent'" \
-- apps/ssp/src/app/brief | awk -F: '{s+=$NF} END {print s}' # MUST print 54
git diff --stat apps/ssp/src/app/brief/domain/brief.ts # MUST be empty — brief.ts unchanged
```
If that number moves, you have renamed a wire contract. Stop and revert rather than adjusting
the number.
The collision sites are untouched:
```bash
git diff --name-only | git grep -c "scenario\|upload" || true # expect no such files
```
```bash
npm run ci # exits 0
```
## Verification
`npm run ci`. Also run `npm run ci -- --full` **with an explicit long timeout**: this edits
`remote-data.mdx`, which Storybook globs, and a broken MDX import is invisible to plain `ci`.
Note for whoever runs it: an 8-minute command cannot complete in the default 120s foreground
window and the harness will move it to the background. Pass `timeout: 600000` on the Bash
call so it runs to completion in the foreground, then commit.
## Out of scope
- `ActionState` / `SaveState` / `pendingPublish` — RD-12, RD-13, RD-14 (decision 6).
- `BriefStatus` (decision 4). If a `BriefStatus` tag changes, the ticket has failed.
- `UploadStatus`'s `type:` discriminant — optional RD-35.
- The `NO_SUBORGS`/`NO_TABLES`-should-be-`Empty` finding — optional RD-34.
## Risks
- **`BriefStatus` (decision 4) is the failure mode to fear.** Its tags are a wire contract.
Rename by union, never by file.
- **Do not blanket-sed `'loading'`/`'failed'`/`'loaded'`.** Five files legitimately use those
words for other purposes (decision 5). Renaming one file at a time and following the
type-checker is slower and correct.
- **`brief.store.ts` and `brief.page.ts` use only the comparison form**, so a
construction-only grep misses them. That is why decision 3 lists four forms.
- **`behaviour-spec.mdx` drift**: it has a `machineRemoteData` section heading at :839 which
changes with the function name. Run `gen:behaviour-spec` in the same commit.
- **`remote-data.ts` carries a `// #region showcase:fold` marker at :30.** If your edit moves
or splits that region, run `npm run gen:snippets` in the same commit too.
+1 -1
View File
@@ -105,7 +105,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done |
| RD-09 | Teach the effect map: ARCHITECTURE §2d + fp-tea (2 docs, no generator) | 08 | | done |
| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | done |
| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo |
| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | done |
| RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo |
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo |
| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo |
@@ -1,6 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { fromLoadLifecycle } from '@shared/application/remote-data';
import { createHistory } from '@shared/application/history';
import {
ChangeCounts,
@@ -20,7 +20,7 @@ import {
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<StamdataEditorState, { tag: 'loaded' }>;
type LoadedState = Extract<StamdataEditorState, { tag: 'Loaded' }>;
/**
* Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the
@@ -42,11 +42,11 @@ export class StamdataStore {
so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */
readonly previewDate = signal<string>('');
readonly remoteData = computed(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
return s.tag === 'Loaded' ? s : null;
});
readonly table = computed<StamTable | null>(() => this.loaded()?.table ?? null);
readonly rows = computed<readonly StamRow[]>(() => this.loaded()?.rows ?? []);
@@ -24,40 +24,40 @@ const seedLoaded = (): StamdataEditorState =>
describe('stamdata-editor reduce', () => {
it('Loaded snapshots original independently of rows', () => {
const s = seedLoaded();
expect(s.tag).toBe('loaded');
if (s.tag !== 'loaded') return;
expect(s.tag).toBe('Loaded');
if (s.tag !== 'Loaded') return;
const edited = reduce(s, { tag: 'CellEdited', row: 0, column: 'beroep', value: 'Chirurg' });
if (edited.tag !== 'loaded') return;
if (edited.tag !== 'Loaded') return;
expect(edited.rows[0]['beroep']).toBe('Chirurg');
expect(edited.original[0]['beroep']).toBe('Arts'); // snapshot untouched → diff works
});
it('RowAdded appends an empty row shaped by the schema', () => {
const s = reduce(seedLoaded(), { tag: 'RowAdded' });
if (s.tag !== 'loaded') return;
if (s.tag !== 'Loaded') return;
expect(s.rows).toHaveLength(2);
expect(s.rows[1]).toEqual({ program: '', beroep: '', geldigVan: '', geldigTot: '' });
});
it('RowRemoved drops the row at the index', () => {
const s = reduce(reduce(seedLoaded(), { tag: 'RowAdded' }), { tag: 'RowRemoved', row: 0 });
if (s.tag !== 'loaded') return;
if (s.tag !== 'Loaded') return;
expect(s.rows).toHaveLength(1);
});
it('edit messages are ignored unless loaded', () => {
expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial);
expect(
reduce({ tag: 'failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' })
reduce({ tag: 'Failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' })
.tag,
).toBe('failed');
).toBe('Failed');
});
it('LoadFailed and Loading transition regardless of prior state', () => {
expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({
tag: 'failed',
tag: 'Failed',
reason: 'boom',
});
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' });
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'Loading' });
});
});
@@ -11,9 +11,9 @@ import { StamRow, StamTable, emptyRow } from '@beheer/domain/stamdata';
* apply path is a reviewed PR, not a runtime write — ADR-0004).
*/
export type StamdataEditorState =
| { tag: 'loading' }
| { tag: 'failed'; reason: string }
| { tag: 'loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] };
| { tag: 'Loading' }
| { tag: 'Failed'; reason: string }
| { tag: 'Loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] };
export type StamdataEditorMsg =
| { tag: 'Loading' }
@@ -24,29 +24,29 @@ export type StamdataEditorMsg =
| { tag: 'RowRemoved'; row: number }
| { tag: 'Seed'; state: StamdataEditorState }; // mount a specific state (stories/tests)
export const initial: StamdataEditorState = { tag: 'loading' };
export const initial: StamdataEditorState = { tag: 'Loading' };
const copy = (rows: readonly StamRow[]): StamRow[] => rows.map((r) => ({ ...r }));
export function reduce(s: StamdataEditorState, m: StamdataEditorMsg): StamdataEditorState {
switch (m.tag) {
case 'Loading':
return { tag: 'loading' };
return { tag: 'Loading' };
case 'Loaded':
// original is an independent snapshot so later edits never mutate it (drives the diff).
return { tag: 'loaded', table: m.table, rows: copy(m.rows), original: copy(m.rows) };
return { tag: 'Loaded', table: m.table, rows: copy(m.rows), original: copy(m.rows) };
case 'LoadFailed':
return { tag: 'failed', reason: m.reason };
return { tag: 'Failed', reason: m.reason };
case 'CellEdited':
if (s.tag !== 'loaded') return s;
if (s.tag !== 'Loaded') return s;
return {
...s,
rows: s.rows.map((r, i) => (i === m.row ? { ...r, [m.column]: m.value } : r)),
};
case 'RowAdded':
return s.tag === 'loaded' ? { ...s, rows: [...s.rows, emptyRow(s.table)] } : s;
return s.tag === 'Loaded' ? { ...s, rows: [...s.rows, emptyRow(s.table)] } : s;
case 'RowRemoved':
return s.tag === 'loaded' ? { ...s, rows: s.rows.filter((_, i) => i !== m.row) } : s;
return s.tag === 'Loaded' ? { ...s, rows: s.rows.filter((_, i) => i !== m.row) } : s;
case 'Seed':
return m.state;
default:
+6 -6
View File
@@ -819,6 +819,12 @@ classes.
- is empty-safe: undefined, null, and empty string all yield the empty string
- returns empty for an unparseable string rather than "Invalid Date"
#### fromLoadLifecycle
- maps Loading → Loading
- maps Failed → Failure carrying an Error with the reason
- maps Loaded → Success carrying the whole loaded state
#### httpClientFetch
- sends the pending idempotency key as a header for a write, not a fresh one per attempt
@@ -836,12 +842,6 @@ classes.
- keeps query + hash on both targets
- the root maps nl → / and en → /en/
#### machineRemoteData
- maps loading → Loading
- maps failed → Failure carrying an Error with the reason
- maps loaded → Success carrying the whole loaded state
#### parseBsn (elfproef)
- accepts a valid BSN (passes the elfproef)
+4 -4
View File
@@ -69,7 +69,7 @@ The idiom this repo uses instead — see `brief.page.ts`, `dashboard.page.ts`,
// in the component class
protected readonly loaded = computed(() => {
const s = this.model(); // or store.someRemoteData()
return s.tag === 'loaded' ? s : undefined;
return s.tag === 'Loaded' ? s : undefined;
});
```
@@ -94,8 +94,8 @@ timing/outcome of `/api/*` calls. Try it on `/brief` or `/dashboard`.
A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of
what it holds (draft → submitted → approved, in the brief's case) — not the network
fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a
machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not
loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store
layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
machine's own `Loading`/`Failed`/`Loaded` tags purely mirror the fetch (nothing extra
beyond "not loaded yet" / "the GET failed"), project them with `fromLoadLifecycle` at the
store layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is
doing.
@@ -1,20 +0,0 @@
import { describe, it, expect } from 'vitest';
import { machineRemoteData } from './machine-remote-data';
import { loading, success } from '../testing/remote-data';
describe('machineRemoteData', () => {
it('maps loading → Loading', () => {
expect(machineRemoteData({ tag: 'loading' })).toEqual(loading());
});
it('maps failed → Failure carrying an Error with the reason', () => {
const rd = machineRemoteData({ tag: 'failed', reason: 'boom' });
expect(rd.tag).toBe('Failure');
if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom');
});
it('maps loaded → Success carrying the whole loaded state', () => {
const loaded = { tag: 'loaded', foo: 42 } as const;
expect(machineRemoteData(loaded)).toEqual(success(loaded));
});
});
@@ -1,24 +0,0 @@
import { RemoteData } from '@shared/application/remote-data';
/** The standard load-lifecycle tags an editor machine exposes. */
export type LoadLifecycle =
{ tag: 'loading' } | { tag: 'failed'; reason: string } | { tag: 'loaded' };
/**
* Project an Elm-machine state onto `RemoteData` for the `<app-async>` seam. The machine
* keeps owning its own domain lifecycle (draft/submitted/…); this is purely the
* loading/failed/loaded → async mapping, which was byte-identical across BriefStore,
* OrgTemplateStore and StamdataStore (WP-31). Wrap the call in a `computed`.
*/
export function machineRemoteData<S extends LoadLifecycle>(
s: S,
): RemoteData<Error, Extract<S, { tag: 'loaded' }>> {
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
default: // 'loaded'
return { tag: 'Success', value: s as Extract<S, { tag: 'loaded' }> };
}
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map, successOf } from './remote-data';
import { RemoteData, fromLoadLifecycle, map2, map, successOf } from './remote-data';
import { loading, failure, empty, success } from '../testing/remote-data';
const loadingRd: RemoteData<string, number> = loading();
@@ -33,3 +33,20 @@ describe('successOf', () => {
expect(successOf(empty())).toBeUndefined();
});
});
describe('fromLoadLifecycle', () => {
it('maps Loading → Loading', () => {
expect(fromLoadLifecycle({ tag: 'Loading' })).toEqual(loading());
});
it('maps Failed → Failure carrying an Error with the reason', () => {
const rd = fromLoadLifecycle({ tag: 'Failed', reason: 'boom' });
expect(rd.tag).toBe('Failure');
if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom');
});
it('maps Loaded → Success carrying the whole loaded state', () => {
const loadedState = { tag: 'Loaded', foo: 42 } as const;
expect(fromLoadLifecycle(loadedState)).toEqual(success(loadedState));
});
});
@@ -27,6 +27,26 @@ export function fromResource<T>(
return { tag: 'Loading' };
}
/**
* Project an Elm-machine's load lifecycle onto `RemoteData`, for the `<app-async>` seam. The
* machine keeps owning its own domain lifecycle (draft/submitted/…); this is purely the
* Loading/Failed/Loaded → async mapping, which was byte-identical across BriefStore,
* OrgTemplateStore and StamdataStore (WP-31). A `RemoteData` constructor, not a sixth
* encoding — wrap the call in a `computed`.
*/
export function fromLoadLifecycle<
S extends { tag: 'Loading' } | { tag: 'Failed'; reason: string } | { tag: 'Loaded' },
>(s: S): RemoteData<Error, Extract<S, { tag: 'Loaded' }>> {
switch (s.tag) {
case 'Loading':
return { tag: 'Loading' };
case 'Failed':
return { tag: 'Failure', error: new Error(s.reason) };
default: // 'Loaded'
return { tag: 'Success', value: s as Extract<S, { tag: 'Loaded' }> };
}
}
// #region showcase:fold
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(