Skip to content

Data Flow

This page traces how entity data moves between the backend, the cache, the list controller, and the workspace UI — and how mutations flow back the other way without manual refresh wiring. The unifying idea is mutation announcement + subscription: writers announce what changed, the cache service brokers the announcement against everyone who subscribed, and controllers re-read.

Read path

EntityListController<T> holds the query lifecycle (page, page size, search, sort, filters, drafts toggle) as raw MobX observables and uses cached_query_flutter for stale-while-revalidate streaming. The controller exposes a Stream<QueryStatus<List<T>>> that EntityView(query:) subscribes to. While the cache reissues a request in the background, EntityView keeps showing cached data and surfaces a LinearProgressIndicator.

EntityView<T> accepts four data-source modes: future, stream, data, and query. The query mode is the default for list / detail surfaces because it integrates with the cache's invalidation events.

EntityApi and HttpEntityApi

EntityApi<T> is the abstract CRUD contract:

  • getOne, getMany, create, update, delete, count, findPosition.
  • getDrafts, countDrafts for Draftable entities.
  • entityType, schemaType, defaultSortField, hasDrafts.

HttpEntityApi<T> implements it over REST. It composes:

  • An EndpointBuilder that constructs URIs (getMany, getOne(id), create, update(id), delete(id), count, findPosition(id) and custom paths via buildUri).
  • A CachingPolicy (staleDuration, excludedOperations) — override on your subclass to change behaviour, or use CachingPolicy.none to disable.
  • A CacheKeyBuilder that namespaces every key under entityType.

For non-CRUD endpoints (workflow transitions, custom actions), HttpEntityApi exposes typed helpers (post, put, patch, plus mutate(uri:, method:, body:, announces:)) that take a Set<Mutation> to announce on success. This is how a custom workflow API keeps the cache coherent without touching the read site.

The cache service

QueryCacheService is the central facade. It runs two cooperating caches:

  1. Lightweight one-shot cacheMap<String, _CacheEntry> plus in-flight deduplication. Used by HttpEntityApi.cachedGet.
  2. Reactive observable cache — backed by cached_query_flutter. Used by QueryCacheService.observe(...) to produce ObservableStream<QueryStatus<T>> for MobX widgets.

Both caches share a single subscription / announcement registry, so a mutation invalidates entries regardless of which path produced them.

Mutation announcement and subscription

Mutation carries (entityType, MutationType, entityId?). The three types are create, update, delete. A subscription with a non-null entityId matches only that id (or an entity-wide announcement). Mutation.any(type) matches any MutationType for the type.

HttpEntityApi.create / update / delete automatically fan out the right announcements. Custom write endpoints declare their announcements explicitly through the announces: parameter on post / put / patch.

InvalidationEvent stream

QueryCacheService.invalidations is a broadcast Stream<InvalidationEvent>. Every workspace template subscribes to it through WorkspaceTemplate's shared infrastructure and dispatches typed MutationEvents (EntityCreated, EntityUpdated, EntityDeleted) to the workspace's onAfterMutation callback. This is how the UI auto-refreshes after CRUD operations or SSE pushes — no manual refresh() calls needed.

EntityListController

EntityListController<T> owns four concerns: query lifecycle, query params, selection, and counts. It deliberately does not own detail data, tab state, or panel control — those belong in widgets.

Key state (raw Observables, never exposed directly):

  • Query: _currentQuery, _querySub, _queryStream, _isLoading, _entities.
  • Params: _currentPage, _pageSize, _searchQuery, _sortField, _sortAscending, _showDrafts, plus an EntityFilterState.
  • Selection: _selectedEntityId, _selectedEntityIds, _selectedEntityDetail, _enableMultiSelect.
  • Counts: _totalCount.

A baseFilter (always-applied, invisible) lets a workspace scope itself to a fixed slice (e.g. an Inbox tab restricted to a particular activity type) without polluting the user-facing filter UI. The controller also subscribes to QueryCacheService.invalidations and re-issues the current query when its entity type is invalidated.

Drafts and versioning

For entities with Draftable, EntityApi.getDrafts and countDrafts power the "Drafts" tab. EntityListController exposes a _showDrafts toggle that swaps the underlying call. Each entity comes back hydrated with a DraftMetadata payload (status, operation, workflow link, revision comments, canEditDraft); UI uses these to drive draft badges, banners, and edit gating.

The full lifecycle (draft → underReview → approved → effective, or rejected / cancelled / failed terminal states) is described on the drafts & versioning guide.

EntityVersion and EntityAudit records back the version-history and audit-trail tabs. Both expose user attribution; the EntityNameCacheService resolves display names client-side without per-row API calls.

Editor write path

A StandardEntityEditor<T> calls api.create or api.update directly. A SignatureDrivenEditor<T> first resolves a LifecycleRequirements value, and when verification is required it wraps the save inside the signature service so remarks flow back into the API call:

When approvals are configured for the operation, SignatureDrivenEditor short-circuits verification and saves to draft instead — verification happens later through the workflow system.

Realtime updates

EntityRealtimeService is the polling-based realtime channel registered by the plugin. Features that need push semantics can wire SSE or WebSocket sources to call QueryCacheService.invalidateEntity(entityType) or invalidateById(entityType, id). From there the existing subscription path takes over and everything that observes those keys re-fetches.

Error handling

HttpEntityApi surfaces failures as ApiResponseException (or richer typed errors when the backend returns an ApiErrorResponse envelope). The UI package's ErrorDisplayService resolves any error into a DisplayableError for consistent rendering. Always surface failures through ErrorResolver.resolve(e).message and showApiErrorDialog — never raw exception strings, and never SnackBar (the app forbids it).

Next steps