Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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_idandfield_definition.vocabulary_idareREFERENCES vocabulary (id) ON DELETE RESTRICT(migrations0002,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_labelare allON DELETE CASCADEfrom their parent, so deleting a term/authority/field-def cleans up its own labels.
Decisions settled in brainstorming
- Scope: cohesive — backend and frontend edit/delete for all four kinds in one milestone (no backend endpoints shipping without UI).
- 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.)
- Field-definition immutability:
key,data_type, and binding (vocabulary_id/authority_kind) are immutable;labels,group_key,requiredare editable. requiredtoggled on: allowed; governs validation on future object writes only — no retroactive scan or block of existing objects.- Vocabulary
keyrename: allowed (cosmetic — terms and field-definitions bind by vocabulary UUID, not key). - Frontend affordance: in-place edit +
AlertDialogdelete (Option A) — reuse the existing two-pane form panes and the installed AlertDialog; no new shadcn deps. - 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}— renamekeyonly.DELETE /api/admin/vocabularies/{id}— allowed only when the vocabulary has no terms and is bound by no field-definition. Enforced by the existing FKRESTRICT; the resultingsqlxFK 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 (itsterm_labelrows cascade).
Authorities (crates/api/src/admin_authorities.rs)
PATCH /api/admin/authorities/{id}— edit labels +external_uri.kindis immutable (field bindings filter by kind).DELETE /api/admin/authorities/{id}— 409 + count if referenced; otherwise delete (authority_labelcascades).
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, sokey/data_type/binding are structurally immutable (they cannot be sent — no runtime reject needed). Invalid values (empty label, emptygroup_key— both haveCHECK <> ''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
fieldsJSONB contains the UUID as a value. Use a jsonpath value-match, scoped to the field keys whose definition isterm/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_termauthority::update_authority,authority::delete_authority,authority::count_objects_referencing_authorityfields::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 / emptygroup_key), consistent withset_fields'FieldErrorViewand 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
FieldListturns the right pane (FieldForm) into an edit form:labels/group_key/requirededitable;key/data_type/ binding shown disabled. A "New field" button resets the pane to create mode. - Each list row gets a delete affordance →
AlertDialogconfirm.
Vocabularies (web/src/vocab/, two-pane /vocabularies/:id)
- Left
VocabularyListrows: 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_vocabularyFK-restrict → typed in-use error; an audit row (Updated/Deleted, correct actor) per mutation. - api: each
PATCH/DELETE—EditCataloguerequired (401/403 without), happy path (200/204), 409 + count when referenced, 422 on an invalid value (emptygroup_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
- Update + delete endpoints for vocabulary (rename), term, authority, field-definition —
all
EditCatalogue, all audited. - Referenced term/authority/field-def delete → 409 + count; vocabulary delete → 409 when it has terms or is bound.
- Field-def
key/data_type/binding immutable (absent from the PATCH DTO — cannot be changed);labels/group/requirededitable;requirednot retroactively enforced. - In-place edit +
AlertDialogdelete on all three screens; a blocked delete shows "used by N" without closing. - Storybook stories added for the meaningfully-changed components.
- 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).