refactor(web): split api.ts into transport seam, domain modules, and stores #119

Merged
dominik.polakovics merged 1 commit from afk/112 into main 2026-07-18 18:07:06 +02:00

Closes #112

What changed

web/src/api.ts (2,127 lines — ~60 types, ~40 fetch wrappers, four polling store factories) is gone. In its place:

Module Owns
api/client.ts The transport seam: the single fetch call site; request<T> (throws), requestText (raw text for byte-compare polling), requestResult (status-driven, never throws), postOp<T> (the house POST contract); ApiError { status, body }; centralized 422 decoding (decodeFieldErrors, decodeFieldMap)
api/types.ts Only the genuinely cross-domain shapes: PageId, SchemaParam, FieldType, SchemaLoadError
api/content.ts The pure document model (DocumentNode/ElementNode/…) + node helpers (nodeAtPath, flattenChildren, …) — a dependency-free leaf
api/{status,tree,elements,layouts,pagetypes}.ts Read-model endpoints, types colocated
api/{pageedit,pagemanage,pagesettings,elementops,git,publish,media,links}.ts Mutation/tagged-union endpoints, types colocated
stores/{hugoStatus,pageTree,pagetypes,gitState}.ts One Solid store per module; the only place solid-js appears in the split

No barrel replaces the monolith — all ~58 importers deep-import exactly the modules they use.

