chart data loading
This commit is contained in:
190
gateway/src/workspace/container-sync.ts
Normal file
190
gateway/src/workspace/container-sync.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Container Sync
|
||||
*
|
||||
* Handles synchronization of persistent workspace stores with the user container
|
||||
* via MCP tools. Persistent stores (chartStore, userPreferences, etc.) are
|
||||
* stored in the container and loaded/saved via MCP tool calls.
|
||||
*
|
||||
* Container-side storage: /data/workspace/{store_name}.json
|
||||
*
|
||||
* MCP Tools used:
|
||||
* - workspace_read(store_name) -> dict
|
||||
* - workspace_write(store_name, data) -> None
|
||||
* - workspace_patch(store_name, patch) -> dict (new state)
|
||||
*/
|
||||
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Operation as JsonPatchOp } from 'fast-json-patch';
|
||||
import type { MCPClientConnector } from '../harness/mcp-client.js';
|
||||
|
||||
/**
|
||||
* Result of loading a store from the container.
|
||||
*/
|
||||
export interface LoadResult {
|
||||
exists: boolean;
|
||||
state?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of saving a store to the container.
|
||||
*/
|
||||
export interface SaveResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of patching a store in the container.
|
||||
*/
|
||||
export interface PatchResult {
|
||||
success: boolean;
|
||||
newState?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles synchronization with the user's container via MCP.
|
||||
*/
|
||||
export class ContainerSync {
|
||||
private mcpClient: MCPClientConnector;
|
||||
private logger: FastifyBaseLogger;
|
||||
|
||||
constructor(mcpClient: MCPClientConnector, logger: FastifyBaseLogger) {
|
||||
this.mcpClient = mcpClient;
|
||||
this.logger = logger.child({ component: 'ContainerSync' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a workspace store from the container.
|
||||
* Returns the stored state or indicates the store doesn't exist.
|
||||
*/
|
||||
async loadStore(storeName: string): Promise<LoadResult> {
|
||||
if (!this.mcpClient.isConnected()) {
|
||||
this.logger.warn({ store: storeName }, 'MCP client not connected, cannot load store');
|
||||
return { exists: false, error: 'MCP client not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug({ store: storeName }, 'Loading store from container');
|
||||
|
||||
const result = (await this.mcpClient.callTool('workspace_read', {
|
||||
store_name: storeName,
|
||||
})) as { exists: boolean; data?: unknown; error?: string };
|
||||
|
||||
if (result.error) {
|
||||
this.logger.warn({ store: storeName, error: result.error }, 'Container returned error');
|
||||
return { exists: false, error: result.error };
|
||||
}
|
||||
|
||||
if (!result.exists) {
|
||||
this.logger.debug({ store: storeName }, 'Store does not exist in container');
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
this.logger.debug({ store: storeName }, 'Loaded store from container');
|
||||
return { exists: true, state: result.data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error({ store: storeName, error: message }, 'Failed to load store from container');
|
||||
return { exists: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a workspace store to the container.
|
||||
* Overwrites any existing state.
|
||||
*/
|
||||
async saveStore(storeName: string, state: unknown): Promise<SaveResult> {
|
||||
if (!this.mcpClient.isConnected()) {
|
||||
this.logger.warn({ store: storeName }, 'MCP client not connected, cannot save store');
|
||||
return { success: false, error: 'MCP client not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug({ store: storeName }, 'Saving store to container');
|
||||
|
||||
const result = (await this.mcpClient.callTool('workspace_write', {
|
||||
store_name: storeName,
|
||||
data: state,
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
if (result.error || !result.success) {
|
||||
this.logger.warn({ store: storeName, error: result.error }, 'Failed to save store');
|
||||
return { success: false, error: result.error || 'Unknown error' };
|
||||
}
|
||||
|
||||
this.logger.debug({ store: storeName }, 'Saved store to container');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error({ store: storeName, error: message }, 'Failed to save store to container');
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a JSON patch to a store in the container.
|
||||
* Returns the new state after applying the patch.
|
||||
*/
|
||||
async patchStore(storeName: string, patch: JsonPatchOp[]): Promise<PatchResult> {
|
||||
if (!this.mcpClient.isConnected()) {
|
||||
this.logger.warn({ store: storeName }, 'MCP client not connected, cannot patch store');
|
||||
return { success: false, error: 'MCP client not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug({ store: storeName, patchOps: patch.length }, 'Patching store in container');
|
||||
|
||||
const result = (await this.mcpClient.callTool('workspace_patch', {
|
||||
store_name: storeName,
|
||||
patch,
|
||||
})) as { success: boolean; data?: unknown; error?: string };
|
||||
|
||||
if (result.error || !result.success) {
|
||||
this.logger.warn({ store: storeName, error: result.error }, 'Failed to patch store');
|
||||
return { success: false, error: result.error || 'Unknown error' };
|
||||
}
|
||||
|
||||
this.logger.debug({ store: storeName }, 'Patched store in container');
|
||||
return { success: true, newState: result.data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error({ store: storeName, error: message }, 'Failed to patch store in container');
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all persistent stores from the container.
|
||||
* Returns a map of store name -> state.
|
||||
*/
|
||||
async loadAllStores(storeNames: string[]): Promise<Map<string, unknown>> {
|
||||
const states = new Map<string, unknown>();
|
||||
|
||||
for (const storeName of storeNames) {
|
||||
const result = await this.loadStore(storeName);
|
||||
if (result.exists && result.state !== undefined) {
|
||||
states.set(storeName, result.state);
|
||||
}
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all persistent stores to the container.
|
||||
*/
|
||||
async saveAllStores(stores: Map<string, unknown>): Promise<void> {
|
||||
for (const [storeName, state] of stores) {
|
||||
await this.saveStore(storeName, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MCP client is connected.
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.mcpClient.isConnected();
|
||||
}
|
||||
}
|
||||
86
gateway/src/workspace/index.ts
Normal file
86
gateway/src/workspace/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Workspace Module
|
||||
*
|
||||
* Provides two-way state synchronization between web clients, gateway, and user containers.
|
||||
*
|
||||
* Key components:
|
||||
* - WorkspaceManager: Per-session state manager with channel-agnostic interface
|
||||
* - SyncRegistry: Handles JSON patch sync protocol
|
||||
* - ContainerSync: Persists state to user containers via MCP
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { WorkspaceManager, ContainerSync, DEFAULT_STORES } from './workspace/index.js';
|
||||
*
|
||||
* // Create container sync (optional, for persistent stores)
|
||||
* const containerSync = new ContainerSync(mcpClient, logger);
|
||||
*
|
||||
* // Create workspace manager for session
|
||||
* const workspace = new WorkspaceManager({
|
||||
* userId: 'user-123',
|
||||
* sessionId: 'session-456',
|
||||
* stores: DEFAULT_STORES,
|
||||
* containerSync,
|
||||
* logger,
|
||||
* });
|
||||
*
|
||||
* // Initialize (loads persistent stores from container)
|
||||
* await workspace.initialize();
|
||||
*
|
||||
* // Attach channel adapter
|
||||
* workspace.setAdapter({
|
||||
* sendSnapshot: (msg) => socket.send(JSON.stringify(msg)),
|
||||
* sendPatch: (msg) => socket.send(JSON.stringify(msg)),
|
||||
* getCapabilities: () => ({ supportsSync: true, ... }),
|
||||
* });
|
||||
*
|
||||
* // Handle sync messages from client
|
||||
* workspace.handleHello(clientSeqs);
|
||||
* workspace.handlePatch(storeName, seq, patch);
|
||||
*
|
||||
* // Access state
|
||||
* const chartState = workspace.getState('chartState');
|
||||
* await workspace.setState('chartState', newState);
|
||||
*
|
||||
* // Register triggers (future use)
|
||||
* const unsub = workspace.onPathChange('/chartState/symbol', (old, new, ctx) => {
|
||||
* console.log('Symbol changed:', old, '->', new);
|
||||
* });
|
||||
*
|
||||
* // Cleanup
|
||||
* await workspace.shutdown();
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
SnapshotMessage,
|
||||
PatchMessage,
|
||||
HelloMessage,
|
||||
InboundSyncMessage,
|
||||
OutboundSyncMessage,
|
||||
StoreConfig,
|
||||
ChannelAdapter,
|
||||
ChannelCapabilities,
|
||||
PathTrigger,
|
||||
PathTriggerHandler,
|
||||
PathTriggerContext,
|
||||
ChartState,
|
||||
ChartStore,
|
||||
ChannelState,
|
||||
ChannelInfo,
|
||||
WorkspaceStores,
|
||||
} from './types.js';
|
||||
|
||||
export { DEFAULT_STORES } from './types.js';
|
||||
|
||||
// Sync registry
|
||||
export { SyncRegistry } from './sync-registry.js';
|
||||
|
||||
// Container sync
|
||||
export { ContainerSync } from './container-sync.js';
|
||||
export type { LoadResult, SaveResult, PatchResult } from './container-sync.js';
|
||||
|
||||
// Workspace manager
|
||||
export { WorkspaceManager } from './workspace-manager.js';
|
||||
export type { WorkspaceManagerConfig } from './workspace-manager.js';
|
||||
407
gateway/src/workspace/sync-registry.ts
Normal file
407
gateway/src/workspace/sync-registry.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Sync Registry
|
||||
*
|
||||
* Manages synchronized state stores with JSON patch-based updates.
|
||||
* Ported from backend.old/src/sync/registry.py.
|
||||
*
|
||||
* Key features:
|
||||
* - Sequence-numbered patches for reliable sync
|
||||
* - History buffer for catchup patches
|
||||
* - Conflict resolution (frontend wins)
|
||||
* - Optimistic updates with rollback on conflict
|
||||
*/
|
||||
|
||||
import type { Operation as JsonPatchOp } from 'fast-json-patch';
|
||||
import fastJsonPatch from 'fast-json-patch';
|
||||
const { applyPatch, compare: computePatch, deepClone } = fastJsonPatch;
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { SnapshotMessage, PatchMessage, StoreConfig } from './types.js';
|
||||
|
||||
/**
|
||||
* History entry: sequence number and the patch that produced it.
|
||||
*/
|
||||
interface HistoryEntry {
|
||||
seq: number;
|
||||
patch: JsonPatchOp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry for a single synchronized store.
|
||||
*/
|
||||
class SyncEntry {
|
||||
readonly storeName: string;
|
||||
private state: unknown;
|
||||
private seq: number = 0;
|
||||
private lastSnapshot: unknown;
|
||||
private history: HistoryEntry[] = [];
|
||||
private readonly historySize: number;
|
||||
|
||||
constructor(storeName: string, initialState: unknown, historySize: number = 50) {
|
||||
this.storeName = storeName;
|
||||
this.state = deepClone(initialState);
|
||||
this.lastSnapshot = deepClone(initialState);
|
||||
this.historySize = historySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state (deep clone to prevent mutation).
|
||||
*/
|
||||
getState(): unknown {
|
||||
return deepClone(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sequence number.
|
||||
*/
|
||||
getSeq(): number {
|
||||
return this.seq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set state directly (used for loading from container).
|
||||
* Resets sequence to 0.
|
||||
*/
|
||||
setState(newState: unknown): void {
|
||||
this.state = deepClone(newState);
|
||||
this.lastSnapshot = deepClone(newState);
|
||||
this.seq = 0;
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute patch from last snapshot to current state.
|
||||
* Returns null if no changes.
|
||||
*/
|
||||
computePatch(): JsonPatchOp[] | null {
|
||||
const currentState = deepClone(this.state);
|
||||
const patch = computePatch(this.lastSnapshot as any, currentState as any);
|
||||
return patch.length > 0 ? patch : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a patch to history and update snapshot.
|
||||
*/
|
||||
commitPatch(patch: JsonPatchOp[]): void {
|
||||
this.seq += 1;
|
||||
this.history.push({ seq: this.seq, patch });
|
||||
|
||||
// Trim history if needed
|
||||
while (this.history.length > this.historySize) {
|
||||
this.history.shift();
|
||||
}
|
||||
|
||||
this.lastSnapshot = deepClone(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get catchup patches since a given sequence.
|
||||
* Returns null if catchup not possible (need full snapshot).
|
||||
*/
|
||||
getCatchupPatches(sinceSeq: number): HistoryEntry[] | null {
|
||||
if (sinceSeq === this.seq) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if we have all patches needed
|
||||
if (this.history.length === 0 || this.history[0].seq > sinceSeq + 1) {
|
||||
return null; // Need full snapshot
|
||||
}
|
||||
|
||||
return this.history.filter((entry) => entry.seq > sinceSeq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch to state (used when applying local changes).
|
||||
*/
|
||||
applyPatch(patch: JsonPatchOp[]): void {
|
||||
const result = applyPatch(deepClone(this.state), patch, false, false);
|
||||
this.state = result.newDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply client patch with conflict resolution.
|
||||
* Returns the resolved state and any patches to send back.
|
||||
*/
|
||||
applyClientPatch(
|
||||
clientBaseSeq: number,
|
||||
patch: JsonPatchOp[],
|
||||
logger?: FastifyBaseLogger
|
||||
): { needsSnapshot: boolean; resolvedState?: unknown } {
|
||||
try {
|
||||
if (clientBaseSeq === this.seq) {
|
||||
// No conflict - apply directly
|
||||
const currentState = deepClone(this.state);
|
||||
const result = applyPatch(currentState, patch, false, false);
|
||||
this.state = result.newDocument;
|
||||
this.commitPatch(patch);
|
||||
logger?.debug(
|
||||
{ store: this.storeName, seq: this.seq },
|
||||
'Applied client patch without conflict'
|
||||
);
|
||||
return { needsSnapshot: false };
|
||||
}
|
||||
|
||||
if (clientBaseSeq < this.seq) {
|
||||
// Conflict! Frontend wins.
|
||||
logger?.debug(
|
||||
{ store: this.storeName, clientSeq: clientBaseSeq, serverSeq: this.seq },
|
||||
'Conflict detected, frontend wins'
|
||||
);
|
||||
|
||||
// Get backend patches since client's base
|
||||
const backendPatches: JsonPatchOp[][] = [];
|
||||
for (const entry of this.history) {
|
||||
if (entry.seq > clientBaseSeq) {
|
||||
backendPatches.push(entry.patch);
|
||||
}
|
||||
}
|
||||
|
||||
// Get paths modified by frontend
|
||||
const frontendPaths = new Set(patch.map((op) => op.path));
|
||||
|
||||
// Apply frontend patch first
|
||||
const currentState = deepClone(this.state);
|
||||
let newState: unknown;
|
||||
try {
|
||||
const result = applyPatch(currentState, patch, false, false);
|
||||
newState = result.newDocument;
|
||||
} catch (e) {
|
||||
logger?.warn(
|
||||
{ store: this.storeName, error: e },
|
||||
'Failed to apply client patch during conflict resolution'
|
||||
);
|
||||
return { needsSnapshot: true, resolvedState: this.state };
|
||||
}
|
||||
|
||||
// Re-apply backend patches that don't overlap with frontend
|
||||
for (const bPatch of backendPatches) {
|
||||
const filteredPatch = bPatch.filter((op) => !frontendPaths.has(op.path));
|
||||
if (filteredPatch.length > 0) {
|
||||
try {
|
||||
const result = applyPatch(deepClone(newState), filteredPatch, false, false);
|
||||
newState = result.newDocument;
|
||||
} catch (e) {
|
||||
logger?.debug(
|
||||
{ store: this.storeName, error: e },
|
||||
'Skipping backend patch during conflict resolution'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.state = newState;
|
||||
|
||||
// Compute final patch from last snapshot
|
||||
const finalPatch = computePatch(this.lastSnapshot as any, newState as any);
|
||||
if (finalPatch.length > 0) {
|
||||
this.commitPatch(finalPatch);
|
||||
}
|
||||
|
||||
// Send snapshot to converge
|
||||
return { needsSnapshot: true, resolvedState: this.state };
|
||||
}
|
||||
|
||||
// clientBaseSeq > this.seq - client is ahead, shouldn't happen
|
||||
logger?.warn(
|
||||
{ store: this.storeName, clientSeq: clientBaseSeq, serverSeq: this.seq },
|
||||
'Client ahead of server, sending snapshot'
|
||||
);
|
||||
return { needsSnapshot: true, resolvedState: this.state };
|
||||
} catch (e) {
|
||||
logger?.error(
|
||||
{ store: this.storeName, error: e },
|
||||
'Unexpected error applying client patch'
|
||||
);
|
||||
return { needsSnapshot: true, resolvedState: this.state };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry managing multiple synchronized stores.
|
||||
*/
|
||||
export class SyncRegistry {
|
||||
private entries = new Map<string, SyncEntry>();
|
||||
private logger?: FastifyBaseLogger;
|
||||
|
||||
constructor(logger?: FastifyBaseLogger) {
|
||||
this.logger = logger?.child({ component: 'SyncRegistry' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a store with initial state.
|
||||
*/
|
||||
register(config: StoreConfig): void {
|
||||
const entry = new SyncEntry(config.name, config.initialState());
|
||||
this.entries.set(config.name, entry);
|
||||
this.logger?.debug({ store: config.name }, 'Registered store');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a store is registered.
|
||||
*/
|
||||
has(storeName: string): boolean {
|
||||
return this.entries.has(storeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state of a store.
|
||||
*/
|
||||
getState<T = unknown>(storeName: string): T | undefined {
|
||||
const entry = this.entries.get(storeName);
|
||||
return entry?.getState() as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sequence number for a store.
|
||||
*/
|
||||
getSeq(storeName: string): number {
|
||||
const entry = this.entries.get(storeName);
|
||||
return entry?.getSeq() ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set state directly (used for loading from container).
|
||||
*/
|
||||
setState(storeName: string, state: unknown): void {
|
||||
const entry = this.entries.get(storeName);
|
||||
if (entry) {
|
||||
entry.setState(state);
|
||||
this.logger?.debug({ store: storeName }, 'Set store state');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state locally and compute patch.
|
||||
* Returns the patch if state changed, null otherwise.
|
||||
*/
|
||||
updateState(storeName: string, updater: (state: unknown) => unknown): JsonPatchOp[] | null {
|
||||
const entry = this.entries.get(storeName);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentState = entry.getState();
|
||||
const newState = updater(currentState);
|
||||
|
||||
// Compute patch
|
||||
const patch = computePatch(currentState as any, newState as any);
|
||||
if (patch.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Apply and commit
|
||||
entry.applyPatch(patch);
|
||||
entry.commitPatch(patch);
|
||||
|
||||
this.logger?.debug(
|
||||
{ store: storeName, seq: entry.getSeq(), patchOps: patch.length },
|
||||
'Updated store state'
|
||||
);
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync client based on their reported sequences.
|
||||
* Returns messages to send (snapshots or patches).
|
||||
*/
|
||||
syncClient(clientSeqs: Record<string, number>): (SnapshotMessage | PatchMessage)[] {
|
||||
const messages: (SnapshotMessage | PatchMessage)[] = [];
|
||||
|
||||
for (const [storeName, entry] of this.entries) {
|
||||
const clientSeq = clientSeqs[storeName] ?? -1;
|
||||
const catchupPatches = entry.getCatchupPatches(clientSeq);
|
||||
|
||||
if (catchupPatches === null) {
|
||||
// Need full snapshot
|
||||
messages.push({
|
||||
type: 'snapshot',
|
||||
store: storeName,
|
||||
seq: entry.getSeq(),
|
||||
state: entry.getState(),
|
||||
});
|
||||
this.logger?.debug(
|
||||
{ store: storeName, clientSeq, serverSeq: entry.getSeq() },
|
||||
'Sending snapshot'
|
||||
);
|
||||
} else {
|
||||
// Send catchup patches
|
||||
for (const { seq, patch } of catchupPatches) {
|
||||
messages.push({
|
||||
type: 'patch',
|
||||
store: storeName,
|
||||
seq,
|
||||
patch,
|
||||
});
|
||||
}
|
||||
if (catchupPatches.length > 0) {
|
||||
this.logger?.debug(
|
||||
{ store: storeName, patchCount: catchupPatches.length },
|
||||
'Sending catchup patches'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch from the client.
|
||||
* Returns message to send back (snapshot if conflict, null otherwise).
|
||||
*/
|
||||
applyClientPatch(
|
||||
storeName: string,
|
||||
clientBaseSeq: number,
|
||||
patch: JsonPatchOp[]
|
||||
): SnapshotMessage | null {
|
||||
const entry = this.entries.get(storeName);
|
||||
if (!entry) {
|
||||
this.logger?.warn({ store: storeName }, 'Store not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = entry.applyClientPatch(clientBaseSeq, patch, this.logger);
|
||||
|
||||
if (result.needsSnapshot) {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
store: storeName,
|
||||
seq: entry.getSeq(),
|
||||
state: result.resolvedState ?? entry.getState(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered store names.
|
||||
*/
|
||||
getStoreNames(): string[] {
|
||||
return Array.from(this.entries.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all current sequences (for persistence).
|
||||
*/
|
||||
getAllSeqs(): Record<string, number> {
|
||||
const seqs: Record<string, number> = {};
|
||||
for (const [name, entry] of this.entries) {
|
||||
seqs[name] = entry.getSeq();
|
||||
}
|
||||
return seqs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all current states (for persistence).
|
||||
*/
|
||||
getAllStates(): Record<string, unknown> {
|
||||
const states: Record<string, unknown> = {};
|
||||
for (const [name, entry] of this.entries) {
|
||||
states[name] = entry.getState();
|
||||
}
|
||||
return states;
|
||||
}
|
||||
}
|
||||
239
gateway/src/workspace/types.ts
Normal file
239
gateway/src/workspace/types.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Workspace Sync Types
|
||||
*
|
||||
* Defines the protocol messages and abstractions for two-way state sync
|
||||
* between web clients, gateway, and user containers.
|
||||
*
|
||||
* The workspace is a unified namespace that:
|
||||
* - Syncs transient state (chartState) between client and gateway
|
||||
* - Syncs persistent state (chartStore) between client, gateway, and container
|
||||
* - Provides triggers for path changes (future use)
|
||||
* - Is channel-agnostic (works with WebSocket, Telegram, Slack, etc.)
|
||||
*/
|
||||
|
||||
import type { Operation as JsonPatchOp } from 'fast-json-patch';
|
||||
|
||||
// =============================================================================
|
||||
// Protocol Messages
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Full state snapshot for a store.
|
||||
* Sent when client connects or when catchup patches are unavailable.
|
||||
*/
|
||||
export interface SnapshotMessage {
|
||||
type: 'snapshot';
|
||||
store: string;
|
||||
seq: number;
|
||||
state: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental patch for a store.
|
||||
* Uses JSON Patch (RFC 6902) format.
|
||||
*/
|
||||
export interface PatchMessage {
|
||||
type: 'patch';
|
||||
store: string;
|
||||
seq: number;
|
||||
patch: JsonPatchOp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client hello message with current sequence numbers.
|
||||
* Sent on connect to request catchup patches or snapshots.
|
||||
*/
|
||||
export interface HelloMessage {
|
||||
type: 'hello';
|
||||
seqs: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Messages from client to gateway */
|
||||
export type InboundSyncMessage = HelloMessage | PatchMessage;
|
||||
|
||||
/** Messages from gateway to client */
|
||||
export type OutboundSyncMessage = SnapshotMessage | PatchMessage;
|
||||
|
||||
// =============================================================================
|
||||
// Store Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for a workspace store.
|
||||
*/
|
||||
export interface StoreConfig {
|
||||
/** Unique store name (e.g., 'chartState', 'chartStore') */
|
||||
name: string;
|
||||
|
||||
/** If true, store is persisted to user container via MCP */
|
||||
persistent: boolean;
|
||||
|
||||
/** Factory function returning initial state for new sessions */
|
||||
initialState: () => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default store configurations.
|
||||
* Additional stores can be registered at runtime.
|
||||
*/
|
||||
export const DEFAULT_STORES: StoreConfig[] = [
|
||||
{
|
||||
name: 'chartState',
|
||||
persistent: false,
|
||||
initialState: () => ({
|
||||
symbol: 'BINANCE:BTC/USDT',
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
interval: '15',
|
||||
selected_shapes: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'chartStore',
|
||||
persistent: true,
|
||||
initialState: () => ({
|
||||
drawings: {},
|
||||
templates: {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'channelState',
|
||||
persistent: false,
|
||||
initialState: () => ({
|
||||
connected: {},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Channel Adapter Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Capabilities that a channel may support.
|
||||
*/
|
||||
export interface ChannelCapabilities {
|
||||
/** Channel supports sync protocol (snapshot/patch messages) */
|
||||
supportsSync: boolean;
|
||||
|
||||
/** Channel supports sending images */
|
||||
supportsImages: boolean;
|
||||
|
||||
/** Channel supports markdown formatting */
|
||||
supportsMarkdown: boolean;
|
||||
|
||||
/** Channel supports streaming responses */
|
||||
supportsStreaming: boolean;
|
||||
|
||||
/** Channel supports TradingView chart embeds */
|
||||
supportsTradingViewEmbed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter interface for communication channels.
|
||||
* Implemented by WebSocket handler, Telegram handler, etc.
|
||||
*/
|
||||
export interface ChannelAdapter {
|
||||
/** Send a full state snapshot to the client */
|
||||
sendSnapshot(msg: SnapshotMessage): void;
|
||||
|
||||
/** Send an incremental patch to the client */
|
||||
sendPatch(msg: PatchMessage): void;
|
||||
|
||||
/** Get channel capabilities */
|
||||
getCapabilities(): ChannelCapabilities;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Path Triggers (Future Use)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Trigger handler function type.
|
||||
* Called when a watched path changes.
|
||||
*/
|
||||
export type PathTriggerHandler = (
|
||||
oldValue: unknown,
|
||||
newValue: unknown,
|
||||
context: PathTriggerContext
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Context passed to trigger handlers.
|
||||
*/
|
||||
export interface PathTriggerContext {
|
||||
/** Store name where change occurred */
|
||||
store: string;
|
||||
|
||||
/** Full path that changed (JSON pointer) */
|
||||
path: string;
|
||||
|
||||
/** Current sequence number after change */
|
||||
seq: number;
|
||||
|
||||
/** User ID for this workspace */
|
||||
userId: string;
|
||||
|
||||
/** Session ID for this workspace */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered path trigger.
|
||||
*/
|
||||
export interface PathTrigger {
|
||||
/** JSON pointer path to watch (e.g., '/chartState/symbol') */
|
||||
path: string;
|
||||
|
||||
/** Handler called when path changes */
|
||||
handler: PathTriggerHandler;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Store State Types (for type-safe access)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Chart state - transient, tracks current view.
|
||||
*/
|
||||
export interface ChartState {
|
||||
symbol: string;
|
||||
start_time: number | null;
|
||||
end_time: number | null;
|
||||
interval: string;
|
||||
selected_shapes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart store - persistent, stores drawings and templates.
|
||||
*/
|
||||
export interface ChartStore {
|
||||
drawings: Record<string, unknown>;
|
||||
templates: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel state - transient, tracks connected channels.
|
||||
*/
|
||||
export interface ChannelState {
|
||||
connected: Record<string, ChannelInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a connected channel.
|
||||
*/
|
||||
export interface ChannelInfo {
|
||||
type: string;
|
||||
connectedAt: number;
|
||||
capabilities: ChannelCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of store names to their state types.
|
||||
*/
|
||||
export interface WorkspaceStores {
|
||||
chartState: ChartState;
|
||||
chartStore: ChartStore;
|
||||
channelState: ChannelState;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
460
gateway/src/workspace/workspace-manager.ts
Normal file
460
gateway/src/workspace/workspace-manager.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Workspace Manager
|
||||
*
|
||||
* Central manager for workspace state synchronization across channels.
|
||||
* Provides a channel-agnostic interface for:
|
||||
* - Two-way sync of transient state (client ↔ gateway)
|
||||
* - Two-way sync of persistent state (client ↔ gateway ↔ container)
|
||||
* - Path-based change triggers (future use)
|
||||
*
|
||||
* Each user session gets one WorkspaceManager instance.
|
||||
* Multiple channels (WebSocket, Telegram, etc.) can attach to the same workspace.
|
||||
*/
|
||||
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Operation as JsonPatchOp } from 'fast-json-patch';
|
||||
import { SyncRegistry } from './sync-registry.js';
|
||||
import type { ContainerSync } from './container-sync.js';
|
||||
import type {
|
||||
StoreConfig,
|
||||
ChannelAdapter,
|
||||
PathTrigger,
|
||||
PathTriggerHandler,
|
||||
PathTriggerContext,
|
||||
WorkspaceStores,
|
||||
} from './types.js';
|
||||
import { DEFAULT_STORES } from './types.js';
|
||||
|
||||
export interface WorkspaceManagerConfig {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
stores: StoreConfig[];
|
||||
containerSync?: ContainerSync;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages workspace state for a user session.
|
||||
*/
|
||||
export class WorkspaceManager {
|
||||
private userId: string;
|
||||
private sessionId: string;
|
||||
private registry: SyncRegistry;
|
||||
private containerSync?: ContainerSync;
|
||||
private logger: FastifyBaseLogger;
|
||||
private stores: StoreConfig[];
|
||||
|
||||
// Current channel adapter (WebSocket, Telegram, etc.)
|
||||
private adapter: ChannelAdapter | null = null;
|
||||
|
||||
// Path triggers for change notifications
|
||||
private triggers: PathTrigger[] = [];
|
||||
|
||||
// Track which stores are dirty (changed since last container sync)
|
||||
private dirtyStores = new Set<string>();
|
||||
|
||||
// Track initialization state
|
||||
private initialized = false;
|
||||
|
||||
constructor(config: WorkspaceManagerConfig) {
|
||||
this.userId = config.userId;
|
||||
this.sessionId = config.sessionId;
|
||||
this.stores = config.stores;
|
||||
this.containerSync = config.containerSync;
|
||||
this.logger = config.logger.child({ component: 'WorkspaceManager', sessionId: config.sessionId });
|
||||
|
||||
this.registry = new SyncRegistry(this.logger);
|
||||
|
||||
// Register all stores
|
||||
for (const store of this.stores) {
|
||||
this.registry.register(store);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize workspace - load persistent stores from container.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Initializing workspace');
|
||||
|
||||
// Load persistent stores from container
|
||||
if (this.containerSync?.isConnected()) {
|
||||
const persistentStores = this.stores.filter((s) => s.persistent).map((s) => s.name);
|
||||
|
||||
if (persistentStores.length > 0) {
|
||||
this.logger.debug({ stores: persistentStores }, 'Loading persistent stores from container');
|
||||
|
||||
const states = await this.containerSync.loadAllStores(persistentStores);
|
||||
for (const [storeName, state] of states) {
|
||||
this.registry.setState(storeName, state);
|
||||
this.logger.debug({ store: storeName }, 'Loaded persistent store');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.debug('Container sync not available, using initial state for persistent stores');
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
this.logger.info('Workspace initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown workspace - save dirty persistent stores to container.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Shutting down workspace');
|
||||
|
||||
// Save dirty persistent stores
|
||||
await this.saveDirtyStores();
|
||||
|
||||
this.adapter = null;
|
||||
this.initialized = false;
|
||||
this.logger.info('Workspace shut down');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Channel Adapter Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Set the channel adapter for sending messages.
|
||||
* Only one adapter can be active at a time.
|
||||
*/
|
||||
setAdapter(adapter: ChannelAdapter): void {
|
||||
this.adapter = adapter;
|
||||
this.logger.debug('Channel adapter set');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the channel adapter.
|
||||
*/
|
||||
clearAdapter(): void {
|
||||
this.adapter = null;
|
||||
this.logger.debug('Channel adapter cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an adapter is connected.
|
||||
*/
|
||||
hasAdapter(): boolean {
|
||||
return this.adapter !== null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Sync Protocol Handlers (called by channel adapters)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Handle hello message from client.
|
||||
* Sends snapshots or catchup patches for all stores.
|
||||
*/
|
||||
async handleHello(clientSeqs: Record<string, number>): Promise<void> {
|
||||
if (!this.adapter) {
|
||||
this.logger.warn('No adapter connected, cannot respond to hello');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug({ clientSeqs }, 'Handling hello');
|
||||
|
||||
const messages = this.registry.syncClient(clientSeqs);
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'snapshot') {
|
||||
this.adapter.sendSnapshot(msg);
|
||||
} else {
|
||||
this.adapter.sendPatch(msg);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug({ messageCount: messages.length }, 'Sent sync messages');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle patch message from client.
|
||||
* Applies patch and may send snapshot back on conflict.
|
||||
*/
|
||||
async handlePatch(storeName: string, clientSeq: number, patch: JsonPatchOp[]): Promise<void> {
|
||||
this.logger.debug({ store: storeName, clientSeq, patchOps: patch.length }, 'Handling client patch');
|
||||
|
||||
// Get old state for triggers
|
||||
const oldState = this.registry.getState(storeName);
|
||||
|
||||
// Apply patch
|
||||
const response = this.registry.applyClientPatch(storeName, clientSeq, patch);
|
||||
|
||||
// Mark as dirty if persistent
|
||||
const storeConfig = this.stores.find((s) => s.name === storeName);
|
||||
if (storeConfig?.persistent) {
|
||||
this.dirtyStores.add(storeName);
|
||||
}
|
||||
|
||||
// Send response if needed
|
||||
if (response && this.adapter) {
|
||||
this.adapter.sendSnapshot(response);
|
||||
}
|
||||
|
||||
// Fire triggers
|
||||
const newState = this.registry.getState(storeName);
|
||||
await this.fireTriggers(storeName, oldState, newState, patch);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// State Access (for gateway code)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get current state of a store.
|
||||
*/
|
||||
getState<K extends keyof WorkspaceStores>(storeName: K): WorkspaceStores[K] | undefined;
|
||||
getState<T = unknown>(storeName: string): T | undefined;
|
||||
getState<T = unknown>(storeName: string): T | undefined {
|
||||
return this.registry.getState<T>(storeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state of a store and notify client.
|
||||
*/
|
||||
async setState(storeName: string, state: unknown): Promise<void> {
|
||||
// Get old state for triggers
|
||||
const oldState = this.registry.getState(storeName);
|
||||
|
||||
// Update state (this computes and commits a patch)
|
||||
const patch = this.registry.updateState(storeName, () => state);
|
||||
|
||||
if (patch) {
|
||||
// Mark as dirty if persistent
|
||||
const storeConfig = this.stores.find((s) => s.name === storeName);
|
||||
if (storeConfig?.persistent) {
|
||||
this.dirtyStores.add(storeName);
|
||||
}
|
||||
|
||||
// Send patch to client
|
||||
if (this.adapter) {
|
||||
this.adapter.sendPatch({
|
||||
type: 'patch',
|
||||
store: storeName,
|
||||
seq: this.registry.getSeq(storeName),
|
||||
patch,
|
||||
});
|
||||
}
|
||||
|
||||
// Fire triggers
|
||||
await this.fireTriggers(storeName, oldState, state, patch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state with a partial merge.
|
||||
*/
|
||||
async updateState<T extends Record<string, unknown>>(
|
||||
storeName: string,
|
||||
updates: Partial<T>
|
||||
): Promise<void> {
|
||||
const current = this.registry.getState<T>(storeName);
|
||||
if (current && typeof current === 'object') {
|
||||
await this.setState(storeName, { ...current, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all store names.
|
||||
*/
|
||||
getStoreNames(): string[] {
|
||||
return this.registry.getStoreNames();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Path Triggers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Register a trigger for path changes.
|
||||
* Returns unsubscribe function.
|
||||
*/
|
||||
onPathChange(path: string, handler: PathTriggerHandler): () => void {
|
||||
const trigger: PathTrigger = { path, handler };
|
||||
this.triggers.push(trigger);
|
||||
|
||||
this.logger.debug({ path }, 'Registered path trigger');
|
||||
|
||||
return () => {
|
||||
const index = this.triggers.indexOf(trigger);
|
||||
if (index >= 0) {
|
||||
this.triggers.splice(index, 1);
|
||||
this.logger.debug({ path }, 'Unregistered path trigger');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire triggers for paths affected by a patch.
|
||||
*/
|
||||
private async fireTriggers(
|
||||
storeName: string,
|
||||
oldState: unknown,
|
||||
newState: unknown,
|
||||
patch: JsonPatchOp[]
|
||||
): Promise<void> {
|
||||
if (this.triggers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context: PathTriggerContext = {
|
||||
store: storeName,
|
||||
path: '',
|
||||
seq: this.registry.getSeq(storeName),
|
||||
userId: this.userId,
|
||||
sessionId: this.sessionId,
|
||||
};
|
||||
|
||||
// Check each patch operation against triggers
|
||||
for (const op of patch) {
|
||||
const fullPath = `/${storeName}${op.path}`;
|
||||
|
||||
for (const trigger of this.triggers) {
|
||||
if (this.pathMatches(fullPath, trigger.path)) {
|
||||
context.path = fullPath;
|
||||
|
||||
// Extract old and new values at the path
|
||||
const oldValue = this.getValueAtPath(oldState, op.path);
|
||||
const newValue = this.getValueAtPath(newState, op.path);
|
||||
|
||||
try {
|
||||
await trigger.handler(oldValue, newValue, context);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ path: trigger.path, error },
|
||||
'Error in path trigger handler'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches a trigger path pattern.
|
||||
* Currently supports exact match and prefix match with wildcard.
|
||||
*/
|
||||
private pathMatches(path: string, pattern: string): boolean {
|
||||
// Exact match
|
||||
if (path === pattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prefix match (e.g., /chartState/* matches /chartState/symbol)
|
||||
if (pattern.endsWith('/*')) {
|
||||
const prefix = pattern.slice(0, -2);
|
||||
return path.startsWith(prefix + '/');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value at a JSON pointer path.
|
||||
*/
|
||||
private getValueAtPath(obj: unknown, path: string): unknown {
|
||||
if (!path || path === '/') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let current: any = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Container Persistence
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Save dirty persistent stores to container.
|
||||
*/
|
||||
async saveDirtyStores(): Promise<void> {
|
||||
if (!this.containerSync?.isConnected()) {
|
||||
this.logger.debug('Container sync not available, skipping save');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dirtyStores.size === 0) {
|
||||
this.logger.debug('No dirty stores to save');
|
||||
return;
|
||||
}
|
||||
|
||||
const toSave = new Map<string, unknown>();
|
||||
|
||||
for (const storeName of this.dirtyStores) {
|
||||
const storeConfig = this.stores.find((s) => s.name === storeName);
|
||||
if (storeConfig?.persistent) {
|
||||
const state = this.registry.getState(storeName);
|
||||
if (state !== undefined) {
|
||||
toSave.set(storeName, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toSave.size > 0) {
|
||||
this.logger.debug({ stores: Array.from(toSave.keys()) }, 'Saving dirty stores to container');
|
||||
await this.containerSync.saveAllStores(toSave);
|
||||
this.dirtyStores.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save a specific store to container.
|
||||
*/
|
||||
async saveStore(storeName: string): Promise<void> {
|
||||
if (!this.containerSync?.isConnected()) {
|
||||
this.logger.warn({ store: storeName }, 'Container sync not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const storeConfig = this.stores.find((s) => s.name === storeName);
|
||||
if (!storeConfig?.persistent) {
|
||||
this.logger.warn({ store: storeName }, 'Store is not persistent');
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.registry.getState(storeName);
|
||||
if (state !== undefined) {
|
||||
await this.containerSync.saveStore(storeName, state);
|
||||
this.dirtyStores.delete(storeName);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Accessors
|
||||
// ===========================================================================
|
||||
|
||||
getUserId(): string {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export DEFAULT_STORES for convenience
|
||||
export { DEFAULT_STORES };
|
||||
Reference in New Issue
Block a user