From 331a6d7f3463bb3968734ab95071f7c55fa2837d Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 20:06:27 +0200 Subject: [PATCH] docs(plans): tier 3 typed-client (#3 Option A, #24, #29) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-04-tier3-typed-client.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-tier3-typed-client.md diff --git a/docs/superpowers/plans/2026-06-04-tier3-typed-client.md b/docs/superpowers/plans/2026-06-04-tier3-typed-client.md new file mode 100644 index 0000000..219d497 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-tier3-typed-client.md @@ -0,0 +1,201 @@ +# Tier 3 — Typed-Client Quality Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Tighten the generated OpenAPI/TypeScript contract so the frontend drops its `as`-casts — type the free-form `fields` map as an open map (#24) and the enum-valued fields (`visibility`, `data_type`, authority `kind`) as string enums (#29). Architecture decision #3 = **Option A** (allow `utoipa::ToSchema` in `domain`). + +**Architecture:** `domain`'s already-serde enums gain `ToSchema`; a new `DataType` enum is added to `domain` for the `data_type` discriminant. The `api` View DTOs reference these via `#[schema(value_type = …)]` (fields stay `String`/`Value` at runtime; only the *schema description* changes). Regenerate `schema.d.ts`; remove the now-redundant frontend casts. + +**Tech Stack:** Rust (utoipa 5, sqlx), React + TS, openapi-typescript. + +**Conventions:** nightly fmt; clippy; no `any`/`eslint-disable`/`@ts-ignore`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. + +--- + +## Task 1: `domain` — `ToSchema` on enums + new `DataType` + +**Files:** `crates/domain/Cargo.toml`, `crates/domain/src/object.rs`, `crates/domain/src/authority.rs`, `crates/domain/src/field_definition.rs`. + +- [ ] **Step 1: Add the utoipa dep.** In `crates/domain/Cargo.toml` `[dependencies]`, add: +```toml +utoipa.workspace = true +``` +(The workspace already defines `utoipa = { version = "5", features = ["uuid"] }`.) + +- [ ] **Step 2: Derive `ToSchema` on `Visibility`** (`crates/domain/src/object.rs:7-9`). Add `utoipa::ToSchema` to the derive list (keep everything else): +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum Visibility { +``` + +- [ ] **Step 3: Derive `ToSchema` on `AuthorityKind`** (`crates/domain/src/authority.rs:10-12`): +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum AuthorityKind { +``` + +- [ ] **Step 4: Add a `DataType` enum** to `crates/domain/src/field_definition.rs` (it describes the `data_type` discriminant string that `FieldType::kind_str()` produces). NOTE: **`snake_case`**, so `LocalizedText` → `"localized_text"` (matching `kind_str`): +```rust +/// The stored `data_type` discriminant of a field definition. This mirrors the strings +/// produced by [`FieldType::kind_str`]; it exists so the OpenAPI schema can describe +/// `data_type` as a closed string enum (consumed by the typed web client). Kept in sync +/// by hand with `FieldType::kind_str`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum DataType { + Text, + LocalizedText, + Integer, + Date, + Boolean, + Term, + Authority, +} +``` +(If `serde::{Serialize, Deserialize}` are already imported at the top of the file, use the bare derive names; otherwise the fully-qualified `serde::Serialize` forms above are fine.) + +- [ ] **Step 5: Verify** — `cargo +nightly fmt`, `cargo build -p domain`, `cargo clippy -p domain --all-targets`. The existing `field_type_round_trips` etc. tests still pass: `cargo test -p domain`. Add a tiny test asserting `DataType` serializes correctly (it must match `kind_str`): +```rust +#[test] +fn data_type_serde_matches_kind_str() { + use serde_json::json; + assert_eq!(serde_json::to_value(DataType::LocalizedText).unwrap(), json!("localized_text")); + assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text")); + assert_eq!(serde_json::to_value(DataType::Authority).unwrap(), json!("authority")); +} +``` +(place it in the existing `#[cfg(test)] mod tests` in `field_definition.rs`). + +- [ ] **Step 6: Commit** +```bash +git add crates/domain +git commit -m "feat(domain): derive ToSchema on Visibility/AuthorityKind; add DataType enum (#3 Option A)" +``` + +--- + +## Task 2: `api` — enum + open-map schema annotations + regenerate client + +**Files:** `crates/api/src/admin_objects.rs`, `crates/api/src/admin_authorities.rs`, `crates/api/src/admin.rs`, `crates/api/src/openapi.rs`; regenerate `web/src/api/schema.d.ts`. + +> The View fields keep their runtime types (`String` / `serde_json::Value`); only the `#[schema(value_type = …)]` annotation changes what the OpenAPI document says. No handler/construction logic changes. + +- [ ] **Step 1: #24 — open-map `fields`.** In `crates/api/src/admin_objects.rs:45`, change `AdminObjectView.fields`: +```rust + #[schema(value_type = std::collections::HashMap)] + pub fields: serde_json::Value, +``` +(This is the only `value_type = Object` site — confirmed by `grep -rn "value_type = Object" crates/api/src`.) This makes utoipa emit `additionalProperties`, which `openapi-typescript` renders as `{ [key: string]: unknown }` instead of `Record`. + +- [ ] **Step 2: #29 — `visibility` enums.** + - `AdminObjectView.visibility` (`admin_objects.rs:43`, currently `pub visibility: String`): add above it `#[schema(value_type = domain::Visibility)]`. + - `ObjectCreateRequest.visibility` (`admin_objects.rs:165-166`): **remove** the `#[schema(value_type = String)]` line so the field (`pub visibility: Visibility`) emits the enum. + - `VisibilityRequest.visibility` (`crates/api/src/admin.rs`, field is `pub visibility: Visibility`): if it has a `#[schema(value_type = String)]` override, **remove** it so it emits the enum. (Check — it may or may not have one.) + +- [ ] **Step 3: #29 — `data_type` + `authority_kind` enums.** In `crates/api/src/admin_objects.rs`, `FieldDefinitionView` (~lines 360-366): + - `data_type` (line 363): add `#[schema(value_type = domain::DataType)]`. + - `authority_kind` (line 365): add `#[schema(value_type = Option)]`. + - The `NewFieldDefinitionRequest` (~lines 374-377) `data_type`/`authority_kind` are request inputs parsed as free strings by the handler — **leave these as `String`** (typing them would force handler conversion; out of scope, and the create form posts plain strings). + +- [ ] **Step 4: #29 — authority `kind`.** In `crates/api/src/admin_authorities.rs`, `AuthorityView.kind` (line 23, `pub kind: String`): add `#[schema(value_type = domain::AuthorityKind)]`. Leave `NewAuthorityRequest.kind` (line 31) as `String` (request input parsed via `from_db`). + +- [ ] **Step 5: Register the domain enums as OpenAPI components.** In `crates/api/src/openapi.rs` `components(schemas(...))`, add: +```rust + domain::Visibility, + domain::AuthorityKind, + domain::DataType, +``` +(utoipa generates `$ref`s to these from the `value_type` annotations; they must be registered. The `api` crate already depends on `domain`.) + +- [ ] **Step 6: Build + backend tests.** +```bash +cargo +nightly fmt +cargo clippy -p api --all-targets +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api +``` +All green (serialized values are unchanged — `visibility` still serializes "draft" etc., `data_type` still "text"/"localized_text"). + +- [ ] **Step 7: Regenerate the typed client.** +```bash +cargo build -p server +lsof -ti :8080 | xargs kill 2>/dev/null +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server & +SERVER_PID=$! +sleep 2 +( cd web && pnpm gen:api ) +kill "$SERVER_PID" +``` +Verify the generated types: +```bash +grep -n "Visibility:\|AuthorityKind:\|DataType:" web/src/api/schema.d.ts +grep -n "additionalProperties\|\[key: string\]: unknown" web/src/api/schema.d.ts | head +``` +Expect `Visibility: "draft" | "internal" | "public"`, `AuthorityKind: "person" | "organisation" | "place"`, `DataType: "text" | "localized_text" | ...`, and `AdminObjectView.fields` as `{ [key: string]: unknown }`. Then `cd web && pnpm typecheck` — it may now report errors at the cast sites (expected; Task 3 fixes them) OR pass (casts on a now-compatible type are just redundant). Either way, do NOT edit web source in this task beyond the regenerated `schema.d.ts`. + +- [ ] **Step 8: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add crates/api web/src/api/schema.d.ts +git commit -m "feat(api): enum-typed visibility/data_type/kind + open-map fields in OpenAPI (#24 #29)" +``` + +--- + +## Task 3: Frontend — drop the now-redundant casts + +**Files:** `web/src/objects/object-detail.tsx`, `web/src/objects/object-form.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/objects/publish-control.tsx` (+ check `visibility-badge.tsx`, `field-input.tsx`). Plus any local `Visibility` type alias. + +- [ ] **Step 1: Remove the `fields` casts (#24).** `fields` is now `{ [key: string]: unknown }`: + - `object-detail.tsx:55`: `Object.entries(object.fields as Record)` → `Object.entries(object.fields)`. + - `object-form.tsx:181`: `Object.entries(value as Record)` → `Object.entries(value)` (only if `value` is the typed `fields`; if `value` is a generic RHF value, the cast may still be needed — verify the type and remove only if redundant). + - `object-edit-form.tsx:37`: `fields: object.fields as Record` → `fields: object.fields` (if the target type accepts the open map; otherwise leave). + Remove a cast only when the typecheck confirms it's now redundant. Keep the code `any`-free. + +- [ ] **Step 2: Remove the `visibility` cast (#29).** `publish-control.tsx:26`: `const current = object.visibility as Visibility;` → `const current = object.visibility;` (it's now the `"draft" | "internal" | "public"` union). If a local `type Visibility = ...` alias exists and is now identical to the schema union, prefer referencing `components["schemas"]["Visibility"]` or keep the alias if it's used as a shared name — but drop the cast. Check `visibility-badge.tsx`: if its prop is `visibility: string`, you may tighten it to the union or leave it (a union is assignable to `string`); do NOT introduce errors. + +- [ ] **Step 3: `data_type` (#29).** `field-input.tsx` switches on `data_type` — now a union. No cast was present; confirm the switch still typechecks (a union improves exhaustiveness). If there's a `data_type as ...` cast anywhere, remove it. + +- [ ] **Step 4: Verify.** +```bash +cd web +pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size +``` +All green; no `any`/`@ts-ignore` introduced; bundle ≤150 KB. Grep to confirm the casts are gone: +```bash +grep -rn "as Record\|as Visibility" web/src/objects | grep -v ".test." +``` +(Test-file `as Record` defaults may remain — they're test scaffolding, not contract casts; leaving them is fine.) + +- [ ] **Step 5: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web +git commit -m "refactor(web): drop redundant fields/visibility casts now the client is typed (#24 #29)" +``` + +--- + +## Task 4: Verification + +- [ ] **Step 1:** `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz). +- [ ] **Step 2:** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p domain +cargo clippy --workspace --all-targets +cargo +nightly fmt --check +``` +- [ ] **Step 3:** i18n parity check (unchanged keys, but run it); `git grep -in 'biggus\|dickus' -- crates web/src` → none. +- [ ] **Step 4:** Confirm acceptance: OpenAPI `fields` has `additionalProperties`; `visibility`/`data_type`/`kind` are string enums in `schema.d.ts`; the `as Record`/`as Visibility` contract casts are gone. + +--- + +## Self-Review (completed) +- **Spec coverage:** #3 decided (Option A, documented + closed) → this plan's architecture; #24 (open-map fields) → T2 Step 1 + T3 Step 1; #29 (visibility/data_type/kind enums) → T1 + T2 Steps 2-5 + T3 Steps 2-3. ✓ +- **Placeholder scan:** none — exact files/lines/annotations given; the "remove cast only if typecheck confirms redundant" notes are correct verification guards (the generated types determine redundancy). +- **Type consistency:** `DataType` uses `snake_case` to match `FieldType::kind_str` (`localized_text`); `value_type = domain::X` references match the enums registered in `openapi.rs` components; runtime serialization is unchanged (backend tests prove it), so only the schema/TS types tighten. + +## Notes +- Request-side enums (`NewFieldDefinitionRequest.data_type`/`authority_kind`, `NewAuthorityRequest.kind`) intentionally stay `String` — the handlers parse/validate them; typing them is a separate, larger change (would need handler conversion) and isn't required by #24/#29.