Behavior preservation (the issue's core constraint)

  • Error message strings byte-identical, including the deliberately loose form-error extraction on the two save endpoints (empty-string server error passes through) vs the strict non-empty house contract everywhere else.
  • URLs, methods, JSON/FormData payloads, and headers identical; tagged unions (SaveResult, SettingsSaveResult, ElementOpResult, PublishOutcome, UndoResult, MediaUploadResult, LibraryMediaDeleteResult) keep exact shapes and stay return values; network failures still propagate raw.
  • Strict vs tolerant success-body decode preserved per endpoint; store behavior byte-identical (1500ms/2000ms intervals, per-field Hugo signals, tree/badges raw-text byte-compare, catch-all connected semantics).
  • Endpoints on first-render paths (fetchElements, fetchPageElements) resolve after the same microtask count as the monolith via request's synchronous transform hook — the jsdom suite has tests that observe a single extra hop, which is why the hook exists.
  • Thrown errors are now ApiError extends Error with the same .message; #117's typed-error migration builds on this.

Verification

  • Existing vitest suite passes with import-path-only edits (checked mechanically: every changed line in the 58 migrated files is an import line).
  • 27 new jsdom-free unit tests for the transport seam (@vitest-environment node): success decode, ApiError status/message/body, message modes, 422 decoders, FormData passthrough, tolerant decode, raw network-error propagation. Suite total: 43 files / 898 tests, green.
  • tsc --noEmit clean; vite build clean; go vet ./..., go test ./... (with fresh web/dist), and go build all green.
  • Structural criteria grep-verified: one fetch( site (client.ts); res.ok appears only in client.ts; no solid-js/DOM anywhere under api/; stores ride domain functions only.
  • An independent adversarial review compared every one of the monolith's ~58 exports against its new implementation; its findings (a dropped fetchElements normalization, res.ok naming on TransportResult locals) were fixed before this PR.

Out of scope (per the brief)

Component migration off err.message text-matching (#117) and page-editor controller extraction (#113), both of which should be sequenced after this lands.

🤖 Generated with Claude Code

Closes #112 ## What changed `web/src/api.ts` (2,127 lines — ~60 types, ~40 fetch wrappers, four polling store factories) is gone. In its place: | Module | Owns | | --- | --- | | `api/client.ts` | The transport seam: the **single `fetch` call site**; `request<T>` (throws), `requestText` (raw text for byte-compare polling), `requestResult` (status-driven, never throws), `postOp<T>` (the house POST contract); `ApiError { status, body }`; centralized 422 decoding (`decodeFieldErrors`, `decodeFieldMap`) | | `api/types.ts` | Only the genuinely cross-domain shapes: `PageId`, `SchemaParam`, `FieldType`, `SchemaLoadError` | | `api/content.ts` | The pure document model (`DocumentNode`/`ElementNode`/…) + node helpers (`nodeAtPath`, `flattenChildren`, …) — a dependency-free leaf | | `api/{status,tree,elements,layouts,pagetypes}.ts` | Read-model endpoints, types colocated | | `api/{pageedit,pagemanage,pagesettings,elementops,git,publish,media,links}.ts` | Mutation/tagged-union endpoints, types colocated | | `stores/{hugoStatus,pageTree,pagetypes,gitState}.ts` | One Solid store per module; the only place solid-js appears in the split | No barrel replaces the monolith — all ~58 importers deep-import exactly the modules they use. ## Behavior preservation (the issue's core constraint) - Error message strings byte-identical, including the deliberately loose form-error extraction on the two save endpoints (empty-string server `error` passes through) vs the strict non-empty house contract everywhere else. - URLs, methods, JSON/FormData payloads, and headers identical; tagged unions (`SaveResult`, `SettingsSaveResult`, `ElementOpResult`, `PublishOutcome`, `UndoResult`, `MediaUploadResult`, `LibraryMediaDeleteResult`) keep exact shapes and stay return values; network failures still propagate raw. - Strict vs tolerant success-body decode preserved per endpoint; store behavior byte-identical (1500ms/2000ms intervals, per-field Hugo signals, tree/badges raw-text byte-compare, catch-all `connected` semantics). - Endpoints on first-render paths (`fetchElements`, `fetchPageElements`) resolve after the **same microtask count** as the monolith via `request`'s synchronous `transform` hook — the jsdom suite has tests that observe a single extra hop, which is why the hook exists. - Thrown errors are now `ApiError extends Error` with the same `.message`; #117's typed-error migration builds on this. ## Verification - Existing vitest suite passes with **import-path-only** edits (checked mechanically: every changed line in the 58 migrated files is an import line). - 27 new jsdom-free unit tests for the transport seam (`@vitest-environment node`): success decode, ApiError status/message/body, message modes, 422 decoders, FormData passthrough, tolerant decode, raw network-error propagation. Suite total: 43 files / 898 tests, green. - `tsc --noEmit` clean; `vite build` clean; `go vet ./...`, `go test ./...` (with fresh `web/dist`), and `go build` all green. - Structural criteria grep-verified: one `fetch(` site (client.ts); `res.ok` appears only in client.ts; no solid-js/DOM anywhere under `api/`; stores ride domain functions only. - An independent adversarial review compared every one of the monolith's ~58 exports against its new implementation; its findings (a dropped `fetchElements` normalization, `res.ok` naming on `TransportResult` locals) were fixed before this PR. ## Out of scope (per the brief) Component migration off `err.message` text-matching (#117) and page-editor controller extraction (#113), both of which should be sequenced after this lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
refactor(web): split api.ts into transport seam, domain modules, and stores
All checks were successful
ci / build-and-test (pull_request) Successful in 7m56s
3a8b602b8e
The 2,127-line web/src/api.ts monolith mixed ~60 type declarations, ~40
fetch wrappers (17 hand-rolled ok-checks, 8 hand-rolled JSON POST bodies),
and four Solid store factories with polling timers. It is now:

- api/client.ts — the transport seam: the single fetch call site, with
  request<T> (throw on error), requestText (raw text for the polling
  stores' byte-compare), requestResult (status-driven, never throws),
  postOp<T> (the house POST contract), a typed ApiError carrying the HTTP
  status and decoded body, and the 422 field-error decoding centralized
  behind decodeFieldErrors/decodeFieldMap.
- api/{types,content,status,tree,elements,layouts,pagetypes,pageedit,
  pagemanage,pagesettings,elementops,git,publish,media,links}.ts — domain
  endpoint modules that colocate the request/response types they own;
  api/types.ts holds only the genuinely cross-domain shapes (PageId,
  SchemaParam, FieldType, SchemaLoadError); api/content.ts is the pure
  document model + node helpers. All Solid-free and DOM-free.
- stores/{hugoStatus,pageTree,pagetypes,gitState}.ts — one polling store
  per module, behavior byte-identical (intervals, per-field signals,
  byte-compare caches, catch semantics).

No barrel replaces the monolith: all ~58 importers deep-import the module
they need. Error message strings, URLs, methods, payloads, tagged-union
shapes (SaveResult/SettingsSaveResult/ElementOpResult/PublishOutcome/
UndoResult/MediaUploadResult/LibraryMediaDeleteResult), and polling
cadence are preserved byte-for-byte; endpoints on first-render paths keep
their exact microtask hop count via request's synchronous transform hook.
The transport seam gets 27 jsdom-free unit tests (@vitest-environment
node); the existing suite passes with import-path-only edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PASS

Verification signal relied on: the repo's own Forgejo Actions CI, ci / build-and-testsuccess in 7m56s. The workflow is unconditional (no path filters, no gated jobs), running npm ci && npm run build && npm test plus go vet ./... && go test ./... && go build. It therefore exercised this diff's full path, including the 27 new transport-seam tests. Not re-run locally.

Mergeability: no conflicts; afk/112 sits directly on current main (merge-base f8b776a). Single commit, Conventional Commits title, Closes #112 present and well-formed.

Acceptance criteria (issue #112) — mechanically verified

Criterion Result
Monolith gone, no barrel re-export PASS — web/src/api.ts deleted, no api/index.ts, zero dangling imports of the old path
Exactly one fetch + status-check + JSON decode site PASS — web/src/api/client.ts:88; res.ok appears only in client.ts (other .ok hits are TransportResult locals)
No solid-js / DOM under api/ PASS — zero matches
Four stores, one per file, intervals unchanged PASS — POLL_INTERVAL_MS = 1500, TREE_POLL_INTERVAL_MS = 2000
Existing suite passes with import-path-only edits PASS with one justified exception (below)
Error strings byte-identical PASS — see behavior review

Behavior preservation

An independent adversarial pass compared all 128 exports of the old monolith against their new implementations. No divergence in endpoint URL, HTTP method, headers, body serialization, thrown/returned error message strings, tagged-union shapes, or polling cadence. The /api/… URL-literal multiset is byte-identical (48 entries, same counts); all 128 exports have a home, nothing dropped.

The riskiest part — the 422 centralization — was handled correctly. The monolith had two loose extractions (savePage, savePageSettings: an empty-string server error passes through) against eight strict ones. The split preserves that asymmetry deliberately: strict sites use client.serverError, the two loose sites keep a dedicated looseFormError.

Four benign divergences, none blocking:

  1. Thrown errors are now ApiError (name: "ApiError"), so String(err) changes prefix. Every catch site uses the err instanceof Error ? err.message : String(err) idiom, which is unaffected.
  2. Failure bodies are now always consumed (extra read on error paths only); messages identical.
  3. Failure decode moved from res.json() to res.text() + JSON.parse — differs only for a body of literal JSON null, where the new code is strictly more robust. Unreachable from this server.
  4. Microtask timing shifts ~2 ticks for endpoints normalizing in an async wrapper. The transform hook exists precisely to preserve await-count and was applied where the jsdom suite observes it; it was applied selectively rather than uniformly.

Notes (non-blocking, follow-up material)

  • looseFormError is duplicated verbatim in web/src/api/pageedit.ts:80 and web/src/api/pagesettings.ts:48. Given the PR's headline is centralizing 422 decoding, the loose half staying copy-pasted is the one spot the refactor stops short of its own thesis — it belongs beside serverError in client.ts as a named alternative contract, so the strict-vs-loose distinction lives in one documented place.
  • web/src/test/setup.ts gained a typeof window !== "undefined" guard around window.scrollTo — the sole non-import test edit. It is forced by the jsdom-free-tests criterion (node-environment tests have no window) and weakens no assertion.
  • Web-only diff, so this repo's scaffold/site/AGENTS.md tracking rule does not apply.

Verdict: PASS. Awaiting maintainer confirmation to merge.

> *This was generated by AI while landing a PR.* ## Landing audit — PASS **Verification signal relied on:** the repo's own Forgejo Actions CI, `ci / build-and-test` — **success in 7m56s**. The workflow is unconditional (no path filters, no gated jobs), running `npm ci && npm run build && npm test` plus `go vet ./... && go test ./... && go build`. It therefore exercised this diff's full path, including the 27 new transport-seam tests. Not re-run locally. **Mergeability:** no conflicts; `afk/112` sits directly on current `main` (merge-base `f8b776a`). Single commit, Conventional Commits title, `Closes #112` present and well-formed. ### Acceptance criteria (issue #112) — mechanically verified | Criterion | Result | | --- | --- | | Monolith gone, no barrel re-export | PASS — `web/src/api.ts` deleted, no `api/index.ts`, zero dangling imports of the old path | | Exactly one `fetch` + status-check + JSON decode site | PASS — `web/src/api/client.ts:88`; `res.ok` appears only in `client.ts` (other `.ok` hits are `TransportResult` locals) | | No solid-js / DOM under `api/` | PASS — zero matches | | Four stores, one per file, intervals unchanged | PASS — `POLL_INTERVAL_MS = 1500`, `TREE_POLL_INTERVAL_MS = 2000` | | Existing suite passes with import-path-only edits | PASS with one justified exception (below) | | Error strings byte-identical | PASS — see behavior review | ### Behavior preservation An independent adversarial pass compared **all 128 exports** of the old monolith against their new implementations. **No divergence** in endpoint URL, HTTP method, headers, body serialization, thrown/returned error message strings, tagged-union shapes, or polling cadence. The `/api/…` URL-literal multiset is byte-identical (48 entries, same counts); all 128 exports have a home, nothing dropped. The riskiest part — the 422 centralization — was handled correctly. The monolith had two **loose** extractions (`savePage`, `savePageSettings`: an empty-string server `error` passes through) against eight **strict** ones. The split preserves that asymmetry deliberately: strict sites use `client.serverError`, the two loose sites keep a dedicated `looseFormError`. Four benign divergences, none blocking: 1. Thrown errors are now `ApiError` (`name: "ApiError"`), so `String(err)` changes prefix. Every catch site uses the `err instanceof Error ? err.message : String(err)` idiom, which is unaffected. 2. Failure bodies are now always consumed (extra read on error paths only); messages identical. 3. Failure decode moved from `res.json()` to `res.text()` + `JSON.parse` — differs only for a body of literal JSON `null`, where the new code is strictly more robust. Unreachable from this server. 4. Microtask timing shifts ~2 ticks for endpoints normalizing in an `async` wrapper. The `transform` hook exists precisely to preserve await-count and was applied where the jsdom suite observes it; it was applied selectively rather than uniformly. ### Notes (non-blocking, follow-up material) - `looseFormError` is duplicated verbatim in `web/src/api/pageedit.ts:80` and `web/src/api/pagesettings.ts:48`. Given the PR's headline is centralizing 422 decoding, the loose half staying copy-pasted is the one spot the refactor stops short of its own thesis — it belongs beside `serverError` in `client.ts` as a named alternative contract, so the strict-vs-loose distinction lives in one documented place. - `web/src/test/setup.ts` gained a `typeof window !== "undefined"` guard around `window.scrollTo` — the sole non-import test edit. It is forced by the jsdom-free-tests criterion (node-environment tests have no `window`) and weakens no assertion. - Web-only diff, so this repo's `scaffold/site/AGENTS.md` tracking rule does not apply. **Verdict: PASS.** Awaiting maintainer confirmation to merge.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle!119
No description provided.