✨ feat: add SQLite persistence, multi-tenancy, auto-sync, and CI/Docker improvements (#1)
Replace in-memory storage with SQLite (WAL mode), add workspace-based multi-tenancy with auto-detection via server.listRoots(), and embed the canvas server into the MCP process for single-process operation. 🔧 Core enhancements: - SQLite persistence with versioning, element history, and search - Multi-tenancy: isolated canvases per workspace (SHA-256 tenant IDs) - Embedded canvas lifecycle (single node process starts MCP + canvas) - Auto-sync with 3s debounce and manual override toggle - Configurable canvas port via CANVAS_PORT env var - 6 new MCP tools (search, history, tenants, projects) - Workspace switcher UI with dropdown search - Sync normalization to prevent bound-text breakage on reload 🐳 Docker & CI improvements: - BuildKit cache mounts for faster npm installs across builds - Skip native compilation in frontend-builder stage (--ignore-scripts) - Build only linux/amd64 on PRs, multi-arch on push to main - Docker Hub registry with proper build tools for better-sqlite3 - CI and Docker status check gates (github/ci-status-check, github/docker-build-check) 📦 Package & publishing: - Renamed to @sanjibdevnath/mcp-excalidraw-local (v3.0.0) - Updated npm-publish workflow for scoped package - Updated bin entry, keywords, and files list 📝 Documentation: - README with UI screenshots, architecture diagram, and full feature docs - Updated agent skill with 32-tool cheatsheet and workflow playbooks - Fork attribution and upstream comparison table Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import logger from './utils/logger.js';
|
||||
import type { ServerElement, Snapshot } from './types.js';
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
workspace_path: string;
|
||||
created_at: string;
|
||||
last_accessed_at: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tenant_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ElementVersion {
|
||||
id: number;
|
||||
element_id: string;
|
||||
project_id: string;
|
||||
version: number;
|
||||
data: ServerElement;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PROJECT_ID = 'default';
|
||||
const DEFAULT_TENANT_ID = 'default';
|
||||
|
||||
let db: Database.Database;
|
||||
let activeTenantId: string = DEFAULT_TENANT_ID;
|
||||
let activeProjectId: string = DEFAULT_PROJECT_ID;
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||||
}
|
||||
|
||||
export function initDb(dbPath?: string): void {
|
||||
const resolvedPath = dbPath
|
||||
|| process.env.EXCALIDRAW_DB_PATH
|
||||
|| path.join(os.homedir(), '.excalidraw-mcp', 'excalidraw.db');
|
||||
|
||||
const dir = path.dirname(resolvedPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(resolvedPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
runMigrations();
|
||||
|
||||
// Ensure default tenant exists
|
||||
const defaultTenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(DEFAULT_TENANT_ID);
|
||||
if (!defaultTenant) {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare('INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)').run(
|
||||
DEFAULT_TENANT_ID, 'Default', '(none)', now, now
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure default project exists and is linked to default tenant
|
||||
const defaultProject = db.prepare('SELECT id FROM projects WHERE id = ?').get(DEFAULT_PROJECT_ID);
|
||||
if (!defaultProject) {
|
||||
db.prepare('INSERT INTO projects (id, name, description, tenant_id) VALUES (?, ?, ?, ?)').run(
|
||||
DEFAULT_PROJECT_ID, 'Default', 'Default project', DEFAULT_TENANT_ID
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`SQLite database initialized at ${resolvedPath}`);
|
||||
}
|
||||
|
||||
function runMigrations(): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
workspace_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_accessed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elements (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
label_text TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS element_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
element_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
elements TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(project_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_project ON elements(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_type ON elements(project_id, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_deleted ON elements(project_id, is_deleted);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_element ON element_versions(element_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_project ON element_versions(project_id, created_at);
|
||||
`);
|
||||
|
||||
// FTS table
|
||||
const ftsExists = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='elements_fts'"
|
||||
).get();
|
||||
|
||||
if (!ftsExists) {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE elements_fts USING fts5(
|
||||
element_id,
|
||||
label_text,
|
||||
type
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// Migration: add tenant_id to projects if it doesn't exist (upgrading from older schema)
|
||||
const cols = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
|
||||
const hasTenantCol = cols.some(c => c.name === 'tenant_id');
|
||||
if (!hasTenantCol) {
|
||||
db.exec(`ALTER TABLE projects ADD COLUMN tenant_id TEXT REFERENCES tenants(id)`);
|
||||
logger.info('Migrated: added tenant_id column to projects');
|
||||
}
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id)`);
|
||||
|
||||
// Migration: assign orphan projects (no tenant_id) to default tenant
|
||||
const orphans = db.prepare('SELECT id FROM projects WHERE tenant_id IS NULL').all() as { id: string }[];
|
||||
if (orphans.length > 0) {
|
||||
// Ensure default tenant exists for migration
|
||||
const defTenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(DEFAULT_TENANT_ID);
|
||||
if (!defTenant) {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare('INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)').run(
|
||||
DEFAULT_TENANT_ID, 'Default', '(none)', now, now
|
||||
);
|
||||
}
|
||||
db.prepare('UPDATE projects SET tenant_id = ? WHERE tenant_id IS NULL').run(DEFAULT_TENANT_ID);
|
||||
logger.info(`Migrated: assigned ${orphans.length} orphan projects to default tenant`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractLabelText(element: ServerElement): string | null {
|
||||
if (element.label?.text) return element.label.text;
|
||||
if (element.text) return element.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve effective project ID: explicit override > in-memory active
|
||||
function pid(override?: string): string {
|
||||
return override ?? activeProjectId;
|
||||
}
|
||||
|
||||
// Given a tenant ID, return its default project (creating one if needed)
|
||||
export function getDefaultProjectForTenant(tenantId: string): string {
|
||||
const row = db.prepare(
|
||||
'SELECT id FROM projects WHERE tenant_id = ? ORDER BY created_at ASC LIMIT 1'
|
||||
).get(tenantId) as { id: string } | undefined;
|
||||
|
||||
if (row) return row.id;
|
||||
|
||||
const id = `${tenantId}-default`;
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(id, 'Default', 'Default project', tenantId, now, now);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Element CRUD ──
|
||||
|
||||
export function getElement(id: string, projectId?: string): ServerElement | undefined {
|
||||
const row = db.prepare(
|
||||
'SELECT data FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
|
||||
).get(id, pid(projectId)) as { data: string } | undefined;
|
||||
return row ? JSON.parse(row.data) : undefined;
|
||||
}
|
||||
|
||||
export function hasElement(id: string, projectId?: string): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT 1 FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
|
||||
).get(id, pid(projectId));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function setElement(id: string, element: ServerElement, projectId?: string): void {
|
||||
const p = pid(projectId);
|
||||
const now = new Date().toISOString();
|
||||
const data = JSON.stringify(element);
|
||||
const labelText = extractLabelText(element);
|
||||
const existing = db.prepare(
|
||||
'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?'
|
||||
).get(id, p) as { version: number; is_deleted: number } | undefined;
|
||||
|
||||
if (existing) {
|
||||
const newVersion = existing.is_deleted ? 1 : (existing.version + 1);
|
||||
db.prepare(`
|
||||
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0
|
||||
WHERE id = ? AND project_id = ?
|
||||
`).run(element.type, data, labelText, now, newVersion, id, p);
|
||||
|
||||
recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p);
|
||||
updateFts(id, labelText, element.type);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`).run(id, p, element.type, data, labelText, now, now);
|
||||
|
||||
recordVersion(id, 1, data, 'create', p);
|
||||
insertFts(id, labelText, element.type);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteElement(id: string, projectId?: string): boolean {
|
||||
const p = pid(projectId);
|
||||
const existing = db.prepare(
|
||||
'SELECT version, data FROM elements WHERE id = ? AND project_id = ? AND is_deleted = 0'
|
||||
).get(id, p) as { version: number; data: string } | undefined;
|
||||
|
||||
if (!existing) return false;
|
||||
|
||||
const newVersion = existing.version + 1;
|
||||
db.prepare(`
|
||||
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?
|
||||
WHERE id = ? AND project_id = ?
|
||||
`).run(newVersion, new Date().toISOString(), id, p);
|
||||
|
||||
recordVersion(id, newVersion, existing.data, 'delete', p);
|
||||
deleteFts(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getAllElements(projectId?: string): ServerElement[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT data FROM elements WHERE project_id = ? AND is_deleted = 0'
|
||||
).all(pid(projectId)) as { data: string }[];
|
||||
return rows.map(r => JSON.parse(r.data));
|
||||
}
|
||||
|
||||
export function getElementCount(projectId?: string): number {
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM elements WHERE project_id = ? AND is_deleted = 0'
|
||||
).get(pid(projectId)) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
export function clearElements(projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
const now = new Date().toISOString();
|
||||
const elements = getAllElements(p);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?
|
||||
WHERE project_id = ? AND is_deleted = 0
|
||||
`);
|
||||
|
||||
const clearTx = db.transaction(() => {
|
||||
const info = stmt.run(now, p);
|
||||
for (const el of elements) {
|
||||
recordVersion(el.id, (el.version || 1) + 1, JSON.stringify(el), 'delete', p);
|
||||
deleteFts(el.id);
|
||||
}
|
||||
return info.changes;
|
||||
});
|
||||
|
||||
return clearTx() as number;
|
||||
}
|
||||
|
||||
export function queryElements(type?: string, filter?: Record<string, any>, projectId?: string): ServerElement[] {
|
||||
let elements = getAllElements(projectId);
|
||||
if (type) {
|
||||
elements = elements.filter(el => el.type === type);
|
||||
}
|
||||
if (filter) {
|
||||
elements = elements.filter(el => {
|
||||
return Object.entries(filter).every(([key, value]) => {
|
||||
return (el as any)[key] === value;
|
||||
});
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
export function searchElements(query: string, projectId?: string): ServerElement[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT e.data FROM elements e
|
||||
INNER JOIN elements_fts fts ON fts.element_id = e.id
|
||||
WHERE elements_fts MATCH ? AND e.project_id = ? AND e.is_deleted = 0
|
||||
`).all(query, pid(projectId)) as { data: string }[];
|
||||
return rows.map(r => JSON.parse(r.data));
|
||||
}
|
||||
|
||||
// ── FTS helpers ──
|
||||
|
||||
function insertFts(elementId: string, labelText: string | null, type: string): void {
|
||||
db.prepare('INSERT INTO elements_fts (element_id, label_text, type) VALUES (?, ?, ?)').run(
|
||||
elementId, labelText || '', type
|
||||
);
|
||||
}
|
||||
|
||||
function updateFts(elementId: string, labelText: string | null, type: string): void {
|
||||
deleteFts(elementId);
|
||||
insertFts(elementId, labelText, type);
|
||||
}
|
||||
|
||||
function deleteFts(elementId: string): void {
|
||||
db.prepare("DELETE FROM elements_fts WHERE element_id = ?").run(elementId);
|
||||
}
|
||||
|
||||
// ── Version history ──
|
||||
|
||||
function recordVersion(elementId: string, version: number, data: string, operation: string, projectId?: string): void {
|
||||
db.prepare(`
|
||||
INSERT INTO element_versions (element_id, project_id, version, data, operation)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(elementId, pid(projectId), version, data, operation);
|
||||
}
|
||||
|
||||
export function getElementHistory(elementId: string, limit: number = 50, projectId?: string): ElementVersion[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, element_id, project_id, version, data, operation, created_at
|
||||
FROM element_versions WHERE element_id = ? AND project_id = ?
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
`).all(elementId, pid(projectId), limit) as any[];
|
||||
return rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
|
||||
}
|
||||
|
||||
export function getProjectHistory(limit: number = 100, projectId?: string): ElementVersion[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, element_id, project_id, version, data, operation, created_at
|
||||
FROM element_versions WHERE project_id = ?
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
`).all(pid(projectId), limit) as any[];
|
||||
return rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
|
||||
}
|
||||
|
||||
// ── Snapshots ──
|
||||
|
||||
export function saveSnapshot(name: string, elements: ServerElement[], projectId?: string): void {
|
||||
const data = JSON.stringify(elements);
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO snapshots (project_id, name, elements, created_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
`).run(pid(projectId), name, data);
|
||||
}
|
||||
|
||||
export function getSnapshot(name: string, projectId?: string): Snapshot | undefined {
|
||||
const row = db.prepare(
|
||||
'SELECT name, elements, created_at FROM snapshots WHERE name = ? AND project_id = ?'
|
||||
).get(name, pid(projectId)) as { name: string; elements: string; created_at: string } | undefined;
|
||||
|
||||
if (!row) return undefined;
|
||||
return { name: row.name, elements: JSON.parse(row.elements), createdAt: row.created_at };
|
||||
}
|
||||
|
||||
export function listSnapshots(projectId?: string): { name: string; elementCount: number; createdAt: string }[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT name, elements, created_at FROM snapshots WHERE project_id = ? ORDER BY created_at DESC'
|
||||
).all(pid(projectId)) as { name: string; elements: string; created_at: string }[];
|
||||
return rows.map(r => ({
|
||||
name: r.name,
|
||||
elementCount: (JSON.parse(r.elements) as any[]).length,
|
||||
createdAt: r.created_at
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Tenants ──
|
||||
|
||||
export function ensureTenant(id: string, name: string, workspacePath: string): Tenant {
|
||||
const now = new Date().toISOString();
|
||||
const existing = db.prepare('SELECT * FROM tenants WHERE id = ?').get(id) as Tenant | undefined;
|
||||
|
||||
if (existing) {
|
||||
db.prepare('UPDATE tenants SET last_accessed_at = ? WHERE id = ?').run(now, id);
|
||||
return { ...existing, last_accessed_at: now };
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO tenants (id, name, workspace_path, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(id, name, workspacePath, now, now);
|
||||
|
||||
return { id, name, workspace_path: workspacePath, created_at: now, last_accessed_at: now };
|
||||
}
|
||||
|
||||
export function setActiveTenant(id: string): void {
|
||||
const tenant = db.prepare('SELECT id FROM tenants WHERE id = ?').get(id);
|
||||
if (!tenant) throw new Error(`Tenant "${id}" not found`);
|
||||
activeTenantId = id;
|
||||
|
||||
// Auto-set active project to the tenant's first project, creating a default if none exists
|
||||
const firstProject = db.prepare(
|
||||
'SELECT id FROM projects WHERE tenant_id = ? ORDER BY created_at ASC LIMIT 1'
|
||||
).get(id) as { id: string } | undefined;
|
||||
|
||||
if (firstProject) {
|
||||
activeProjectId = firstProject.id;
|
||||
} else {
|
||||
const defaultId = `${id}-default`;
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(defaultId, 'Default', 'Default project', id, now, now);
|
||||
activeProjectId = defaultId;
|
||||
}
|
||||
|
||||
logger.info(`Active tenant set to "${id}", active project: "${activeProjectId}"`);
|
||||
}
|
||||
|
||||
export function getActiveTenant(): Tenant {
|
||||
return db.prepare('SELECT * FROM tenants WHERE id = ?').get(activeTenantId) as Tenant;
|
||||
}
|
||||
|
||||
export function getActiveTenantId(): string {
|
||||
return activeTenantId;
|
||||
}
|
||||
|
||||
export function listTenants(): Tenant[] {
|
||||
return db.prepare('SELECT * FROM tenants ORDER BY last_accessed_at DESC').all() as Tenant[];
|
||||
}
|
||||
|
||||
// ── Projects ──
|
||||
|
||||
export function createProject(name: string, description?: string): Project {
|
||||
const id = generateId();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(id, name, description || null, activeTenantId, now, now);
|
||||
return { id, name, description: description || null, tenant_id: activeTenantId, created_at: now, updated_at: now };
|
||||
}
|
||||
|
||||
export function listProjects(): Project[] {
|
||||
return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(activeTenantId) as Project[];
|
||||
}
|
||||
|
||||
export function setActiveProject(id: string): void {
|
||||
const project = db.prepare('SELECT id, tenant_id FROM projects WHERE id = ?').get(id) as { id: string; tenant_id: string } | undefined;
|
||||
if (!project) throw new Error(`Project "${id}" not found`);
|
||||
if (project.tenant_id !== activeTenantId) {
|
||||
throw new Error(`Project "${id}" belongs to tenant "${project.tenant_id}", not the active tenant "${activeTenantId}"`);
|
||||
}
|
||||
activeProjectId = id;
|
||||
}
|
||||
|
||||
export function getActiveProject(): Project {
|
||||
return db.prepare('SELECT * FROM projects WHERE id = ?').get(activeProjectId) as Project;
|
||||
}
|
||||
|
||||
export function getActiveProjectId(): string {
|
||||
return activeProjectId;
|
||||
}
|
||||
|
||||
// ── Bulk operations (for sync endpoint) ──
|
||||
|
||||
export function bulkReplaceElements(elements: ServerElement[], projectId?: string): number {
|
||||
const tx = db.transaction(() => {
|
||||
clearElements(projectId);
|
||||
for (const el of elements) {
|
||||
setElement(el.id, el, projectId);
|
||||
}
|
||||
return elements.length;
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
logger.info('SQLite database closed');
|
||||
}
|
||||
}
|
||||
+329
-29
@@ -6,7 +6,7 @@ process.env.NO_COLOR = '1';
|
||||
|
||||
import { fileURLToPath } from "url";
|
||||
import { deflateSync } from 'zlib';
|
||||
import { webcrypto } from 'crypto';
|
||||
import { webcrypto, createHash } from 'crypto';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
@@ -28,6 +28,17 @@ import {
|
||||
validateElement
|
||||
} from './types.js';
|
||||
import fetch from 'node-fetch';
|
||||
import { startCanvasServer, stopCanvasServer } from './server.js';
|
||||
import {
|
||||
initDb, closeDb,
|
||||
searchElements as dbSearchElements,
|
||||
listProjects as dbListProjects, createProject as dbCreateProject,
|
||||
setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject,
|
||||
getElementHistory as dbGetElementHistory, getProjectHistory as dbGetProjectHistory,
|
||||
ensureTenant as dbEnsureTenant, setActiveTenant as dbSetActiveTenant,
|
||||
getActiveTenant as dbGetActiveTenant, getActiveTenantId as dbGetActiveTenantId,
|
||||
listTenants as dbListTenants
|
||||
} from './db.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -47,9 +58,10 @@ function sanitizeFilePath(filePath: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Express server configuration
|
||||
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000';
|
||||
const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true
|
||||
// Express server configuration — derive URL from CANVAS_PORT
|
||||
const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
|
||||
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
|
||||
const ENABLE_CANVAS_SYNC = true;
|
||||
|
||||
// API Response types
|
||||
interface ApiResponse {
|
||||
@@ -66,6 +78,14 @@ interface SyncResponse {
|
||||
elements?: ServerElement[];
|
||||
}
|
||||
|
||||
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': dbGetActiveTenantId(),
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions to sync with Express server (canvas)
|
||||
async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
|
||||
if (!ENABLE_CANVAS_SYNC) {
|
||||
@@ -82,7 +102,7 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
|
||||
url = `${EXPRESS_SERVER_URL}/api/elements`;
|
||||
options = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
};
|
||||
break;
|
||||
@@ -91,21 +111,21 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
|
||||
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
|
||||
options = {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
};
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
url = `${EXPRESS_SERVER_URL}/api/elements/${data.id}`;
|
||||
options = { method: 'DELETE' };
|
||||
options = { method: 'DELETE', headers: canvasHeaders() };
|
||||
break;
|
||||
|
||||
case 'batch_create':
|
||||
url = `${EXPRESS_SERVER_URL}/api/elements/batch`;
|
||||
options = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ elements: data })
|
||||
};
|
||||
break;
|
||||
@@ -168,7 +188,9 @@ async function getElementFromCanvas(elementId: string): Promise<ServerElement |
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`);
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/${elementId}`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn(`Failed to fetch element ${elementId}: ${response.status}`);
|
||||
return null;
|
||||
@@ -819,6 +841,88 @@ const tools: Tool[] = [
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'search_elements',
|
||||
description: 'Full-text search across element labels and text content. Returns elements matching the query.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query for FTS (matches against element labels and text)'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_projects',
|
||||
description: 'List all diagram projects. Projects organize diagrams into separate workspaces.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'switch_project',
|
||||
description: 'Switch the active project or create a new one. All element operations apply to the active project.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
description: 'ID of existing project to switch to'
|
||||
},
|
||||
createName: {
|
||||
type: 'string',
|
||||
description: 'Name for a new project (creates and switches to it)'
|
||||
},
|
||||
createDescription: {
|
||||
type: 'string',
|
||||
description: 'Optional description for the new project'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'element_history',
|
||||
description: 'View the version history of a specific element or the entire active project. Shows create, update, and delete operations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementId: {
|
||||
type: 'string',
|
||||
description: 'Element ID to view history for (omit for project-wide history)'
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of history entries to return (default: 50)'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_tenants',
|
||||
description: 'List all tenants (workspaces). Each tenant corresponds to a Cursor workspace and has isolated diagrams.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'switch_tenant',
|
||||
description: 'Switch the active tenant (workspace). All subsequent operations will use the selected tenant\'s projects and elements.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tenantId: {
|
||||
type: 'string',
|
||||
description: 'ID of the tenant to switch to'
|
||||
}
|
||||
},
|
||||
required: ['tenantId']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -983,9 +1087,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
});
|
||||
}
|
||||
|
||||
// Query elements from HTTP server
|
||||
const url = `${EXPRESS_SERVER_URL}/api/elements/search?${queryParams}`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, { headers: canvasHeaders() });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
||||
@@ -1019,8 +1122,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
case 'library':
|
||||
case 'elements':
|
||||
try {
|
||||
// Get elements from HTTP server
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
@@ -1327,7 +1431,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
// The frontend will use mermaid-to-excalidraw to convert it
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
mermaidDiagram: params.mermaidDiagram,
|
||||
config: params.config
|
||||
@@ -1429,7 +1533,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
logger.info('Clearing canvas via MCP');
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1453,7 +1558,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
logger.info('Exporting scene via MCP');
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch elements: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
@@ -1522,9 +1629,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
throw new Error('No elements found in the import data');
|
||||
}
|
||||
|
||||
// If replace mode, clear first
|
||||
if (params.mode === 'replace') {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' });
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
}
|
||||
|
||||
// Batch create the imported elements
|
||||
@@ -1557,7 +1663,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
format: params.format,
|
||||
background: params.background ?? true
|
||||
@@ -1649,7 +1755,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ name: params.name })
|
||||
});
|
||||
|
||||
@@ -1671,16 +1777,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const params = z.object({ name: z.string() }).parse(args);
|
||||
logger.info('Restoring snapshot via MCP', { name: params.name });
|
||||
|
||||
// Fetch the snapshot
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots/${encodeURIComponent(params.name)}`);
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/snapshots/${encodeURIComponent(params.name)}`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Snapshot "${params.name}" not found`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } };
|
||||
|
||||
// Clear current canvas
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE' });
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
|
||||
// Restore elements
|
||||
const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
|
||||
@@ -1696,7 +1802,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
case 'describe_scene': {
|
||||
logger.info('Describing scene via MCP');
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch elements: ${response.status}`);
|
||||
}
|
||||
@@ -1813,7 +1921,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/export/image`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
format: 'png',
|
||||
background: params.background ?? true
|
||||
@@ -1851,8 +1959,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
case 'export_to_excalidraw_url': {
|
||||
logger.info('Exporting to excalidraw.com URL');
|
||||
|
||||
// 1. Fetch current scene elements
|
||||
const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`);
|
||||
const urlExportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (!urlExportResponse.ok) {
|
||||
throw new Error(`Failed to fetch elements: ${urlExportResponse.status}`);
|
||||
}
|
||||
@@ -2151,7 +2260,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const viewportResponse = await fetch(`${EXPRESS_SERVER_URL}/api/viewport`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify(viewportParams)
|
||||
});
|
||||
|
||||
@@ -2170,6 +2279,135 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
};
|
||||
}
|
||||
|
||||
case 'search_elements': {
|
||||
const params = z.object({ query: z.string() }).parse(args);
|
||||
logger.info('Searching elements via MCP', { query: params.query });
|
||||
|
||||
const results = dbSearchElements(params.query);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: results.length > 0
|
||||
? `Found ${results.length} matching elements:\n\n${JSON.stringify(results, null, 2)}`
|
||||
: `No elements found matching "${params.query}"`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
case 'list_projects': {
|
||||
logger.info('Listing projects via MCP');
|
||||
const projects = dbListProjects();
|
||||
const active = dbGetActiveProject();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Active project: ${active.name} (${active.id})\n\nAll projects:\n${JSON.stringify(projects, null, 2)}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
case 'switch_project': {
|
||||
const params = z.object({
|
||||
projectId: z.string().optional(),
|
||||
createName: z.string().optional(),
|
||||
createDescription: z.string().optional()
|
||||
}).parse(args || {});
|
||||
|
||||
if (params.createName) {
|
||||
const newProject = dbCreateProject(params.createName, params.createDescription);
|
||||
dbSetActiveProject(newProject.id);
|
||||
logger.info('Created and switched to new project', { project: newProject });
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Created new project "${newProject.name}" and switched to it.\n\n${JSON.stringify(newProject, null, 2)}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (params.projectId) {
|
||||
dbSetActiveProject(params.projectId);
|
||||
const active = dbGetActiveProject();
|
||||
logger.info('Switched project', { project: active });
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Switched to project "${active.name}" (${active.id})`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Provide either projectId to switch to or createName to create a new project');
|
||||
}
|
||||
|
||||
case 'element_history': {
|
||||
const params = z.object({
|
||||
elementId: z.string().optional(),
|
||||
limit: z.number().optional()
|
||||
}).parse(args || {});
|
||||
|
||||
const limit = params.limit ?? 50;
|
||||
|
||||
if (params.elementId) {
|
||||
const history = dbGetElementHistory(params.elementId, limit);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: history.length > 0
|
||||
? `Version history for element ${params.elementId} (${history.length} entries):\n\n${JSON.stringify(history, null, 2)}`
|
||||
: `No history found for element ${params.elementId}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
const history = dbGetProjectHistory(limit);
|
||||
const active = dbGetActiveProject();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: history.length > 0
|
||||
? `Project history for "${active.name}" (${history.length} entries):\n\n${JSON.stringify(history, null, 2)}`
|
||||
: `No history in project "${active.name}"`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
case 'list_tenants': {
|
||||
logger.info('Listing tenants via MCP');
|
||||
const tenants = dbListTenants();
|
||||
const activeTenant = dbGetActiveTenant();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Active tenant: ${activeTenant.name} (${activeTenant.id})\nWorkspace: ${activeTenant.workspace_path}\n\nAll tenants:\n${JSON.stringify(tenants, null, 2)}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
case 'switch_tenant': {
|
||||
const params = z.object({ tenantId: z.string() }).parse(args);
|
||||
logger.info('Switching tenant via MCP', { tenantId: params.tenantId });
|
||||
|
||||
dbSetActiveTenant(params.tenantId);
|
||||
const tenant = dbGetActiveTenant();
|
||||
const activeProject = dbGetActiveProject();
|
||||
|
||||
try {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
|
||||
method: 'PUT',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ tenantId: params.tenantId })
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Switched to tenant "${tenant.name}" (${tenant.id})\nWorkspace: ${tenant.workspace_path}\nActive project: ${activeProject.name} (${activeProject.id})`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
@@ -2193,12 +2431,74 @@ async function runServer(): Promise<void> {
|
||||
try {
|
||||
logger.info('Starting Excalidraw MCP server...');
|
||||
|
||||
// Initialize SQLite before anything else
|
||||
initDb();
|
||||
|
||||
// Bootstrap tenant from process.cwd() (may be home dir for global MCPs)
|
||||
let workspacePath = process.cwd();
|
||||
|
||||
function applyTenant(wp: string) {
|
||||
const tid = createHash('sha256').update(wp).digest('hex').slice(0, 12);
|
||||
const tname = path.basename(wp);
|
||||
dbEnsureTenant(tid, tname, wp);
|
||||
dbSetActiveTenant(tid);
|
||||
logger.info(`Tenant initialized: "${tname}" (${tid}) from ${wp}`);
|
||||
return { tenantId: tid, tenantName: tname };
|
||||
}
|
||||
|
||||
applyTenant(workspacePath);
|
||||
|
||||
try {
|
||||
await startCanvasServer();
|
||||
logger.info('Canvas server started — lifecycle managed by MCP process');
|
||||
} catch (canvasError) {
|
||||
logger.warn('Canvas server failed to start:', (canvasError as Error).message);
|
||||
logger.warn('MCP tools will work without real-time canvas sync');
|
||||
}
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
logger.debug('Connecting to stdio transport...');
|
||||
|
||||
await server.connect(transport);
|
||||
logger.info('Excalidraw MCP server running on stdio');
|
||||
|
||||
// After connecting, ask the client for the real workspace roots.
|
||||
// Global MCPs often get cwd=HOME; roots gives us the actual workspace.
|
||||
try {
|
||||
const { roots } = await server.listRoots(undefined, { timeout: 5_000 });
|
||||
if (roots && roots.length > 0) {
|
||||
const rootUri = roots[0]!.uri;
|
||||
const rootPath = rootUri.startsWith('file://') ? decodeURIComponent(rootUri.slice(7)) : rootUri;
|
||||
if (rootPath && rootPath !== workspacePath) {
|
||||
logger.info(`Client reported workspace root: ${rootPath} (was ${workspacePath})`);
|
||||
workspacePath = rootPath;
|
||||
const { tenantId: newTid } = applyTenant(workspacePath);
|
||||
|
||||
try {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
|
||||
method: 'PUT',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ tenantId: newTid })
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (rootsErr) {
|
||||
logger.debug('Could not retrieve roots from client (not supported or timed out):', (rootsErr as Error).message);
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
logger.info('MCP transport closed — shutting down');
|
||||
try { await stopCanvasServer(); } catch {}
|
||||
try { closeDb(); } catch {}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
server.onclose = shutdown;
|
||||
process.stdin.on('close', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
process.stdin.resume();
|
||||
} catch (error) {
|
||||
logger.error('Error starting server:', error);
|
||||
|
||||
+163
-94
@@ -1,4 +1,4 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import express, { type Application, Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
@@ -7,8 +7,6 @@ import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import logger from './utils/logger.js';
|
||||
import {
|
||||
elements,
|
||||
snapshots,
|
||||
generateId,
|
||||
EXCALIDRAW_ELEMENT_TYPES,
|
||||
ServerElement,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
InitialElementsMessage,
|
||||
Snapshot
|
||||
} from './types.js';
|
||||
import * as store from './db.js';
|
||||
import { listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
|
||||
import { z } from 'zod';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
@@ -31,9 +31,9 @@ dotenv.config();
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
const app: Application = express();
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
@@ -45,6 +45,14 @@ app.use(express.static(staticDir));
|
||||
// Also serve frontend assets
|
||||
app.use(express.static(path.join(__dirname, '../dist/frontend')));
|
||||
|
||||
// Resolve tenant from X-Tenant-Id header to a projectId override.
|
||||
// Returns undefined when header is absent (browser requests), falling back to global state.
|
||||
function resolveTenantProject(req: Request): string | undefined {
|
||||
const tenantId = req.headers['x-tenant-id'] as string | undefined;
|
||||
if (!tenantId) return undefined;
|
||||
return getDefaultProjectForTenant(tenantId);
|
||||
}
|
||||
|
||||
// WebSocket connections
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
@@ -62,18 +70,27 @@ function broadcast(message: WebSocketMessage): void {
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
clients.add(ws);
|
||||
logger.info('New WebSocket connection established');
|
||||
|
||||
// Send current tenant info
|
||||
try {
|
||||
const tenant = dbGetActiveTenant();
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tenant_switched',
|
||||
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
|
||||
}));
|
||||
} catch {}
|
||||
|
||||
// Send current elements to new client
|
||||
const initialMessage: InitialElementsMessage = {
|
||||
type: 'initial_elements',
|
||||
elements: Array.from(elements.values())
|
||||
elements: store.getAllElements()
|
||||
};
|
||||
ws.send(JSON.stringify(initialMessage));
|
||||
|
||||
// Send sync status to new client
|
||||
const syncMessage: SyncStatusMessage = {
|
||||
type: 'sync_status',
|
||||
elementCount: elements.size,
|
||||
elementCount: store.getElementCount(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
ws.send(JSON.stringify(syncMessage));
|
||||
@@ -161,11 +178,12 @@ const UpdateElementSchema = z.object({
|
||||
// Get all elements
|
||||
app.get('/api/elements', (req: Request, res: Response) => {
|
||||
try {
|
||||
const elementsArray = Array.from(elements.values());
|
||||
const projId = resolveTenantProject(req);
|
||||
const allElements = store.getAllElements(projId);
|
||||
res.json({
|
||||
success: true,
|
||||
elements: elementsArray,
|
||||
count: elementsArray.length
|
||||
elements: allElements,
|
||||
count: allElements.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching elements:', error);
|
||||
@@ -179,10 +197,10 @@ app.get('/api/elements', (req: Request, res: Response) => {
|
||||
// Create new element
|
||||
app.post('/api/elements', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const params = CreateElementSchema.parse(req.body);
|
||||
logger.info('Creating element via API', { type: params.type });
|
||||
|
||||
// Prioritize passed ID (for MCP sync), otherwise generate new ID
|
||||
const id = params.id || generateId();
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
@@ -192,9 +210,8 @@ app.post('/api/elements', (req: Request, res: Response) => {
|
||||
version: 1
|
||||
};
|
||||
|
||||
elements.set(id, element);
|
||||
store.setElement(id, element, projId);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const message: ElementCreatedMessage = {
|
||||
type: 'element_created',
|
||||
element: element
|
||||
@@ -217,6 +234,7 @@ app.post('/api/elements', (req: Request, res: Response) => {
|
||||
// Update element
|
||||
app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { id } = req.params;
|
||||
const updates = UpdateElementSchema.parse({ id, ...req.body });
|
||||
|
||||
@@ -227,7 +245,7 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
const existingElement = elements.get(id);
|
||||
const existingElement = store.getElement(id, projId);
|
||||
if (!existingElement) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -242,9 +260,8 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
version: (existingElement.version || 0) + 1
|
||||
};
|
||||
|
||||
elements.set(id, updatedElement);
|
||||
store.setElement(id, updatedElement, projId);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const message: ElementUpdatedMessage = {
|
||||
type: 'element_updated',
|
||||
element: updatedElement
|
||||
@@ -267,8 +284,8 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
// Clear all elements (must be before /:id route)
|
||||
app.delete('/api/elements/clear', (req: Request, res: Response) => {
|
||||
try {
|
||||
const count = elements.size;
|
||||
elements.clear();
|
||||
const projId = resolveTenantProject(req);
|
||||
const count = store.clearElements(projId);
|
||||
|
||||
broadcast({
|
||||
type: 'canvas_cleared',
|
||||
@@ -294,6 +311,7 @@ app.delete('/api/elements/clear', (req: Request, res: Response) => {
|
||||
// Delete element
|
||||
app.delete('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
@@ -303,14 +321,14 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!elements.has(id)) {
|
||||
if (!store.hasElement(id, projId)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Element with ID ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
elements.delete(id);
|
||||
store.deleteElement(id, projId);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const message: ElementDeletedMessage = {
|
||||
@@ -335,22 +353,19 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
|
||||
// Query elements with filters
|
||||
app.get('/api/elements/search', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, ...filters } = req.query;
|
||||
let results = Array.from(elements.values());
|
||||
|
||||
// Filter by type if specified
|
||||
if (type && typeof type === 'string') {
|
||||
results = results.filter(element => element.type === type);
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
if (Object.keys(filters).length > 0) {
|
||||
results = results.filter(element => {
|
||||
return Object.entries(filters).every(([key, value]) => {
|
||||
return (element as any)[key] === value;
|
||||
});
|
||||
});
|
||||
const projId = resolveTenantProject(req);
|
||||
const { type, q, ...filters } = req.query;
|
||||
|
||||
if (q && typeof q === 'string') {
|
||||
const results = store.searchElements(q, projId);
|
||||
return res.json({ success: true, elements: results, count: results.length });
|
||||
}
|
||||
|
||||
const results = store.queryElements(
|
||||
type && typeof type === 'string' ? type : undefined,
|
||||
Object.keys(filters).length > 0 ? filters as Record<string, any> : undefined,
|
||||
projId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -369,6 +384,7 @@ app.get('/api/elements/search', (req: Request, res: Response) => {
|
||||
// Get element by ID
|
||||
app.get('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
@@ -378,7 +394,7 @@ app.get('/api/elements/:id', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
const element = elements.get(id);
|
||||
const element = store.getElement(id, projId);
|
||||
|
||||
if (!element) {
|
||||
return res.status(404).json({
|
||||
@@ -453,14 +469,14 @@ function computeEdgePoint(
|
||||
}
|
||||
|
||||
// Helper: resolve arrow bindings in a batch
|
||||
function resolveArrowBindings(batchElements: ServerElement[]): void {
|
||||
function resolveArrowBindings(batchElements: ServerElement[], projectId?: string): void {
|
||||
const elementMap = new Map<string, ServerElement>();
|
||||
batchElements.forEach(el => elementMap.set(el.id, el));
|
||||
|
||||
// Also check existing elements for cross-batch references
|
||||
elements.forEach((el, id) => {
|
||||
if (!elementMap.has(id)) elementMap.set(id, el);
|
||||
});
|
||||
for (const el of store.getAllElements(projectId)) {
|
||||
if (!elementMap.has(el.id)) elementMap.set(el.id, el);
|
||||
}
|
||||
|
||||
for (const el of batchElements) {
|
||||
if (el.type !== 'arrow' && el.type !== 'line') continue;
|
||||
@@ -535,6 +551,7 @@ function resolveArrowBindings(batchElements: ServerElement[]): void {
|
||||
// Batch create elements
|
||||
app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { elements: elementsToCreate } = req.body;
|
||||
|
||||
if (!Array.isArray(elementsToCreate)) {
|
||||
@@ -548,7 +565,6 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
|
||||
elementsToCreate.forEach(elementData => {
|
||||
const params = CreateElementSchema.parse(elementData);
|
||||
// Prioritize passed ID (for MCP sync), otherwise generate new ID
|
||||
const id = params.id || generateId();
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
@@ -561,11 +577,9 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
createdElements.push(element);
|
||||
});
|
||||
|
||||
// Resolve arrow bindings (computes positions, startBinding, endBinding, boundElements)
|
||||
resolveArrowBindings(createdElements);
|
||||
resolveArrowBindings(createdElements, projId);
|
||||
|
||||
// Store all elements after binding resolution
|
||||
createdElements.forEach(el => elements.set(el.id, el));
|
||||
createdElements.forEach(el => store.setElement(el.id, el, projId));
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const message: BatchCreatedMessage = {
|
||||
@@ -632,6 +646,7 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
|
||||
// Sync elements from frontend (overwrite sync)
|
||||
app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { elements: frontendElements, timestamp } = req.body;
|
||||
|
||||
logger.info(`Sync request received: ${frontendElements.length} elements`, {
|
||||
@@ -639,7 +654,6 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
elementCount: frontendElements.length
|
||||
});
|
||||
|
||||
// Validate input data
|
||||
if (!Array.isArray(frontendElements)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -647,23 +661,15 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Record element count before sync
|
||||
const beforeCount = elements.size;
|
||||
|
||||
// 1. Clear existing memory storage
|
||||
elements.clear();
|
||||
logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
|
||||
|
||||
// 2. Batch write new data
|
||||
let successCount = 0;
|
||||
const beforeCount = store.getElementCount(projId);
|
||||
|
||||
// Process elements with server metadata
|
||||
const processedElements: ServerElement[] = [];
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
frontendElements.forEach((element: any, index: number) => {
|
||||
try {
|
||||
// Ensure element has ID, generate one if missing
|
||||
const elementId = element.id || generateId();
|
||||
|
||||
// Add server metadata
|
||||
const processedElement: ServerElement = {
|
||||
...element,
|
||||
id: elementId,
|
||||
@@ -672,35 +678,30 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
syncTimestamp: timestamp,
|
||||
version: 1
|
||||
};
|
||||
|
||||
// Store to memory
|
||||
elements.set(elementId, processedElement);
|
||||
processedElements.push(processedElement);
|
||||
successCount++;
|
||||
|
||||
} catch (elementError) {
|
||||
logger.warn(`Failed to process element ${index}:`, elementError);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
store.bulkReplaceElements(processedElements, projId);
|
||||
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
|
||||
|
||||
// 3. Broadcast sync event to all WebSocket clients
|
||||
|
||||
broadcast({
|
||||
type: 'elements_synced',
|
||||
count: successCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'manual_sync'
|
||||
});
|
||||
|
||||
// 4. Return sync results
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully synced ${successCount} elements`,
|
||||
count: successCount,
|
||||
syncedAt: new Date().toISOString(),
|
||||
beforeCount,
|
||||
afterCount: elements.size
|
||||
afterCount: store.getElementCount(projId)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -919,6 +920,7 @@ app.post('/api/viewport/result', (req: Request, res: Response) => {
|
||||
// Snapshots: save
|
||||
app.post('/api/snapshots', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
@@ -928,20 +930,15 @@ app.post('/api/snapshots', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
name,
|
||||
elements: Array.from(elements.values()),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
snapshots.set(name, snapshot);
|
||||
logger.info(`Snapshot saved: "${name}" with ${snapshot.elements.length} elements`);
|
||||
const allElements = store.getAllElements(projId);
|
||||
store.saveSnapshot(name, allElements, projId);
|
||||
logger.info(`Snapshot saved: "${name}" with ${allElements.length} elements`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
name,
|
||||
elementCount: snapshot.elements.length,
|
||||
createdAt: snapshot.createdAt
|
||||
elementCount: allElements.length,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error saving snapshot:', error);
|
||||
@@ -955,11 +952,8 @@ app.post('/api/snapshots', (req: Request, res: Response) => {
|
||||
// Snapshots: list
|
||||
app.get('/api/snapshots', (req: Request, res: Response) => {
|
||||
try {
|
||||
const list = Array.from(snapshots.values()).map(s => ({
|
||||
name: s.name,
|
||||
elementCount: s.elements.length,
|
||||
createdAt: s.createdAt
|
||||
}));
|
||||
const projId = resolveTenantProject(req);
|
||||
const list = store.listSnapshots(projId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -978,8 +972,9 @@ app.get('/api/snapshots', (req: Request, res: Response) => {
|
||||
// Snapshots: get by name
|
||||
app.get('/api/snapshots/:name', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { name } = req.params;
|
||||
const snapshot = snapshots.get(name!);
|
||||
const snapshot = store.getSnapshot(name!, projId);
|
||||
|
||||
if (!snapshot) {
|
||||
return res.status(404).json({
|
||||
@@ -1012,21 +1007,68 @@ app.get('/', (req: Request, res: Response) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tenant API ──
|
||||
|
||||
app.get('/api/tenants', (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenants = dbListTenants();
|
||||
const active = dbGetActiveTenant();
|
||||
res.json({ success: true, tenants, activeTenantId: active.id });
|
||||
} catch (error) {
|
||||
logger.error('Error listing tenants:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/tenant/active', (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenant = dbGetActiveTenant();
|
||||
res.json({ success: true, tenant });
|
||||
} catch (error) {
|
||||
logger.error('Error getting active tenant:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/tenant/active', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { tenantId } = req.body;
|
||||
if (!tenantId || typeof tenantId !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'tenantId is required' });
|
||||
}
|
||||
|
||||
dbSetActiveTenant(tenantId);
|
||||
const tenant = dbGetActiveTenant();
|
||||
|
||||
broadcast({
|
||||
type: 'tenant_switched',
|
||||
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
|
||||
});
|
||||
|
||||
res.json({ success: true, tenant });
|
||||
} catch (error) {
|
||||
logger.error('Error switching tenant:', error);
|
||||
res.status(400).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req: Request, res: Response) => {
|
||||
const projId = resolveTenantProject(req);
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
elements_count: elements.size,
|
||||
elements_count: store.getElementCount(projId),
|
||||
websocket_clients: clients.size
|
||||
});
|
||||
});
|
||||
|
||||
// Sync status endpoint
|
||||
app.get('/api/sync/status', (req: Request, res: Response) => {
|
||||
const projId = resolveTenantProject(req);
|
||||
res.json({
|
||||
success: true,
|
||||
elementCount: elements.size,
|
||||
elementCount: store.getElementCount(projId),
|
||||
timestamp: new Date().toISOString(),
|
||||
memoryUsage: {
|
||||
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
|
||||
@@ -1045,13 +1087,40 @@ app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
// Server configuration
|
||||
const PORT = parseInt(process.env.CANVAS_PORT || process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || 'localhost';
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
logger.info(`POC server running on http://${HOST}:${PORT}`);
|
||||
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
|
||||
});
|
||||
export function startCanvasServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
httpServer.removeListener('error', onError);
|
||||
reject(err);
|
||||
};
|
||||
httpServer.on('error', onError);
|
||||
|
||||
httpServer.listen(PORT, HOST, () => {
|
||||
httpServer.removeListener('error', onError);
|
||||
logger.info(`Canvas server running on http://${HOST}:${PORT}`);
|
||||
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function stopCanvasServer(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
clients.forEach(c => c.close());
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Direct execution: `node dist/server.js` still works standalone
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
startCanvasServer().catch((err) => {
|
||||
logger.error('Failed to start canvas server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
+15
-6
@@ -182,7 +182,8 @@ export type WebSocketMessageType =
|
||||
| 'mermaid_convert'
|
||||
| 'canvas_cleared'
|
||||
| 'export_image_request'
|
||||
| 'set_viewport';
|
||||
| 'set_viewport'
|
||||
| 'tenant_switched';
|
||||
|
||||
export interface InitialElementsMessage extends WebSocketMessage {
|
||||
type: 'initial_elements';
|
||||
@@ -271,6 +272,16 @@ export interface SetViewportMessage extends WebSocketMessage {
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
// Tenant switched message
|
||||
export interface TenantSwitchedMessage extends WebSocketMessage {
|
||||
type: 'tenant_switched';
|
||||
tenant: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspace_path: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Snapshot types
|
||||
export interface Snapshot {
|
||||
name: string;
|
||||
@@ -278,11 +289,9 @@ export interface Snapshot {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// In-memory storage for Excalidraw elements
|
||||
export const elements = new Map<string, ServerElement>();
|
||||
|
||||
// In-memory storage for snapshots
|
||||
export const snapshots = new Map<string, Snapshot>();
|
||||
// Storage is now handled by src/db.ts (SQLite).
|
||||
// The Map exports below are kept only for backward compatibility with
|
||||
// standalone server.ts usage; they are NOT used when the DB is active.
|
||||
|
||||
// Validation function for Excalidraw elements
|
||||
export function validateElement(element: Partial<ServerElement>): element is ServerElement {
|
||||
|
||||
Reference in New Issue
Block a user