Persistence
Workspaces are saved through a small storage abstraction. State and UI code
depend only on the WorkspacePersistenceProvider interface, so the backing
store stays isolated and swappable.
Source: src/workspaces/persistence/
The provider interface
interface WorkspacePersistenceProvider {
/** Stable id of the backing store, e.g. "arcgis-item". */
readonly id: string;
/** List summaries of all workspaces available to the user. */
listWorkspaces(userId: string): Promise<WorkspaceSummary[]>;
/** Load a full workspace, or null if it does not exist. */
getWorkspace(
userId: string,
workspaceId: string,
): Promise<WorkspaceConfig | null>;
/** Create or update a workspace; returns the stored workspace. */
saveWorkspace(
userId: string,
workspace: WorkspaceConfig,
): Promise<WorkspaceConfig>;
/** Delete a workspace. No-op if it does not exist. */
deleteWorkspace(userId: string, workspaceId: string): Promise<void>;
// --- Custom icon resources (see "Custom icons" below) ---
/** Upload an icon image to the workspace's item; returns its path + URL. */
addIconResource(
workspaceId: string,
blob: Blob,
): Promise<WorkspaceIconResource>;
/** List the resource paths of the workspace's uploaded icons. */
listIconResources(workspaceId: string): Promise<string[]>;
/** Resolve an icon resource path to an `<img>`-ready URL, or null. */
resolveIconResourceUrl(
workspaceId: string,
path: string,
): Promise<string | null>;
/** Remove an uploaded icon by its resource path. No-op if absent. */
removeIconResource(workspaceId: string, path: string): Promise<void>;
}
The userId is the signed-in user's portal username. On saveWorkspace, if the
workspace has no backing item yet (no portalItemId), the implementation creates
a new item and returns a workspace whose id and portalItemId reflect it;
otherwise it updates the existing item.
The active store: ArcGIS Portal Items
The active implementation is
ArcGisItemWorkspacePersistenceProvider,
exported as a singleton:
import { workspacePersistence } from "@/workspaces/persistence";
How it stores data:
- Each workspace is an ArcGIS Portal Item in the signed-in user's account, so workspaces roam with the portal account.
- The workspace
idis the Portal Item id. - The workspace document is stored in the item's JSON
datafield as a flat, versioned envelope:{ schemaVersion, app, activeViewId, views, createdAt, updatedAt }. - The workspace identity and label are not duplicated in the data blob: the
id/portalItemIdare the item id, and thename/descriptionare the item'stitle/snippet. They are injected when the item loads, so the item metadata stays the single source of truth. - Item metadata mirrors the workspace: title ←
name, snippet ←description. - Items are tagged with a
typeKeyword(fusion-analyst-workspace) so the provider can query just its own items.
Portal injection
The provider needs the authenticated portal. The WorkspacesProvider injects it
whenever the session changes:
workspacePersistence.setPortal(portal); // portal: __esri.Portal | null
All token handling is delegated to the ArcGIS SDK's IdentityManager, which the
auth provider configures. Tokens are never stored
by the persistence layer.
Schema and versioning
The stored envelope is validated on load
(workspaceSchema.ts),
and WorkspaceConfig.schemaVersion carries the document version so older items
can be migrated forward as the model evolves. The current version is 1: a
flat envelope holding the workspace's views, activeViewId, and timestamps.
On load each view's groups are sanitized — dangling member ids are dropped and
groups that fall below two valid members are dissolved — so a malformed document
can never crash the canvas. A document whose schemaVersion is newer than the
app supports is rejected rather than silently misread.
Custom icons
Widgets that let a user pick an icon (the Indicator value icon and List item/template icons) accept either a built-in Lucide icon or a custom icon the user uploads. Custom icons are stored as resources on the workspace's portal item, so they roam with the workspace and are shared by every widget in it.
An uploaded icon is referenced by an IconRef:
type IconRef =
| { kind: "lucide"; name: string } // built-in, by kebab-case name
| { kind: "resource"; path: string } // uploaded, by item resource path
| { kind: "layer-symbol" }; // the referenced layer's renderer symbol
The layer-symbol kind stores no image — only the intent to use the
widget's referenced feature layer symbol. It is resolved live at render time
against that layer's current renderer (per-feature for List rows), so author
changes to the layer's symbology in the webmap appear on the next load. The
layer's metadata is loaded once per session (shared with the widget's data
queries) and each distinct symbol is rasterized once to a blob: URL, so no
extra network requests are made beyond the layer load that already happens.
The List widget can be backed by a layer inside a web map instead of a
feature service (config field sourceKind: "web-map" with webMapId /
webMapLayerId). The layer is resolved directly from the web map by its
operational-layer id — no portal item is required, so it works whether the web
map references its service by item or by URL. All data, fields, filters and
queries behave identically to feature-service mode, and the layer-symbol
icon's renderer is read from that layer's web-map styling (which can differ from
its source service). The web map and its layer are loaded once per session and
cached like feature layers, so the icon likewise resolves live.
The provider exposes four methods for managing these resources:
interface WorkspaceIconResource {
/** Resource path under the item, e.g. "icons/ab12cd34.png". */
path: string;
/** An <img>-ready URL for the uploaded image. */
url: string;
}
- Upload —
addIconResourcewrites the image undericons/<id>.pngon the item. The picker first crops the chosen file to a square and rasterizes it to a 256×256 PNG; rasterizing also neutralizes any script embedded in an SVG upload. - List / resolve —
listIconResourcesreturns the stored paths, andresolveIconResourceUrlturns a path into a URL for rendering. - Remove —
removeIconResourcedeletes the resource from the item.
Because a workspace item may be private, a bare image URL would carry no auth
token. The provider therefore fetches each resource as a blob through the
SDK-authenticated PortalItemResource, wraps it in an object URL
(URL.createObjectURL), and caches that URL per item + path for reuse. The
template renderer's HTML sanitizer is configured to allow blob: image sources
so these object URLs survive sanitization.
Swapping the store
Because consumers depend only on the interface (plus setPortal), an alternative
store — for example a localStorage provider for offline development — can be
implemented against WorkspacePersistenceProvider and substituted for the
singleton without touching state or UI code.