Files
biggus-dickus/docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md
2026-06-05 18:15:53 +02:00

12 KiB

Reference-Data Edit/Delete Lifecycle — Design

Date: 2026-06-05 Status: Approved (brainstorming) — ready for implementation planning. Issues: #30 (vocabularies/terms/authorities edit+delete), #36 (field-definitions edit+delete).

Context

The admin reference-data surface currently exposes create + list only for all four entity kinds:

  • Vocabularies & terms — GET/POST /api/admin/vocabularies, GET/POST /api/admin/vocabularies/{id}/terms
  • Authorities — GET/POST /api/admin/authorities?kind=
  • Field definitions — GET/POST /api/admin/field-definitions

There is no way to rename a vocabulary, correct a term's labels/URI, fix a misspelled authority, edit a field definition's labels/group/required flag, or delete any of them. In a cataloguing system this is a real operational gap: reference data accrues mistakes that today can only be fixed by direct DB edits. This milestone completes the CRUD lifecycle — backend endpoints and frontend UI — for all four kinds.

The integrity constraint that shapes everything

Object flexible-field values live in a JSONB column on object (migration 0005_object_fields.sql: ALTER TABLE object ADD COLUMN fields JSONB NOT NULL DEFAULT '{}'), keyed by field-definition key, with term/authority references stored as UUID strings inside that JSON. There is no foreign key from object data to terms/authorities/field-definitions. Consequences:

  • Deleting a term, authority, or field-definition is not blocked or cascaded by the database — it would silently leave dangling UUIDs / orphaned keys in objects. Any "is it referenced?" guard must be an explicit JSONB scan we write.
  • Deleting a vocabulary is already protected: term.vocabulary_id and field_definition.vocabulary_id are REFERENCES vocabulary (id) ON DELETE RESTRICT (migrations 0002, 0004). So a non-empty/bound vocabulary delete fails at the FK level — we catch that and surface a clean 409, rather than invent a check.
  • term_label, authority_label, field_definition_label are all ON DELETE CASCADE from their parent, so deleting a term/authority/field-def cleans up its own labels.

Decisions settled in brainstorming

  1. Scope: cohesive — backend and frontend edit/delete for all four kinds in one milestone (no backend endpoints shipping without UI).
  2. Delete policy for referenced entities: block with 409 + count. Never silently alter catalogue data. The curator must clear/reassign the referencing object fields first. (Cascade-scrub was rejected as too destructive / no undo.)
  3. Field-definition immutability: key, data_type, and binding (vocabulary_id/authority_kind) are immutable; labels, group_key, required are editable.
  4. required toggled on: allowed; governs validation on future object writes only — no retroactive scan or block of existing objects.
  5. Vocabulary key rename: allowed (cosmetic — terms and field-definitions bind by vocabulary UUID, not key).
  6. Frontend affordance: in-place edit + AlertDialog delete (Option A) — reuse the existing two-pane form panes and the installed AlertDialog; no new shadcn deps.
  7. Storybook: add co-located stories for components created or meaningfully changed where it makes sense (forms, editable rows, dialogs) — not every trivial change.

Backend

All endpoints are gated by EditCatalogue and audited: every mutation runs the insert/update/delete and audit::record(&mut *conn, &NewAuditEvent { actor, action, entity_type, entity_id, .. }) in one transaction, mirroring the #21 create-audit pattern (AuditActor::User(auth.user.id.to_uuid())). Audit actions: Updated / Deleted.

Endpoints

Vocabularies / terms (crates/api/src/admin_vocab.rs)

  • PATCH /api/admin/vocabularies/{id} — rename key only.
  • DELETE /api/admin/vocabularies/{id} — allowed only when the vocabulary has no terms and is bound by no field-definition. Enforced by the existing FK RESTRICT; the resulting sqlx FK error is mapped to 409 with a reason, not a 500.
  • PATCH /api/admin/vocabularies/{id}/terms/{term_id} — edit labels + external_uri.
  • DELETE /api/admin/vocabularies/{id}/terms/{term_id}409 + count if any object references the term; otherwise delete (its term_label rows cascade).

Authorities (crates/api/src/admin_authorities.rs)

  • PATCH /api/admin/authorities/{id} — edit labels + external_uri. kind is immutable (field bindings filter by kind).
  • DELETE /api/admin/authorities/{id}409 + count if referenced; otherwise delete (authority_label cascades).

Field definitions (crates/api/src/ field-definition handler)

  • PATCH /api/admin/field-definitions/{key} — edit labels, group_key, required. The PATCH body exposes only those three fields, so key/data_type/binding are structurally immutable (they cannot be sent — no runtime reject needed). Invalid values (empty label, empty group_key — both have CHECK <> '' constraints) return 422, consistent with the create path.
  • DELETE /api/admin/field-definitions/{key}409 + count if any object stores that field key; otherwise delete (label cascades).

Referenced-checks (JSONB scans)

  • Term / authority referenced: count objects whose fields JSONB contains the UUID as a value. Use a jsonpath value-match, scoped to the field keys whose definition is term/authority-typed for that vocabulary/kind, to avoid false positives. Returns a count (and the implementation may also collect a small sample of object numbers for the UI message).
  • Field-definition referenced: count objects where fields ? '<key>' (JSONB key-exists operator) — simple and indexable.

DB layer (crates/db/src/{vocab,authority,fields}.rs)

