Data flow — builder
A workspace owner editing a finder in the dashboard goes through several distinct lifecycle phases: initial data load, local edits with undo/redo, draft save, and publish (CDN regeneration). This document traces the full path for each phase.
For the visitor-side complement, see Data flow — end user. For the broader architecture context, see Architecture overview.
Phase 1 — Route mount and initial data fetch
The owner navigates to /app/finders/:id. The App Router renders FinderBuilderV2, which is a 'use client' component.
Initial data (the full finder record with its settings JSON) is fetched via TanStack Query:
useQuery({ queryKey: ['finder', finderId], queryFn: () => fetchFinder(finderId) })fetchFinder calls fetchAPI('/finders/:id') from src/lib/api/core.ts. fetchAPI attaches the JWT from the session store, plus a X-Audit-Request-Id UUID header on every request. The auditAction, auditResourceType, and auditResourceId fields are omitted on reads — only mutations carry those.
The raw API response goes through a normalizer and then parseFinderV2Config() in finderBuilderConfig.ts to produce the V2Config type — the canonical local representation used throughout the builder.
Sources: src/components/finders/v2/FinderBuilderV2.tsx, src/lib/api/finders.ts, src/lib/api/core.ts, src/components/finders/v2/finderBuilderConfig.ts
Phase 2 — Local edits via history reducer
The builder’s edit state is managed with useReducer:
const [history, dispatch] = useReducer(historyReducer, {
past: [],
present: initialConfig,
future: [],
});
const config = history.present;Action types:
| Action | Effect |
|---|---|
{ type: 'SET', payload } | Pushes present into past, sets payload as new present, clears future |
{ type: 'UNDO' } | Pops from past into present, pushes old present to future |
{ type: 'REDO' } | Pops from future into present, pushes old present to past |
Every section component calls update(fn) — a memoized wrapper around dispatch({ type: 'SET', payload: fn(current) }). This fires on every slider move, toggle, or color change. The history stack enables the undo/redo buttons in the builder toolbar.
Dirty tracking: isDirty compares JSON.stringify(currentPayload) against savedFingerprint.current (set to the payload fingerprint after each successful save).
Source: src/components/finders/v2/finderBuilderConfig.ts:540-700, src/components/finders/v2/FinderBuilderV2.tsx:229-373
Phase 3 — Live Preview sync
The Live Preview pane is an <iframe> rendering the current draft. It stays in sync with unsaved state via two mechanisms:
-
sessionStorage payload:
buildFinderPreviewPayload()derives a preview-mode payload from the currentV2Config. This is serialized intosessionStorageunder a session-scoped key. -
postMessage bridge:
LivePreview.tsxposts messages to the iframe’scontentWindow:'lf-preview:init'— sent when the iframe first loads'lf-preview:update'— sent on every edit dispatch that changes the draft payload
The iframe loads the widget in preview mode, which reads these messages instead of fetching from the CDN. The preview is scoped to the same origin (window.location.origin) to satisfy postMessage’s security requirement.
On publish, previewRef.current?.reload() is called — this resets the iframe src to force a fresh load against the newly published CDN payload.
Sources: src/components/finders/v2/LivePreview.tsx, src/components/finders/v2/previewPayload.ts
Phase 4 — Draft save
The owner clicks Save (or save is triggered automatically before publish).
const saveDirty = async () => {
await Promise.all([
updateFinder(finderId, currentPayload),
// if there are pending location ID changes:
updateMap(activeMapId, { location_ids: Array.from(pendingLocationIds) }),
]);
savedFingerprint.current = JSON.stringify(currentPayload);
};updateFinder calls:
PUT /api/finders/:idHeaders injected by fetchAPI:
| Header | Value |
|---|---|
Authorization | Bearer <JWT> |
X-Audit-Request-Id | UUID (per-request) |
X-Audit-Action | "finders.update" |
X-Audit-Resource-Type | "finder" |
X-Audit-Resource-Id | finder id |
The backend RecordAuditLog middleware intercepts these headers and creates an AuditLog record linking the mutation to the authenticated user.
On success:
queryClient.invalidateQueries({ queryKey: ['finder', finderId] });
queryClient.invalidateQueries({ queryKey: ['finders'] });
queryClient.invalidateQueries({ queryKey: ['map', activeMapId] });
addAlert('Draft saved.', 'success');What save does NOT do: it does not regenerate the CDN payload. After a save, the visitor-facing widget still serves the previously published version. The builder shows an “Unpublished changes” indicator.
Sources: src/components/finders/v2/FinderBuilderV2.tsx:451-470, src/lib/api/finders.ts:29-35, src/lib/api/core.ts:96-120
Phase 5 — Publish (CDN payload regeneration)
The owner clicks Publish. The publish mutation runs:
const publishMutation = useMutation({
mutationFn: async () => {
if (isDirty) await saveDirty(); // ensure draft is committed first
return syncFinder(finder.token!);
},
...
});syncFinder calls:
POST /api/ext/finders/details/{token}/syncThis hits ExternalController::syncFinderDetails on the backend. The controller:
-
Resolves the finder by token, confirms it belongs to an authorized workspace.
-
Calls
buildPayload($finder)— assembles the full JSON payload from the finder’s settings, attached maps, locations, custom field definitions, etc. -
Computes the content hash:
substr(sha1(json_encode($hashPayload)), 0, 12). -
Stores the payload in Laravel
Cacheunder'finder_details_' . $key(serves as origin API fallback). -
Uploads to Cloudflare R2 via the
r2Storage disk:File Path Cache-Control Immutable versioned payload finders/{key}/v/{hash}.jsonmax-age=31536000, immutableShort-TTL pointer finders/{key}/config.jsonmax-age=60 -
Returns
{ hash, published_at, cdn_synced: true/false }.
Back in the frontend, on success:
publishedFingerprint.current = savedFingerprint.current;
previewRef.current?.reload();
addAlert(`Finder published · v${data.hash.slice(0, 7)}`, 'success');The finder is now live. Visitors whose pointer TTL has expired (≤60s after publish) will pick up the new hash on their next pointer fetch and load the new payload.
Sources: src/components/finders/v2/FinderBuilderV2.tsx:473-489, src/lib/api/finders.ts:107-113, dropafinder-app-backend/app/Http/Controllers/ExternalController.php:164-212
Full sequence diagram
Browser (FinderBuilderV2) Backend API R2 CDN / Cache
| | |
|-- GET /finders/:id -------------->| |
|<-- finder JSON ------------------| |
| parse V2Config; init history | |
| | |
| [owner edits → dispatch SET] | |
| [undo/redo → dispatch UNDO/REDO] | |
| [postMessage → preview iframe] | |
| | |
|-- PUT /finders/:id + audit hdrs ->| |
|<-- updated finder ---------------| |
| invalidate TanStack Query cache | |
| | |
|-- POST /api/ext/finders/details/{token}/sync -->| |
| buildPayload() | |
| sha1 hash | |
| Cache::put() (Laravel Cache — origin fallback store)
| Storage::put() -----------------------> |
| finders/{key}/v/{hash}.json (immutable)
| finders/{key}/config.json (60s TTL)
|<-- { hash, published_at } --------| |
| reload preview iframe | |
| show "Published · v{hash}" toast | |Key invariants
- Save ≠ Publish. A save writes the draft to the database. A publish regenerates the CDN payload from whatever is currently saved. Calling publish on a dirty builder first saves, then syncs — so publish is always two backend calls when there are unsaved changes.
- Audit headers are client-set. The
X-Audit-Actionand related headers are set byfetchAPIin the frontend, not the backend. TheRecordAuditLogmiddleware trusts these headers from authenticated requests. They are not signed — any authenticated user could fabricate them. This is an accepted design trade-off (convenience vs. tamper-proof audit). - CDN propagation delay. After publish, the pointer file has a
max-age=60TTL. Visitors with a cached pointer may continue to see the old version for up to 60 seconds. The immutable versioned payload has a year-long TTL and is never invalidated — new publishes generate a new hash, leaving old payloads in place (orphaned but harmless).