Skip to main content

Custom chart storage adapter

Implement ChartStorageAdapter directly when your backend does not match the built-in REST convention or when it must push drawing updates. Every method is optional, so expose one complete object family at a time.

This adapter covers chart-owned state. To customize persistence for the outer widget panel arrangement used by WidgetLayoutContainer or Trading Terminal, implement the separate WidgetLayoutStorageAdapter described in Widget layout storage.

Choose complete capabilities

Object familyComplete lifecycleOptional extension
Chart workspace layoutslistLayouts, saveLayout, loadLayout, deleteLayoutNone
DrawingssaveDrawings, loadDrawingsgetDrawingSharingPermissions, subscribeDrawings
TemplateslistTemplates, saveTemplate, loadTemplate, deleteTemplateNone
User settingsloadUserSettings, saveUserSettings, deleteUserSettingsNone
Replay stateloadReplayState, saveReplayStateNone

An intentionally partial adapter is valid, but the corresponding built-in UI may require the whole lifecycle. Check chart.getStorageCapabilities() before rendering host controls.

Bind authenticated scope in the adapter

ChartStorageContext fields are optional routing context. A controller call is not guaranteed to include userId or workspaceId, and a browser-supplied id must never authorize access.

Create the adapter inside the authenticated application session. Close over an API client that already carries current credentials and tenant scope:

customStorage.ts
import type {
ChartLayout,
ChartLayoutSummary,
ChartStorageAdapter,
} from '@tradescript/pro/sdk';

interface StoredLayout {
storageId: string;
layout: ChartLayout;
timestamp: number;
}

interface LayoutStorageClient {
list(): Promise<StoredLayout[]>;
create(layout: ChartLayout): Promise<StoredLayout>;
update(storageId: string, layout: ChartLayout): Promise<StoredLayout>;
load(storageId: string): Promise<StoredLayout | null>;
delete(storageId: string): Promise<void>;
}

export function createChartStorage(
client: LayoutStorageClient,
): ChartStorageAdapter {
return {
async listLayouts(): Promise<ChartLayoutSummary[]> {
return (await client.list()).map(({ storageId, layout, timestamp }) => ({
id: storageId,
name: layout.name,
symbol: layout.charts[0]?.symbol.ticker,
interval: layout.charts[0]?.interval,
chartId: layout.charts[0]?.id,
timestamp,
}));
},

async saveLayout(request) {
const logicalId = request.ref?.layoutId ?? request.layout.id;
if (request.operation === 'update') {
const storageId = request.ref?.storageId;
if (!storageId) {
throw new Error('Chart layout update requires ref.storageId');
}
const saved = await client.update(storageId, request.layout);
return { layoutId: logicalId, storageId: saved.storageId };
}

const saved = await client.create(request.layout);
return { layoutId: logicalId, storageId: saved.storageId };
},

async loadLayout({ layoutId }) {
return (await client.load(layoutId))?.layout ?? null;
},

async deleteLayout({ layoutId }) {
await client.delete(layoutId);
},
};
}

listLayouts() returns backend record ids. The chart workspace controller records their mapping to logical chart workspace layout ids. Later load and delete requests therefore receive the backend id in layoutId; later update requests carry it explicitly as ref.storageId.

Validate every network response before it becomes a public SDK object. The typed example assumes LayoutStorageClient performs that validation.

Preserve outcome distinctions

  • Return null only when a load found no record.
  • Reject authentication, authorization, transport, timeout, and validation failures.
  • Return a SaveLayoutResult with the backend-assigned id after chart workspace layout create.
  • Return { revision, savedAt } after a separate drawing save, then compare the next baseRevision on the server.
  • Omit unsupported methods instead of resolving fake success.

Chart workspace layout, template, user-setting, and replay-state writes are last-write-wins unless your custom backend defines a stronger contract. Drawing storage has an explicit optimistic-concurrency flow; see Drawing storage.

Stream drawing updates safely

subscribeDrawings(request, callback) is available only on custom adapters. The built-in REST adapter does not create a stream.

The subscription must:

  • listen to the exact requested sharing bucket;
  • runtime-validate every emitted DrawingState;
  • avoid echoing the caller's own save as a second remote mutation unless the backend protocol identifies and de-duplicates it;
  • return an unsubscribe function, including after asynchronous setup;
  • close sockets, listeners, and timers when unsubscribed.

The SDK tears down subscriptions when chart context changes or the controller is destroyed. Late loads are ignored by controller sequence and context guards, but the adapter should still cancel avoidable work.

Add custom user-setting methods

User-setting calls include chart, symbol, and interval context but are not guaranteed to carry user identity. Bind the authenticated session in the custom adapter and runtime-validate the loaded state:

userSettingsStorage.ts
import type {
ChartStorageAdapter,
UserSettingsState,
} from '@tradescript/pro/sdk';

declare const session: { workspaceId: string };

function parseUserSettings(value: unknown): UserSettingsState {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Invalid user-settings response');
}
const settings = value as { values?: unknown };
if (!settings.values || typeof settings.values !== 'object' || Array.isArray(settings.values)) {
throw new Error('Invalid user-settings values');
}
return value as UserSettingsState;
}

const endpoint = () =>
`/api/me/chart-settings?workspace=${encodeURIComponent(session.workspaceId)}`;

export const userSettingMethods = {
async loadUserSettings() {
const response = await fetch(endpoint(), { credentials: 'include' });
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Settings load failed: ${response.status}`);
}
return parseUserSettings(await response.json());
},

async saveUserSettings({ settings }) {
const response = await fetch(endpoint(), {
method: 'PUT',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error(`Settings save failed: ${response.status}`);
}
},

async deleteUserSettings({ keys }) {
const response = await fetch(endpoint(), {
method: 'DELETE',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ keys }),
});
if (!response.ok) {
throw new Error(`Settings delete failed: ${response.status}`);
}
},
} satisfies Pick<
ChartStorageAdapter,
'loadUserSettings' | 'saveUserSettings' | 'deleteUserSettings'
>;

Return null only for a genuine missing record. Reject permission, transport, and invalid-payload failures. Merge these methods into the one adapter supplied to the widget; the server derives the user from the authenticated session and treats workspace context only as routing input.

Combine object families

Keep one adapter object as the storage authority. Compose method groups into it when separate backend clients own different resources:

const storage: ChartStorageAdapter = {
...layoutMethods,
...drawingMethods,
...templateMethods,
...userSettingMethods,
...replayMethods,
};

Do not combine two implementations of the same method. If the built-in REST routes cover persistence but drawings also need pushed updates, delegate the REST methods and add subscribeDrawings. Implement methods directly when the backend uses different routes or transports. The composed adapter remains the single chart-storage boundary.

Next steps