Skip to main content

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 id is the Portal Item id.
  • The workspace document is stored in the item's JSON data field 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/portalItemId are the item id, and the name/description are the item's title/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;
}
  • UploadaddIconResource writes the image under icons/<id>.png on 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 / resolvelistIconResources returns the stored paths, and resolveIconResourceUrl turns a path into a URL for rendering.
  • RemoveremoveIconResource deletes 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.