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 family | Complete lifecycle | Optional extension |
|---|---|---|
| Chart workspace layouts | listLayouts, saveLayout, loadLayout, deleteLayout | None |
| Drawings | saveDrawings, loadDrawings | getDrawingSharingPermissions, subscribeDrawings |
| Templates | listTemplates, saveTemplate, loadTemplate, deleteTemplate | None |
| User settings | loadUserSettings, saveUserSettings, deleteUserSettings | None |
| Replay state | loadReplayState, saveReplayState | None |
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:
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
nullonly when a load found no record. - Reject authentication, authorization, transport, timeout, and validation failures.
- Return a
SaveLayoutResultwith the backend-assigned id after chart workspace layout create. - Return
{ revision, savedAt }after a separate drawing save, then compare the nextbaseRevisionon 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:
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
- Chart storage — compare the local, REST, and custom integration paths.
- Test chart storage — prove CRUD, isolation, conflicts, and teardown.
- Drawing storage — implement revisions, permissions, and optional pushed updates.