New functions, each taking actor: AuditActor and a &mut PgConnection and writing its audit entry atomically (mirroring the existing create functions):

  • vocab::rename_vocabulary, vocab::delete_vocabulary, vocab::update_term, vocab::delete_term, vocab::count_objects_referencing_term
  • authority::update_authority, authority::delete_authority, authority::count_objects_referencing_authority
  • fields::update_field_definition, fields::delete_field_definition, fields::count_objects_using_field

delete_vocabulary maps the FK-restrict error to a typed "in use" error so the handler can return 409. The three count_* helpers are read-only (no audit, no actor).

API error shape

Reuse the existing typed-error conventions:

  • 409 (referenced / in-use): JSON body with the blocking count (e.g. { "code": "in_use", "count": N }) so the UI can render "used by N objects".
  • 422 (invalid value): the existing field-error shape ({ field, code }, e.g. empty label / empty group_key), consistent with set_fields' FieldErrorView and the create path. (Immutability is enforced structurally by the PATCH DTO, not by a runtime 422.)
  • 404: entity not found.

OpenAPI is regenerated so web/src/api/schema.d.ts picks up the new endpoints and DTOs.

Frontend

Affordance: in-place edit + AlertDialog delete, per screen layout. Deletes everywhere use the installed AlertDialog, mirroring the existing DeleteObjectDialog.

Per-screen

Fields (web/src/fields/, two-pane /fields)

  • Selecting a row in the left FieldList turns the right pane (FieldForm) into an edit form: labels / group_key / required editable; key / data_type / binding shown disabled. A "New field" button resets the pane to create mode.
  • Each list row gets a delete affordance → AlertDialog confirm.

Vocabularies (web/src/vocab/, two-pane /vocabularies/:id)

  • Left VocabularyList rows: rename-key (inline edit) + delete.
  • Right-pane (VocabularyTerms) term rows: edit (labels/URI) + delete.

Authorities (web/src/authorities/, single-pane /authorities/:kind)

  • Each authority row: inline-expand edit (labels/URI) + delete.

Hooks (web/src/api/queries.ts)

Add, mirroring useUpdateObject/useDeleteObject and invalidating the correct list query keys: useRenameVocabulary, useDeleteVocabulary, useUpdateTerm, useDeleteTerm, useUpdateAuthority, useDeleteAuthority, useUpdateFieldDefinition, useDeleteFieldDefinition. The 409 (referenced) and 422 (immutable) responses parse into the existing HttpError/FieldRejection style.

Delete-blocked UX

On a 409 the AlertDialog stays open and shows the blocking reason — t("actions.inUse", { count }) → e.g. "Used by 7 objects — clear those fields first" — instead of closing.

i18n (web/src/i18n/{en,sv}.json)

New action keys under vocab.* / authorities.* / fields.* plus a shared actions.* namespace where it makes sense: edit, delete, rename, save, cancel, inUse (count-interpolated). en/sv parity required.

Storybook

Add co-located *.stories.tsx for the components that gain meaningful states: the field-definition edit form, the editable term/authority row, and the delete-confirm dialog wrapper. (Skip trivially-changed components.)

Testing

Backend (existing infra: compose Postgres on host 5442, Meili on 7700; #[sqlx::test] provisions its own DB; mirror crates/api/tests/admin_catalog.rs):

  • db: update/delete per entity; each count_objects_referencing_* returns the right count; delete blocked when referenced; delete_vocabulary FK-restrict → typed in-use error; an audit row (Updated/Deleted, correct actor) per mutation.
  • api: each PATCH/DELETEEditCatalogue required (401/403 without), happy path (200/204), 409 + count when referenced, 422 on an invalid value (empty group_key/label), 404 when missing. (Immutables are absent from the PATCH DTO, so there is no "immutable changed" path to test.)
  • OpenAPI regenerated.

Frontend (Vitest + RTL + MSW, onUnhandledRequest: "error"):

  • Mutation hooks invalidate the correct keys; a 409 parses into the typed error.
  • Per screen: edit form populates and saves; delete confirms via AlertDialog; a 409 keeps the dialog open showing "used by N"; field-def edit shows key/type/binding disabled.
  • en/sv key-parity check (existing test).
  • Storybook stories for the meaningfully-changed components run green under the addon-vitest project.

Acceptance criteria

  1. Update + delete endpoints for vocabulary (rename), term, authority, field-definition — all EditCatalogue, all audited.
  2. Referenced term/authority/field-def delete → 409 + count; vocabulary delete → 409 when it has terms or is bound.
  3. Field-def key/data_type/binding immutable (absent from the PATCH DTO — cannot be changed); labels/group/required editable; required not retroactively enforced.
  4. In-place edit + AlertDialog delete on all three screens; a blocked delete shows "used by N" without closing.
  5. Storybook stories added for the meaningfully-changed components.
  6. en/sv parity; no any/eslint-disable/@ts-ignore; codename ban; bundle ≤150 KB gz; cargo + web typecheck/lint/test/build green; OpenAPI regenerated.

Out of scope / follow-ups

  • A "find/replace a reference across objects" bulk-reassign tool (would let a curator clear references blocking a delete in one action) — file if the 409 friction is felt.
  • Surfacing the referencing objects as clickable links in the 409 message (v1 shows a count + optional sample, not a live list).
  • Audit-entry coalescing for multi-step edits (#13, separate).