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
@@ -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>(