Frontend CRUD¶
Diverges from the code — 3 finding(s) · 19d408be · 2026-08-25
Adversarial re-check confirms all three first-pass findings against the code on disk: HeaderAddInfoBtn is genuinely gone (deleted in ec5b324c, locked in by tests/test_frontend_node_header_declutter.py), EngAddPropForm is a fictional name for the real AddPropForm component, and every one of the doc's precise app.js/detail-panel.js line citations is stale by 130-3100 lines — the HeaderAddInfoBtn removal is a genuine high-severity behavioural contradiction, so the page keeps its "diverges" verdict.
Divergences from the code — details
| Sev | Where | Doc says | Code does | Evidence |
|---|---|---|---|---|
| HIGH | Add Information Dialog — launch points and predicate scoping (frontend-crud.md line 181) | "The node header's 'Add information' entry (HeaderAddInfoBtn in detail-panel.js) opens it unscoped — the full Phase-1 type selector — for editors on non-document domains." | Confirmed absent: grep for HeaderAddInfoBtn / ndp-header-addinfo across frontend/plugins/detail-panel.js and the whole frontend/ tree returns zero matches. Commit ec5b324c ("declutter header — remove catch-all add, glyph-edit, smaller name", 2026-08-25) deliberately deleted the component and its CSS class, and tests/test_frontend_node_header_declutter.py:36-44 (test_header_addinfo_button_component_removed / test_header_addinfo_css_removed) locks the removal in as a static-source invariant. Only the per-coordinate scoped empty-slot 'Add {label}' buttons remain. | frontend/plugins/detail-panel.js (component absent, confirmed by Grep); tests/test_frontend_node_header_declutter.py:36-44 |
| MEDIUM | Engineering properties (detail panel) — frontend-crud.md line 197 | "EngAddPropForm.save (line 2194 of detail-panel.js) and EngGroupEditForm.save (line 2292) follow the same two-step protocol" | Confirmed: no symbol EngAddPropForm exists anywhere in detail-panel.js. The real component is function AddPropForm(...) at line 2199 with its save const at line 2215. EngGroupEditForm does exist but at line 2286 (save at 2313), not 2292. |
frontend/plugins/detail-panel.js:2199 (AddPropForm def), :2215 (save) |
| LOW | apiFetch — Full Signature and Semantics (applies to nearly every line citation in the page) | Doc cites precise line numbers throughout for app.js/detail-panel.js symbols, e.g. apiFetch "Defined at line 2279", apiFetchAbsolute "line 2320", _gidShowPreview "line 8177"/"8143", _gidDoCommit "line 8189", _gidPrimary "line 8108", openAddNodeDialog "line 7508", openUpdateNodeDialog "line 9624", openCreateRelationDialog "line 9249", saveNodeNote "line 4651"/409-handling "line 4662", NoteField.save "line 1517". | Independently re-located every one of these symbols by grep and confirmed all cited line numbers are stale: apiFetch is actually at app.js:2609, apiFetchAbsolute:2657, saveNodeNote:5403, _gidPrimary:8971, _gidShowPreview:9008, _gidDoCommit:9054, openAddNodeDialog:10596, openUpdateNodeDialog:10688, openCreateRelationDialog:11514, NoteField at detail-panel.js:1646 with its save const at 1658. Symbol names and described logic/behaviour are otherwise correct — only the citations are wrong, consistent with the doc's stated verified_at_commit predating 14 intervening commits. | frontend/app.js:2609 (apiFetch, representative example); frontend/plugins/detail-panel.js:1646 (NoteField) |
Layer: frontend
Overview — Frontend as the CRUD Surface¶
The Frontend CRUD engine is the browser-side layer that owns every user-initiated graph mutation in EIDOS Explorer. It is not a server-side Python module — it is a collection of JavaScript functions and React components in frontend/app.js and frontend/plugins/detail-panel.js that together implement the complete read/write surface of the graph UI.
The engine is responsible for:
- Routing all API calls through
apiFetch, which enforces authentication, timeout, and cross-domain proxy rules in a single place. - Implementing the preview-before-commit gate for every destructive or creative operation (add node, update node, create relation). No mutation reaches the server's write path without a prior
previewround-trip that returns apreviewId. - Maintaining four browser-side LRU caches —
nodeCache,childrenCache,labelMap, andparentMap— that are declared at module scope inapp.js(lines 137–146) and kept consistent after commits via optimistic patches followed by authoritative reloads. As of the view-refresh engine (Wave 1), that "optimistic patch → authoritative reconcile" is no longer hand-coded per call-site: every CRUDonConfirmcallsEidosCore.dispatchMutation(outcome)and the canonical engine drives the refresh. See the View Refresh engine page. - Enforcing invariant I-2 from the CRUD model: the Confirm button is disabled whenever
previewIdis falsy orerrorsis non-empty. This check is performed inside_gidShowPreview(line 8177):primaryBtn.disabled = !!(res.errors?.length) || !res.previewId.
The engine is the only place that ever calls the /mutations/* endpoint family. The detail panel plugin (detail-panel.js) imports apiFetch via window.EidosCore and reuses the same endpoint convention for inline edits (note field, engineering properties). There is no second mutation path.
apiFetch — Full Signature and Semantics¶
Full JavaScript signature¶
async function apiFetch(path, base = BASE_URL, timeoutMs = 8000, extraOpts = {})
Defined at line 2279 of frontend/app.js. Returns a parsed JSON value, or null for HTTP 204. Throws on any non-2xx status.
Parameter table¶
| Param | Type | Description |
|---|---|---|
path |
string |
The URL path starting with /, e.g. /mutations/node/preview. Must not include the origin. |
base |
string |
The domain base URI. Defaults to BASE_URL (the primary server origin). Pass a secondary domain's baseURI to route the call through the proxy. Pass '' or null to force the primary domain. |
timeoutMs |
number |
Request timeout in milliseconds. Default 8000. CRUD operations use 15000 because preview round-trips may be slow on large subgraphs. |
extraOpts |
object |
Passed as additional fetch init options (e.g. method, headers, body). The function merges authHeaders() into any caller-supplied headers, so callers do not need to add the Bearer token themselves. |
What "base" means here¶
base maps to a domain's baseURI as declared in domains.json (loaded into DOMAIN_REGISTRY by loadRegistry at startup). For the primary domain, base equals BASE_URL and the URL is constructed as:
url = `${BASE_URL}${path}`
For a secondary domain (a reference domain such as location or type), base is that domain's baseURI. The function calls baseToDomain(base) (line 2268) to reverse-look up the domain name key from DOMAIN_REGISTRY.domains, then constructs:
url = `${BASE_URL}/api/proxy/${encodeURIComponent(domain)}${path}`
This proxy routing is a hard security boundary. The comment on line 2281 states the invariant explicitly: the browser must never send the user's Bearer token directly to secondary services. All cross-domain calls are funnelled through the primary server's /api/proxy/ endpoint, which re-signs the request on behalf of the user.
If base is non-null and non-primary but cannot be found in DOMAIN_REGISTRY, baseToDomain returns null and apiFetch throws Error("Unregistered domain base: " + base) immediately, before any network call.
Error handling and timeout behavior¶
apiFetch wraps the fetch call in an AbortController timer. If timeoutMs elapses, controller.abort() fires and the underlying fetch rejects with an AbortError.
For HTTP errors, apiFetch branches as follows:
- 401: calls
showLogin()and throwsError("Not authenticated"). - 403: throws
AccessDeniedError("Access denied"). - 404: throws
NotFoundError("HTTP 404 for {url}"). - All other non-2xx: attempts to parse the response body as JSON and extracts a detail string using the priority chain
j.detail || j.message || j.errors?.[0]?.message. The resolved string becomes the error message. Crucially, the raw HTTP status code is attached to the thrownErrorobject ase.status(line 2309). Callers that need to distinguish 409 (version conflict) from other errors branch on this property directly. - 204: returns
nullwithout attempting to parse JSON.
apiFetchAbsolute¶
async function apiFetchAbsolute(absoluteUri, timeoutMs = 8000)
Defined at line 2320 of frontend/app.js. Accepts a full URL (including origin), parses it with the URL constructor to extract pathname + search, then delegates to apiFetch with u.origin as the base argument.
This function exists for call sites that already have a fully-qualified URI — for example when navigating to a node whose @id is an absolute URI and the caller does not know in advance whether it belongs to the primary or a secondary domain. apiFetchAbsolute handles the routing decision internally by passing u.origin as base, which apiFetch will look up in DOMAIN_REGISTRY and proxy if necessary.
Use apiFetch when the path and domain are known separately (the common case for mutation calls). Use apiFetchAbsolute when the call site only has a full URI string — for instance when following a relation target URL that arrived from the server (lines 5686, 5768 in app.js).
The Preview → Commit Lifecycle¶
Every user-initiated graph mutation follows a strict two-step protocol. There is no direct write path. This section describes the lifecycle as implemented, not as a specification.
Step 1: POST /mutations/{entity}/preview¶
The frontend assembles a command object and POSTs it to the appropriate preview endpoint:
- Add node:
POST /mutations/node/preview - Update node (label, description, product type, engineering properties, shared note):
POST /mutations/node-update/preview - Create relation:
POST /mutations/relation/preview
The body is always a JSON object with a commandType field (e.g. "AddNode", "UpdateNode", "CreateRelation") and the mutation-specific fields. For example, the Add Node preview body assembled at line 9575:
{
commandType: "AddNode",
parentNodeId: sel,
label: values.label,
description: values.description,
idempotencyKey: idemKey
}
The idempotencyKey is a client-generated UUID4 (line 9538: const idemKey = _uuid4()). It is embedded in the preview command and carried forward into the commit body, allowing the server to detect and reject a duplicate Add Node that was previewed but whose commit was sent twice.
The server responds with a preview result object. The fields the frontend depends on are:
| Field | Type | Meaning |
|---|---|---|
previewId |
string |
An opaque server-issued token that identifies this specific pending mutation. The client must echo this value back in the commit call. |
status |
string |
"valid" if the mutation can proceed; other values indicate the mutation is rejected at preview time. |
errors |
array |
Validation errors. If non-empty, previewId may still be present but the Confirm button will be disabled (I-2). |
warnings |
array |
Non-blocking messages shown to the user in the diff panel. |
diff |
object |
Shape of the proposed change. Used by renderDiff callbacks to populate the preview UI. For node creates: diff.nodesCreated; for updates: diff.nodeUpdated; for relations: diff.relationsCreated. |
snapshotScope |
object | null |
If the server took a safety snapshot before the mutation, describes its scope (node count, path). Displayed below the diff. |
Step 2: User sees diff¶
After a successful preview call, _gidShowPreview (line 8143) renders the preview panel. It calls the operation's renderDiff callback to populate the diff container, sets the alert state (error/warning/ok), and conditionally disables the primary button:
primaryBtn.disabled = !!(res.errors?.length) || !res.previewId;
The previewData object (the full server response) is stored in _gid.previewData (line 8135). This is the only client-side storage of previewId. There is no localStorage, no session storage, no URL parameter. The token lives exclusively in the in-memory _gid.previewData object for the duration of the dialog's lifetime.
Step 3: POST /mutations/{entity}/commit¶
When the user clicks Confirm, _gidDoCommit (line 8189) calls the operation's onConfirm callback, passing _gid.previewData as the argument. Each onConfirm implementation extracts pd.previewId and sends it as the commit body:
// Add node commit (line 9601):
await apiFetch("/mutations/node/commit", _edit.selectedDomBase, 15000, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ previewId: pd.previewId }),
});
For node updates, the commit body also includes expectedRev (the last-seen eidos:rev from nodeCache) to detect concurrent edits:
body: JSON.stringify({ previewId: pd.previewId, expectedRev: nodeCache[sel]?.["eidos:rev"] })
The previewId is single-use. The server invalidates it on first commit. Attempting to commit the same previewId a second time yields a 404 (previewId-not-found). After _gidDoCommit calls closeDialog(), the _gid.previewData object is no longer accessible, so accidental double-submission from the UI is structurally impossible — the dialog is closed and the data discarded before any user action could trigger a second commit.
CRUD Operations¶
Add node¶
Triggered by openAddNodeDialog (keyboard shortcut add-node, line 7508). The dialog collects label, description, and optionally productType. The onPreview callback (line 9574) assembles an AddNode command and calls POST /mutations/node/preview via apiFetch with _edit.selectedDomBase as the base argument. On confirm (line 9600), commits with POST /mutations/node/commit. The server response includes entity_id (the new node's UUID), which the frontend uses to navigate to the new node and insert it into the tree in-place (BL-FE-118).
Before calling preview, the frontend must have: the parent node ID (sel = _edit.selectedNodeId), a UUID4 idempotency key, and the selected domain base. The label format is validated against labelPattern before the preview call is made.
Update node¶
Triggered by openUpdateNodeDialog (line 9624). Reads label, description, and productType from nodeCache[sel] as initial values. The onPreview callback (line 9690) detects which fields actually changed (label, description, product type) and assembles an UpdateNode command. Only changed fields are included in the body; an unchanged form throws Error("Nothing to save") before the network call. Calls POST /mutations/node-update/preview and POST /mutations/node-update/commit. The commit includes expectedRev for optimistic concurrency.
Create relation¶
Triggered by openCreateRelationDialog (line 9249). The dialog collects targetId (via a node search field) and relationType. The onPreview callback (line 10261) sends commandType: "CreateRelation" with sourceId, targetId, and relationType. Calls POST /mutations/relation/preview and POST /mutations/relation/commit. For cross-domain relations (e.g. linking a product node to a location node), the Add Information Dialog (_aidDoPreview, line 8665) adds externalDomain and externalSourceDomain fields to the command before calling the same preview endpoint.
Add Information Dialog — launch points and predicate scoping¶
window.openAddInformationDialog(sourceNodeId, sourceDomainKey, opts) is the single canonical entry point for adding data to a node in data-language terms — its buttons read "Location", "Type", "Document", and so on, while the underlying predicate id is kept only in each button's title/data-pred-id and never shown as prose. Several launch points open this same dialog:
- An empty identity coordinate (an unassigned Location or Type slot in the header) opens it scoped to that one predicate (
isLocatedIn/hasType), for editors only; a coordinate with no resolvable predicate stays a static "Unassigned" label rather than a dead button. - Each editable sub-panel's "+" launches it scoped to the kind of data that panel holds.
- The admin "Related information" control room (below) carries an unscoped "Add information" entry — the full Phase-1 type selector — for adding any offered type the header slots don't cover.
The node header itself carries no catch-all "Add information" button — that entry was removed so the header stays operational rather than a control surface. Editors reach every offered type either through the scoped slot/sub-panel affordances or through the Related-information control room.
Filled coordinates are edited in place: on a Location/Type slot that already has a value, the leading glyph is an icon-only <button> (aria-label "Edit {label}") that calls window.openEditInformationDialog(anchorEl, rel, sourceNodeId, sourceDomainKey) — the value text still links to its target. _resolveCoordinates carries the underlying relation record (_rel) onto each value so the glyph has the edge identity. The edit dialog opens directly at Phase 2 (target picker) and, in edit mode, adds a destructive "Remove {label}" button that hands off to the shared relation-delete confirm (openDeleteRelationDialog) — one delete pattern app-wide. Removing a coordinate reference is reversible: the slot reverts to its scoped "Add {label}" affordance.
opts.scopePredicates (an array of predicate ids) restricts the offered types. The pure helper _aidScopePreds(preds, scopePredicates) filters the catalog: an absent or empty scope returns the full set unchanged; a scope resolving to several predicates renders a filtered Phase-1 selector; a scope resolving to exactly one predicate skips Phase 1 — _aidSelectPred opens the target picker (Phase 2) directly, the same path openEditInformationDialog uses for editing an existing relation. If the scope filters out every predicate the dialog shows a toast and does not open, and when a single-predicate scope is used the overlay is revealed only after the target picker has rendered, so a missing target tree never leaves an empty dialog on screen.
Admin control room — the "Related information" zone¶
The raw, full relation list (RelatedInformationZone in detail-panel.js) renders only for admins — the gate is the precomputed isAdmin boolean (from /me's is_admin), layered on top of the existing per-domain _zoneHidden gate. Editors and viewers never see this panel at all: they get a decluttered node panel and interact with data through the header affordances and the meaningful sub-panels instead. Because non-admins never render the list, they never see the amber "not yet on record" (unresolved) rows a cross-domain relation can produce — an admin, who can resolve those targets, sees the full list without that ghosting. This is a UI-visibility choice for clarity, not an access-control boundary: the relation payload is still delivered to every viewer's browser, and only its rendering is suppressed.
Inline note save (detail panel)¶
NoteField.save (line 1517 of detail-panel.js) follows the same pattern but outside the generic dialog: it calls POST /mutations/node-update/preview directly, checks pre.errors, then calls POST /mutations/node-update/commit with pre.previewId. The optimistic cache patch at lines 1532–1534 mirrors the same pattern used in saveNodeNote in app.js (line 4651).
Engineering properties (detail panel)¶
EngAddPropForm.save (line 2194 of detail-panel.js) and EngGroupEditForm.save (line 2292) follow the same two-step protocol: preview with the full updated engineering array, then commit with pre.previewId. They assemble the full replacement array by merging existing properties with the new/edited entry, because the UpdateNode command replaces the engineering list in full, not incrementally.
Error Handling¶
409 Conflict (version conflict)¶
When the server detects that expectedRev in the commit body does not match the current revision stored on the node (i.e. another session modified the node between the user's last load and their commit), the server returns HTTP 409. apiFetch attaches err.status = 409 to the thrown error. Callers that handle 409 explicitly:
saveNodeNoteinapp.js(line 4662): on 409, shows "Denne node blev ændret af en anden" (this node was changed by someone else) and callsloadNodeDetail(nodeId)to reload the current state.openUpdateNodeDialog.onConfirminapp.js: on 409, dispatches anodeMutationOutcome(no optimistic patch → full authoritative reconcile of the node's tree row, detail, and chips) and rethrows with a user-readable message so_gidDoCommitcan surface it in the preview alert panel. (Before the view-refresh engine this called_postCommitdirectly.)NoteField.saveindetail-panel.jsdoes not explicitly handle 409; the genericcatchsurfaces the error message viatoast.
422 Validation error¶
The server returns 422 when the mutation fails business-logic validation (e.g. the node label already exists under the same parent). apiFetch extracts the detail from j.detail || j.message || j.errors?.[0]?.message and throws with that string as the message. In the generic dialog, _gidDoCommit catches this and writes it to the preview alert element.
Validation can also fire at preview time, in which case the server returns a 200 with errors populated and a falsy or absent previewId. _gidShowPreview disables the Confirm button in this case. The user sees the error in the preview panel and must correct their input before re-running preview.
404 previewId-not-found¶
If the frontend sends a commit with a previewId the server no longer holds (expired, already committed, or server restarted), the server returns 404. apiFetch throws a NotFoundError. The commit attempt fails and the dialog remains open. The user must click Back, re-enter the form, and run preview again to obtain a fresh previewId.
Integration¶
Dependency on domain-registry for routing¶
apiFetch depends on DOMAIN_REGISTRY being populated before any cross-domain call is made. loadRegistry (line 2224) fetches domains.json at application startup. Every mutation call that targets a secondary domain passes _edit.selectedDomBase as the base argument. This value is set when the user selects a node in the tree — the tree panel stores the domain base of the active tree tab in _edit.selectedDomBase. If the tree is displaying a secondary domain and the user initiates a mutation, apiFetch will route the call through the proxy automatically.
The baseToDomain function (line 2268) is the lookup that makes this work. It normalises trailing slashes and iterates over DOMAIN_REGISTRY.domains to find the entry whose baseURI matches the supplied base. window.baseToDomain = baseToDomain (line 2277) exposes it to plugins so the Add Information Dialog (in app.js) and the detail panel (in detail-panel.js) can perform the same lookup without importing from a separate module.
The detail panel receives mutationBase as a prop. This is the base argument it passes to apiFetch for all inline CRUD operations. It is resolved from opts.mutationBase in the NodeDetailPanel component (line 314), which is populated by app.js when it mounts the panel. This means the detail panel never reads _edit.selectedDomBase directly — it receives the domain context from its caller, which keeps the panel stateless with respect to domain routing.
How it enforces the preview-before-commit invariant¶
The invariant is structural, not checked at runtime by a guard clause. The commit functions never hold a previewId except as pd.previewId where pd is the server response from a preview call. The flow is:
onPreview(values)is called → returns a preview result → stored in_gid.previewData._gidShowPreview(res)disables Confirm if!res.previewId || res.errors?.length._gidDoCommit()callsonConfirm(_gid.previewData)→onConfirmreadspd.previewId.
There is no code path that calls an onConfirm without first populating _gid.previewData from a preview response. The dialog state machine enforces this: _gid.phase starts as "edit", transitions to "preview" only inside _gidShowPreview, and _gidPrimary (line 8108) routes to _gidDoPreview in "edit" phase and _gidDoCommit in "preview" phase. A commit call with a missing or invalid previewId will result in a 422 or 404 from the server, surfaced as an error in the preview alert panel.