510 lines
14 KiB
TypeScript
510 lines
14 KiB
TypeScript
/**
|
|
* 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);
|
|
// Persist immediately so changes survive page reloads (not just graceful shutdown)
|
|
await this.saveDirtyStores();
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
/**
|
|
* Serialize entire workspace state as JSON.
|
|
*/
|
|
serializeState(): string {
|
|
const state: Record<string, unknown> = {};
|
|
|
|
for (const storeConfig of this.stores) {
|
|
const storeState = this.registry.getState(storeConfig.name);
|
|
if (storeState !== undefined) {
|
|
state[storeConfig.name] = storeState;
|
|
}
|
|
}
|
|
|
|
return JSON.stringify(state, null, 2);
|
|
}
|
|
|
|
/**
|
|
* Get the highest sequence number across all stores.
|
|
*/
|
|
getCurrentSeq(): number {
|
|
let maxSeq = 0;
|
|
for (const storeName of this.registry.getStoreNames()) {
|
|
const seq = this.registry.getSeq(storeName);
|
|
if (seq > maxSeq) {
|
|
maxSeq = seq;
|
|
}
|
|
}
|
|
return maxSeq;
|
|
}
|
|
|
|
/**
|
|
* Get all patches since a given sequence number across all stores.
|
|
* Returns patches grouped by store name.
|
|
*/
|
|
getChangesSince(sinceSeq: number): Record<string, JsonPatchOp[]> {
|
|
const changes: Record<string, JsonPatchOp[]> = {};
|
|
|
|
for (const storeConfig of this.stores) {
|
|
const patches = this.registry.getPatchesSince(storeConfig.name, sinceSeq);
|
|
if (patches && patches.length > 0) {
|
|
changes[storeConfig.name] = patches;
|
|
}
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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 };
|