These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

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:

ActionEffect
{ 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:

  1. sessionStorage payload: buildFinderPreviewPayload() derives a preview-mode payload from the current V2Config. This is serialized into sessionStorage under a session-scoped key.

  2. postMessage bridge: LivePreview.tsx posts messages to the iframe’s contentWindow:

    • '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/:id

Headers injected by fetchAPI:

HeaderValue
AuthorizationBearer <JWT>
X-Audit-Request-IdUUID (per-request)
X-Audit-Action"finders.update"
X-Audit-Resource-Type"finder"
X-Audit-Resource-Idfinder 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}/sync

This hits ExternalController::syncFinderDetails on the backend. The controller:

  1. Resolves the finder by token, confirms it belongs to an authorized workspace.

  2. Calls buildPayload($finder) — assembles the full JSON payload from the finder’s settings, attached maps, locations, custom field definitions, etc.

  3. Computes the content hash: substr(sha1(json_encode($hashPayload)), 0, 12).

  4. Stores the payload in Laravel Cache under 'finder_details_' . $key (serves as origin API fallback).

  5. Uploads to Cloudflare R2 via the r2 Storage disk:

    FilePathCache-Control
    Immutable versioned payloadfinders/{key}/v/{hash}.jsonmax-age=31536000, immutable
    Short-TTL pointerfinders/{key}/config.jsonmax-age=60
  6. 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-Action and related headers are set by fetchAPI in the frontend, not the backend. The RecordAuditLog middleware 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=60 TTL. 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).