Compare commits

...
2 Commits
Author SHA1 Message Date
sanjibdevnathlabs-release-bot[bot] 670961ee73 chore(release): v1.6.0 2026-03-17 18:16:39 +00:00
2cca18153f feat(sync): implement scoped sync architecture with ACK model and comprehensive tests (#11)
Implement a complete sync architecture overhaul (12 tasks) replacing the flat
WebSocket broadcast with scoped, acknowledged delivery:

**Backend (server.ts, db.ts, types.ts, index.ts):**
- Scoped connection registry: Map<tenant, Map<project, Set<ClientConnection>>>
- Hello handshake: WS clients identify tenant/project, server responds with scoped elements
- broadcastToScope() replaces global broadcast for element mutations
- broadcastWithAck() waits for browser ACK before returning syncedToCanvas status
- sync_version: monotonic counter per project, stamped on every mutation
- Delta sync v2: POST /api/elements/sync/v2 for incremental sync with version tracking
- GET /api/sync/version endpoint
- Honest syncedToCanvas + canvasStatus in all mutation responses
- Fixed silent try/catch in tenant switch verification

**Frontend (App.tsx):**
- ACK sending after every updateScene() with element verification
- Delta sync v2 integration in syncToBackend()
- Gap detection: triggers resync when sync_version gaps are detected
- lastSyncVersion tracking via refs + localStorage persistence

**Tests (40 new tests, 168 total):**
- db.test.ts: +11 tests for sync_version CRUD, scoping, getChangesSince
- ws.test.ts: +8 tests for hello handshake, scoped broadcast, ACK model
- api.test.ts: +10 tests for sync/v2, sync/version, canvasStatus responses
- helpers.test.ts: +11 tests for isImageElement, normalizeImageElement, restoreBindings
- canvas.spec.ts: +8 e2e tests including full ACK pipeline verification
- Fixed stale tenant state bug in api.test.ts beforeEach

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 23:44:17 +05:30
13 changed files with 1489 additions and 124 deletions
+9
View File
@@ -104,3 +104,12 @@ frontend/ ── React + Excalidraw UI (Vite build → dist/frontend/)
## Docker
Two Dockerfiles: `Dockerfile` (MCP server only), `Dockerfile.canvas` (canvas with frontend). `docker-compose.yml` orchestrates both with a `full` profile.
## Code Search Optimization
When exploring or understanding code in supported languages (JS, TS, Python, Go, Rust, Java, C, C++, Ruby):
- Use `smart_search(query, path)` instead of Grep+Glob chains for discovering functions/classes/symbols
- Use `smart_outline(file_path)` instead of Read to understand file structure (~1-2K tokens vs ~12K+)
- Use `smart_unfold(file_path, symbol_name)` instead of Read for viewing specific functions (~400-2K tokens)
- Fall back to Grep for exact string/regex searches, Read for non-code files and files under 100 lines
+141 -5
View File
@@ -68,6 +68,12 @@ function App(): JSX.Element {
const isSyncingRef = useRef<boolean>(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSyncedHashRef = useRef<string>('')
const lastSyncVersionRef = useRef<number>(
parseInt(localStorage.getItem('excalidraw-last-sync-version') ?? '0', 10)
)
const lastSyncedElementsRef = useRef<Map<string, ServerElement>>(new Map())
const lastReceivedSyncVersionRef = useRef<number>(0)
const isResyncingRef = useRef<boolean>(false)
const DEBOUNCE_MS = 3000
@@ -254,9 +260,77 @@ function App(): JSX.Element {
}
}
const sendAck = (msgId: string | undefined, status: 'applied' | 'partial' | 'failed', elementCount?: number, expectedCount?: number): void => {
if (!msgId) return
const ws = websocketRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'ack', msgId, status, elementCount, expectedCount }))
}
const triggerDeltaResync = async (): Promise<void> => {
if (isResyncingRef.current) return
isResyncingRef.current = true
try {
const response = await fetch('/api/elements/sync/v2', {
method: 'POST',
headers: tenantHeaders(),
body: JSON.stringify({
lastSyncVersion: lastReceivedSyncVersionRef.current,
changes: []
})
})
if (response.ok) {
const data = await response.json() as {
currentSyncVersion: number
serverChanges: { id: string; action: string; element: any; sync_version: number }[]
}
const api = excalidrawAPIRef.current
if (api && data.serverChanges.length > 0) {
const scene = api.getSceneElements()
let merged = [...scene]
for (const sc of data.serverChanges) {
if (sc.action === 'delete') {
merged = merged.filter(el => el.id !== sc.id)
} else if (sc.element) {
const cleaned = cleanElementForExcalidraw(sc.element)
const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
const idx = merged.findIndex(el => el.id === sc.id)
if (idx >= 0) {
merged[idx] = converted[0]!
} else {
merged.push(...converted)
}
}
}
api.updateScene({ elements: merged, captureUpdate: CaptureUpdateAction.NEVER })
}
lastReceivedSyncVersionRef.current = data.currentSyncVersion
lastSyncVersionRef.current = data.currentSyncVersion
localStorage.setItem('excalidraw-last-sync-version', String(data.currentSyncVersion))
console.log(`Delta resync complete: received ${data.serverChanges.length} changes, now at v${data.currentSyncVersion}`)
}
} catch (err) {
console.error('Delta resync failed:', err)
} finally {
isResyncingRef.current = false
}
}
const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
// Gap detection (Task 12): if a message carries sync_version, check for gaps
if (data.sync_version !== undefined && typeof data.sync_version === 'number') {
const expected = lastReceivedSyncVersionRef.current + 1
if (data.sync_version > expected && lastReceivedSyncVersionRef.current > 0) {
console.warn(`Sync gap: expected v${expected}, got v${data.sync_version}. Triggering resync.`)
triggerDeltaResync()
return // resync will fetch everything including this message's changes
}
lastReceivedSyncVersionRef.current = data.sync_version
}
const api = excalidrawAPIRef.current
if (!api) {
sendAck(data.msgId, 'failed')
return
}
@@ -295,6 +369,9 @@ function App(): JSX.Element {
captureUpdate: CaptureUpdateAction.NEVER
})
}
const scene = api.getSceneElements()
const landed = scene.some(s => s.id === data.element!.id)
sendAck(data.msgId, landed ? 'applied' : 'failed', landed ? 1 : 0, 1)
}
break
@@ -309,6 +386,7 @@ function App(): JSX.Element {
elements: updatedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
sendAck(data.msgId, 'applied', 1, 1)
}
break
@@ -319,6 +397,7 @@ function App(): JSX.Element {
elements: filteredElements,
captureUpdate: CaptureUpdateAction.NEVER
})
sendAck(data.msgId, 'applied', 1, 1)
}
break
@@ -341,6 +420,12 @@ function App(): JSX.Element {
captureUpdate: CaptureUpdateAction.NEVER
})
}
// Verify elements landed in the scene
const scene = api.getSceneElements()
const expectedIds = data.elements.map((e: ServerElement) => e.id)
const landedCount = expectedIds.filter(id => scene.some(s => s.id === id)).length
const status = landedCount === expectedIds.length ? 'applied' : landedCount > 0 ? 'partial' : 'failed'
sendAck(data.msgId, status, landedCount, expectedIds.length)
}
break
@@ -358,6 +443,7 @@ function App(): JSX.Element {
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
sendAck(data.msgId, 'applied')
break
case 'export_image_request':
@@ -732,21 +818,71 @@ function App(): JSX.Element {
const activeElements = currentElements.filter(el => !el.isDeleted)
const backendElements = normalizeForBackend(activeElements)
const response = await fetch('/api/elements/sync', {
// Compute delta: what changed since last sync
const changes: { id: string; action: string; element?: any }[] = []
const currentMap = new Map<string, any>()
for (const el of backendElements) {
currentMap.set(el.id, el)
const prev = lastSyncedElementsRef.current.get(el.id)
if (!prev || JSON.stringify(prev) !== JSON.stringify(el)) {
changes.push({ id: el.id, action: 'upsert', element: el })
}
}
// Detect deletions: elements in last sync but not current
for (const [id] of lastSyncedElementsRef.current) {
if (!currentMap.has(id)) {
changes.push({ id, action: 'delete' })
}
}
const response = await fetch('/api/elements/sync/v2', {
method: 'POST',
headers: tenantHeaders(),
body: JSON.stringify({
elements: backendElements,
timestamp: new Date().toISOString()
lastSyncVersion: lastSyncVersionRef.current,
changes
})
})
if (response.ok) {
const result: ApiResponse = await response.json()
const result = await response.json() as {
currentSyncVersion: number
serverChanges: { id: string; action: string; element: any; sync_version: number }[]
appliedCount: number
}
// Apply server-side changes (MCP-created elements, other tabs' changes)
if (result.serverChanges.length > 0) {
const api = excalidrawAPIRef.current
if (api) {
const scene = api.getSceneElements()
let merged = [...scene]
for (const sc of result.serverChanges) {
if (sc.action === 'delete') {
merged = merged.filter(el => el.id !== sc.id)
} else if (sc.element) {
const cleaned = cleanElementForExcalidraw(sc.element)
const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
const idx = merged.findIndex(el => el.id === sc.id)
if (idx >= 0) {
merged[idx] = converted[0]!
} else {
merged.push(...converted)
}
}
}
api.updateScene({ elements: merged, captureUpdate: CaptureUpdateAction.NEVER })
}
}
// Update tracking state
lastSyncVersionRef.current = result.currentSyncVersion
localStorage.setItem('excalidraw-last-sync-version', String(result.currentSyncVersion))
lastSyncedElementsRef.current = currentMap
lastSyncedHashRef.current = computeElementHash(currentElements)
setSyncStatus('idle')
showToast('Saved')
console.log(`Sync: ${result.count} elements synced`)
console.log(`Delta sync: ${result.appliedCount} applied, ${result.serverChanges.length} received from server`)
} else {
setSyncStatus('idle')
showToast('Sync failed', 3000)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.5.1",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.5.1",
"version": "1.6.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.5.1",
"version": "1.6.0",
"description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
"main": "dist/index.js",
"type": "module",
+67 -10
View File
@@ -168,6 +168,21 @@ function runMigrations(): void {
db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id)`);
// Migration: add sync_version to elements table
const elementCols = db.prepare("PRAGMA table_info(elements)").all() as { name: string }[];
if (!elementCols.some(c => c.name === 'sync_version')) {
db.exec(`ALTER TABLE elements ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 0`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_elements_sync_version ON elements(project_id, sync_version)`);
logger.info('Migrated: added sync_version column to elements');
}
// Migration: add sync_version counter to projects table
const projectCols = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
if (!projectCols.some(c => c.name === 'sync_version')) {
db.exec(`ALTER TABLE projects ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 0`);
logger.info('Migrated: added sync_version counter to projects');
}
// 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) {
@@ -195,6 +210,44 @@ function pid(override?: string): string {
return override ?? activeProjectId;
}
// ── Sync Version ──
export function incrementSyncVersion(projectId?: string): number {
const p = pid(projectId);
db.prepare('UPDATE projects SET sync_version = sync_version + 1 WHERE id = ?').run(p);
const row = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get(p) as { sync_version: number } | undefined;
return row?.sync_version ?? 0;
}
export function getCurrentSyncVersion(projectId?: string): number {
const p = pid(projectId);
const row = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get(p) as { sync_version: number } | undefined;
return row?.sync_version ?? 0;
}
export interface ElementChange {
id: string;
action: 'upsert' | 'delete';
element: ServerElement;
sync_version: number;
}
export function getChangesSince(sinceVersion: number, projectId?: string): ElementChange[] {
const p = pid(projectId);
const rows = db.prepare(`
SELECT id, data, sync_version, is_deleted FROM elements
WHERE project_id = ? AND sync_version > ?
ORDER BY sync_version ASC
`).all(p, sinceVersion) as { id: string; data: string; sync_version: number; is_deleted: number }[];
return rows.map(r => ({
id: r.id,
action: r.is_deleted ? 'delete' as const : 'upsert' as const,
element: JSON.parse(r.data),
sync_version: r.sync_version
}));
}
// Given a tenant ID, return its default project (creating one if needed)
export function getDefaultProjectForTenant(tenantId: string): string {
const row = db.prepare(
@@ -227,11 +280,12 @@ export function hasElement(id: string, projectId?: string): boolean {
return !!row;
}
export function setElement(id: string, element: ServerElement, projectId?: string): void {
export function setElement(id: string, element: ServerElement, projectId?: string): number {
const p = pid(projectId);
const now = new Date().toISOString();
const data = JSON.stringify(element);
const labelText = extractLabelText(element);
const sv = incrementSyncVersion(p);
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;
@@ -239,21 +293,22 @@ export function setElement(id: string, element: ServerElement, projectId?: strin
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
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0, sync_version = ?
WHERE id = ? AND project_id = ?
`).run(element.type, data, labelText, now, newVersion, id, p);
`).run(element.type, data, labelText, now, newVersion, sv, 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);
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version, sync_version)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
`).run(id, p, element.type, data, labelText, now, now, sv);
recordVersion(id, 1, data, 'create', p);
insertFts(id, labelText, element.type);
}
return sv;
}
export function deleteElement(id: string, projectId?: string): boolean {
@@ -265,10 +320,11 @@ export function deleteElement(id: string, projectId?: string): boolean {
if (!existing) return false;
const newVersion = existing.version + 1;
const sv = incrementSyncVersion(p);
db.prepare(`
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?, sync_version = ?
WHERE id = ? AND project_id = ?
`).run(newVersion, new Date().toISOString(), id, p);
`).run(newVersion, new Date().toISOString(), sv, id, p);
recordVersion(id, newVersion, existing.data, 'delete', p);
deleteFts(id);
@@ -293,14 +349,15 @@ export function clearElements(projectId?: string): number {
const p = pid(projectId);
const now = new Date().toISOString();
const elements = getAllElements(p);
const sv = incrementSyncVersion(p);
const stmt = db.prepare(`
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?, sync_version = ?
WHERE project_id = ? AND is_deleted = 0
`);
const clearTx = db.transaction(() => {
const info = stmt.run(now, p);
const info = stmt.run(now, sv, p);
for (const el of elements) {
recordVersion(el.id, (el.version || 1) + 1, JSON.stringify(el), 'delete', p);
deleteFts(el.id);
+83 -39
View File
@@ -122,9 +122,18 @@ interface ApiResponse {
count?: number;
}
interface CanvasStatus {
connectedBrowsers: number;
ackedBy: number;
reason?: string;
scope: string;
}
interface SyncResponse {
element?: ServerElement;
elements?: ServerElement[];
syncedToCanvas?: boolean;
canvasStatus?: CanvasStatus;
}
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
@@ -206,27 +215,27 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
}
// Helper to sync element creation to canvas
async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
async function createElementOnCanvas(elementData: ServerElement): Promise<SyncResponse | null> {
const result = await syncToCanvas('create', elementData);
return result?.element || elementData;
return result ?? null;
}
// Helper to sync element update to canvas
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
// Helper to sync element update to canvas
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<SyncResponse | null> {
const result = await syncToCanvas('update', elementData);
return result?.element || null;
return result ?? null;
}
// Helper to sync element deletion to canvas
async function deleteElementOnCanvas(elementId: string): Promise<any> {
const result = await syncToCanvas('delete', { id: elementId });
return result;
return result ?? null;
}
// Helper to sync batch creation to canvas
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<SyncResponse | null> {
const result = await syncToCanvas('batch_create', elementsData);
return result?.elements || elementsData;
return result ?? null;
}
// Helper to fetch element from canvas
@@ -1052,22 +1061,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const excalidrawElement = convertTextToLabel(element);
// Create element directly on HTTP server (no local storage)
const canvasElement = await createElementOnCanvas(excalidrawElement);
if (!canvasElement) {
const canvasResponse = await createElementOnCanvas(excalidrawElement);
if (!canvasResponse) {
throw new Error('Failed to create element: HTTP server unavailable');
}
logger.info('Element created via MCP and synced to canvas', {
id: excalidrawElement.id,
const synced = canvasResponse.syncedToCanvas ?? false;
logger.info('Element created via MCP', {
id: excalidrawElement.id,
type: excalidrawElement.type,
synced: !!canvasElement
synced,
canvasStatus: canvasResponse.canvasStatus
});
const statusEmoji = synced ? '✅' : '⚠️';
const statusText = synced
? 'Synced to canvas and confirmed by browser'
: `Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`;
return {
content: [{
type: 'text',
text: `Element created successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
content: [{
type: 'text',
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${statusEmoji} ${statusText}`
}]
};
}
@@ -1091,21 +1107,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
// Update element directly on HTTP server (no local storage)
const canvasElement = await updateElementOnCanvas(excalidrawElement);
if (!canvasElement) {
const canvasResponse = await updateElementOnCanvas(excalidrawElement);
if (!canvasResponse) {
throw new Error('Failed to update element: HTTP server unavailable or element not found');
}
logger.info('Element updated via MCP and synced to canvas', {
id: excalidrawElement.id,
synced: !!canvasElement
const synced = canvasResponse.syncedToCanvas ?? false;
logger.info('Element updated via MCP', {
id: excalidrawElement.id,
synced,
canvasStatus: canvasResponse.canvasStatus
});
return {
content: [{
content: [{
type: 'text',
text: `Element updated successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
text: `Element updated successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${synced ? '✅ Synced to canvas and confirmed' : `⚠️ Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`}`
}]
};
}
@@ -1549,28 +1567,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
createdElements.push(excalidrawElement);
}
const canvasElements = await batchCreateElementsOnCanvas(createdElements);
const canvasResponse = await batchCreateElementsOnCanvas(createdElements);
if (!canvasElements) {
if (!canvasResponse) {
throw new Error('Failed to batch create elements: HTTP server unavailable');
}
const result = {
success: true,
elements: canvasElements,
count: canvasElements.length,
syncedToCanvas: true
elements: canvasResponse.elements ?? createdElements,
count: (canvasResponse.elements ?? createdElements).length,
syncedToCanvas: canvasResponse.syncedToCanvas ?? false,
canvasStatus: canvasResponse.canvasStatus
};
logger.info('Batch elements created via MCP and synced to canvas', {
logger.info('Batch elements created via MCP', {
count: result.count,
synced: result.syncedToCanvas
synced: result.syncedToCanvas,
canvasStatus: result.canvasStatus
});
const statusEmoji = result.syncedToCanvas ? '✅' : '⚠️';
const statusText = result.syncedToCanvas
? 'All elements synced to canvas and confirmed by browser'
: `Canvas sync not confirmed (${result.canvasStatus?.reason ?? 'unknown'})`;
return {
content: [{
type: 'text',
text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${result.syncedToCanvas ? '✅ All elements synced to canvas' : '⚠️ Canvas sync failed (elements still created locally)'}`
text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${statusEmoji} ${statusText}`
}]
};
}
@@ -2695,12 +2720,31 @@ async function runServer(): Promise<void> {
const { tenantId: newTid } = applyTenant(workspacePath);
try {
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
const putRes = await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
method: 'PUT',
headers: canvasHeaders(),
body: JSON.stringify({ tenantId: newTid })
});
} catch {}
if (!putRes.ok) {
logger.error(`Failed to set tenant on canvas server: HTTP ${putRes.status}`);
}
// Verify the canvas server accepted the tenant switch
const verifyRes = await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
headers: canvasHeaders()
});
if (verifyRes.ok) {
const verifyData = await verifyRes.json() as { tenant?: { id?: string } };
if (verifyData.tenant?.id !== newTid) {
logger.error(
`Canvas server has stale tenant: expected "${newTid}", got "${verifyData.tenant?.id}". ` +
`Restart the canvas server or kill the process on port ${process.env['CANVAS_PORT'] || 3000}.`
);
}
}
} catch (tenantErr) {
logger.error('Failed to update tenant on canvas server:', (tenantErr as Error).message);
}
}
}
} catch (rootsErr) {
+348 -65
View File
@@ -22,10 +22,12 @@ import {
Snapshot,
normalizeFontFamily,
ExcalidrawFile,
files
files,
ClientConnection,
BroadcastResult
} from './types.js';
import * as store from './db.js';
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getCurrentSyncVersion, getChangesSince } from './db.js';
import { z } from 'zod';
import WebSocket from 'ws';
@@ -57,37 +59,182 @@ function resolveTenantProject(req: Request): string | undefined {
return getDefaultProjectForTenant(tenantId);
}
// WebSocket connections
const clients = new Set<WebSocket>();
// Broadcast to all connected clients
function broadcast(message: WebSocketMessage): void {
const data = JSON.stringify(message);
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
// Resolve both tenantId and projectId for scoped broadcast.
// Falls back to active tenant/project when header is absent.
function resolveScope(req: Request): { tenantId: string; projectId: string } {
const headerTenantId = req.headers['x-tenant-id'] as string | undefined;
if (headerTenantId) {
const projectId = getDefaultProjectForTenant(headerTenantId) ?? `${headerTenantId}-default`;
return { tenantId: headerTenantId, projectId };
}
// Fallback for browser requests without header
const tenant = dbGetActiveTenant();
const projectId = getDefaultProjectForTenant(tenant.id) ?? `${tenant.id}-default`;
return { tenantId: tenant.id, projectId };
}
// WebSocket connection handling
wss.on('connection', (ws: WebSocket) => {
clients.add(ws);
logger.info('New WebSocket connection established');
// ── Connection Registry (Task 3) ──────────────────────────────────────────
// Scoped by tenant → project → Set<ClientConnection>
const connections = new Map<string, Map<string, Set<ClientConnection>>>();
// Reverse lookup: ws → ClientConnection (for fast cleanup)
const wsToConnection = new Map<WebSocket, ClientConnection>();
// 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
function registerConnection(conn: ClientConnection): void {
let tenantMap = connections.get(conn.tenantId);
if (!tenantMap) {
tenantMap = new Map();
connections.set(conn.tenantId, tenantMap);
}
let projectSet = tenantMap.get(conn.projectId);
if (!projectSet) {
projectSet = new Set();
tenantMap.set(conn.projectId, projectSet);
}
projectSet.add(conn);
wsToConnection.set(conn.ws, conn);
}
function unregisterConnection(ws: WebSocket): void {
const conn = wsToConnection.get(ws);
if (!conn) return;
const tenantMap = connections.get(conn.tenantId);
if (tenantMap) {
const projectSet = tenantMap.get(conn.projectId);
if (projectSet) {
projectSet.delete(conn);
if (projectSet.size === 0) tenantMap.delete(conn.projectId);
}
if (tenantMap.size === 0) connections.delete(conn.tenantId);
}
wsToConnection.delete(ws);
}
function moveConnection(ws: WebSocket, newTenantId: string, newProjectId: string): void {
unregisterConnection(ws);
const conn = { ws, tenantId: newTenantId, projectId: newProjectId, connectedAt: Date.now(), identified: true };
registerConnection(conn);
}
function getConnectionsForScope(tenantId: string, projectId: string): Set<ClientConnection> {
return connections.get(tenantId)?.get(projectId) ?? new Set();
}
// ── Scoped Broadcast (Task 5) ─────────────────────────────────────────────
function broadcastToScope(
tenantId: string,
projectId: string,
message: WebSocketMessage,
exclude?: WebSocket
): BroadcastResult {
const msgId = generateId();
(message as any).msgId = msgId;
const scopeConns = getConnectionsForScope(tenantId, projectId);
const targets = [...scopeConns].filter(c =>
c.ws !== exclude && c.ws.readyState === WebSocket.OPEN
);
if (targets.length === 0) {
return { delivered: 0, msgId, reason: 'no_clients_in_scope' };
}
const data = JSON.stringify(message);
for (const conn of targets) {
conn.ws.send(data);
}
return { delivered: targets.length, msgId };
}
// ── ACK Tracking (Task 6) ─────────────────────────────────────────────────
interface AckResult {
acked: boolean;
delivered: number;
reason?: string;
ackPayload?: { status: string; elementCount?: number; expectedCount?: number };
}
interface PendingAck {
resolve: (payload: { status: string; elementCount?: number; expectedCount?: number } | null) => void;
timer: ReturnType<typeof setTimeout>;
}
const pendingAcks = new Map<string, PendingAck>();
function resolveAck(msgId: string, payload: { status: string; elementCount?: number; expectedCount?: number }): void {
const pending = pendingAcks.get(msgId);
if (!pending) return;
clearTimeout(pending.timer);
pendingAcks.delete(msgId);
pending.resolve(payload);
}
async function broadcastWithAck(
tenantId: string,
projectId: string,
message: WebSocketMessage,
timeoutMs: number = 3000
): Promise<AckResult> {
const br = broadcastToScope(tenantId, projectId, message);
if (br.delivered === 0) {
return { acked: false, delivered: 0, reason: br.reason ?? 'no_clients' };
}
// Wait for first ACK from any client
const ackPayload = await new Promise<{ status: string; elementCount?: number; expectedCount?: number } | null>((resolve) => {
const timer = setTimeout(() => {
pendingAcks.delete(br.msgId);
resolve(null);
}, timeoutMs);
pendingAcks.set(br.msgId, { resolve, timer });
});
return {
acked: ackPayload !== null,
delivered: br.delivered,
ackPayload: ackPayload ?? undefined,
reason: ackPayload ? undefined : 'ack_timeout'
};
}
// Legacy broadcast: sends to ALL connected clients (used for global messages
// like tenant_switched that aren't scoped to a single project).
function broadcast(message: WebSocketMessage): void {
const data = JSON.stringify(message);
for (const conn of wsToConnection.values()) {
if (conn.ws.readyState === WebSocket.OPEN) {
conn.ws.send(data);
}
}
}
// ── WebSocket Connection Handling (Task 4: Hello Handshake) ───────────────
wss.on('connection', (ws: WebSocket) => {
// Register with fallback scope until hello handshake identifies the client.
const tenant = (() => { try { return dbGetActiveTenant(); } catch { return { id: 'default', name: 'default', workspace_path: '' }; } })();
const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default';
const conn: ClientConnection = {
ws,
tenantId: tenant.id,
projectId: fallbackProjectId,
connectedAt: Date.now(),
identified: false
};
registerConnection(conn);
logger.info('New WebSocket connection established (awaiting hello)');
// Send tenant info so the FE knows where to send hello
ws.send(JSON.stringify({
type: 'tenant_switched',
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
}));
// For backward compatibility: also send initial_elements immediately.
// New FE versions will ignore this and use hello_ack instead.
const initialMessage: InitialElementsMessage = {
type: 'initial_elements',
elements: store.getAllElements()
elements: store.getAllElements(fallbackProjectId)
};
ws.send(JSON.stringify(initialMessage));
@@ -99,23 +246,57 @@ wss.on('connection', (ws: WebSocket) => {
}
ws.send(JSON.stringify({ type: 'files_added', files: allFiles }));
}
// Send sync status to new client
const syncMessage: SyncStatusMessage = {
type: 'sync_status',
elementCount: store.getElementCount(),
elementCount: store.getElementCount(fallbackProjectId),
timestamp: new Date().toISOString()
};
ws.send(JSON.stringify(syncMessage));
// Handle incoming messages from this client
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === 'hello') {
const helloTenantId = msg.tenantId as string;
const helloProjectId = msg.projectId as string;
if (helloTenantId && helloProjectId) {
// Move connection to the correct scope
moveConnection(ws, helloTenantId, helloProjectId);
logger.info(`Client identified: tenant=${helloTenantId} project=${helloProjectId}`);
// Respond with scoped elements
const elements = store.getAllElements(helloProjectId);
ws.send(JSON.stringify({
type: 'hello_ack',
tenantId: helloTenantId,
projectId: helloProjectId,
elements
}));
}
}
if (msg.type === 'ack' && msg.msgId) {
resolveAck(msg.msgId, {
status: msg.status ?? 'applied',
elementCount: msg.elementCount,
expectedCount: msg.expectedCount
});
}
} catch (err) {
logger.debug('Failed to parse WS message from client:', (err as Error).message);
}
});
ws.on('close', () => {
clients.delete(ws);
unregisterConnection(ws);
logger.info('WebSocket connection closed');
});
ws.on('error', (error) => {
logger.error('WebSocket error:', error);
clients.delete(ws);
unregisterConnection(ws);
});
});
@@ -223,7 +404,7 @@ app.get('/api/elements', (req: Request, res: Response) => {
});
// Create new element
app.post('/api/elements', (req: Request, res: Response) => {
app.post('/api/elements', async (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const params = CreateElementSchema.parse(req.body);
@@ -240,17 +421,26 @@ app.post('/api/elements', (req: Request, res: Response) => {
version: 1
};
store.setElement(id, element, projId);
const sv = store.setElement(id, element, projId);
const scope = resolveScope(req);
const message: ElementCreatedMessage = {
type: 'element_created',
element: element
};
broadcast(message);
(message as any).sync_version = sv;
const ackResult = await broadcastWithAck(scope.tenantId, scope.projectId, message);
res.json({
success: true,
element: element
element: element,
syncedToCanvas: ackResult.acked,
canvasStatus: {
connectedBrowsers: ackResult.delivered,
ackedBy: ackResult.acked ? 1 : 0,
reason: ackResult.reason,
scope: `${scope.tenantId}/${scope.projectId}`
}
});
} catch (error) {
logger.error('Error creating element:', error);
@@ -262,7 +452,7 @@ app.post('/api/elements', (req: Request, res: Response) => {
});
// Update element
app.put('/api/elements/:id', (req: Request, res: Response) => {
app.put('/api/elements/:id', async (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { id } = req.params;
@@ -292,17 +482,26 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
version: (existingElement.version || 0) + 1
};
store.setElement(id, updatedElement, projId);
const sv = store.setElement(id, updatedElement, projId);
const scope = resolveScope(req);
const message: ElementUpdatedMessage = {
type: 'element_updated',
element: updatedElement
};
broadcast(message);
(message as any).sync_version = sv;
const ackResult = await broadcastWithAck(scope.tenantId, scope.projectId, message);
res.json({
success: true,
element: updatedElement
element: updatedElement,
syncedToCanvas: ackResult.acked,
canvasStatus: {
connectedBrowsers: ackResult.delivered,
ackedBy: ackResult.acked ? 1 : 0,
reason: ackResult.reason,
scope: `${scope.tenantId}/${scope.projectId}`
}
});
} catch (error) {
logger.error('Error updating element:', error);
@@ -319,7 +518,8 @@ app.delete('/api/elements/clear', (req: Request, res: Response) => {
const projId = resolveTenantProject(req);
const count = store.clearElements(projId);
broadcast({
const scope = resolveScope(req);
broadcastToScope(scope.tenantId, scope.projectId, {
type: 'canvas_cleared',
timestamp: new Date().toISOString()
});
@@ -361,13 +561,13 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
}
store.deleteElement(id, projId);
// Broadcast to all connected clients
const scope = resolveScope(req);
const message: ElementDeletedMessage = {
type: 'element_deleted',
elementId: id!
};
broadcast(message);
broadcastToScope(scope.tenantId, scope.projectId, message);
res.json({
success: true,
@@ -579,7 +779,7 @@ function resolveArrowBindings(batchElements: ServerElement[], projectId?: string
}
// Batch create elements
app.post('/api/elements/batch', (req: Request, res: Response) => {
app.post('/api/elements/batch', async (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { elements: elementsToCreate } = req.body;
@@ -611,19 +811,28 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
resolveArrowBindings(createdElements, projId);
createdElements.forEach(el => store.setElement(el.id, el, projId));
let latestSyncVersion = 0;
createdElements.forEach(el => { latestSyncVersion = store.setElement(el.id, el, projId); });
// Broadcast to all connected clients
const scope = resolveScope(req);
const message: BatchCreatedMessage = {
type: 'elements_batch_created',
elements: createdElements
};
broadcast(message);
(message as any).sync_version = latestSyncVersion;
const ackResult = await broadcastWithAck(scope.tenantId, scope.projectId, message);
res.json({
success: true,
elements: createdElements,
count: createdElements.length
count: createdElements.length,
syncedToCanvas: ackResult.acked,
canvasStatus: {
connectedBrowsers: ackResult.delivered,
ackedBy: ackResult.acked ? 1 : 0,
reason: ackResult.reason,
scope: `${scope.tenantId}/${scope.projectId}`
}
});
} catch (error) {
logger.error('Error batch creating elements:', error);
@@ -651,8 +860,9 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
hasConfig: !!config
});
// Broadcast to all WebSocket clients to process the Mermaid diagram
broadcast({
// Broadcast to scoped WebSocket clients to process the Mermaid diagram
const scope = resolveScope(req);
broadcastToScope(scope.tenantId, scope.projectId, {
type: 'mermaid_convert',
mermaidDiagram,
config: config || {},
@@ -720,7 +930,8 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
store.bulkReplaceElements(processedElements, projId);
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
broadcast({
const scope = resolveScope(req);
broadcastToScope(scope.tenantId, scope.projectId, {
type: 'elements_synced',
count: successCount,
timestamp: new Date().toISOString(),
@@ -746,6 +957,76 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
}
});
// ── Delta Sync v2 (Task 10) ──
app.post('/api/elements/sync/v2', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const { lastSyncVersion = 0, changes = [] } = req.body;
if (typeof lastSyncVersion !== 'number') {
return res.status(400).json({ success: false, error: 'lastSyncVersion must be a number' });
}
const scope = resolveScope(req);
const feChangeIds = new Set<string>();
// Apply FE changes to DB
let appliedCount = 0;
for (const change of changes) {
const { id, action, element } = change;
if (!id || !action) continue;
feChangeIds.add(id);
if (action === 'delete') {
store.deleteElement(id, projId);
appliedCount++;
} else if (action === 'upsert' && element) {
store.setElement(id, element, projId);
appliedCount++;
}
}
// Get BE-side changes the FE hasn't seen (excluding what FE just sent)
const allBEChanges = getChangesSince(lastSyncVersion, projId);
const serverChanges = allBEChanges.filter(c => !feChangeIds.has(c.id));
const currentVersion = getCurrentSyncVersion(projId);
// Broadcast FE changes to other tabs in scope
if (appliedCount > 0) {
broadcastToScope(scope.tenantId, scope.projectId, {
type: 'elements_synced',
count: appliedCount,
timestamp: new Date().toISOString(),
source: 'delta_sync_v2',
sync_version: currentVersion
});
}
res.json({
success: true,
currentSyncVersion: currentVersion,
serverChanges,
appliedCount
});
} catch (error) {
logger.error('Delta sync v2 error:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
// Get current sync version for a project
app.get('/api/sync/version', (req: Request, res: Response) => {
try {
const projId = resolveTenantProject(req);
const version = getCurrentSyncVersion(projId);
res.json({ success: true, syncVersion: version });
} catch (error) {
res.status(500).json({ success: false, error: (error as Error).message });
}
});
// ── Files API (image element data) ──
// Get all files
@@ -829,7 +1110,7 @@ app.post('/api/export/image', (req: Request, res: Response) => {
});
}
if (clients.size === 0) {
if (wsToConnection.size === 0) {
return res.status(503).json({
success: false,
error: 'No frontend client connected. Open the canvas in a browser first.'
@@ -837,6 +1118,7 @@ app.post('/api/export/image', (req: Request, res: Response) => {
}
const requestId = generateId();
const scope = resolveScope(req);
const exportPromise = new Promise<{ format: string; data: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -847,7 +1129,7 @@ app.post('/api/export/image', (req: Request, res: Response) => {
pendingExports.set(requestId, { resolve, reject, timeout });
});
broadcast({
broadcastToScope(scope.tenantId, scope.projectId, {
type: 'export_image_request',
requestId,
format,
@@ -928,7 +1210,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
try {
const { scrollToContent, scrollToElementId, zoom, offsetX, offsetY } = req.body;
if (clients.size === 0) {
if (wsToConnection.size === 0) {
return res.status(503).json({
success: false,
error: 'No frontend client connected. Open the canvas in a browser first.'
@@ -936,6 +1218,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
}
const requestId = generateId();
const scope = resolveScope(req);
const viewportPromise = new Promise<{ success: boolean; message: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -946,7 +1229,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
pendingViewports.set(requestId, { resolve, reject, timeout });
});
broadcast({
broadcastToScope(scope.tenantId, scope.projectId, {
type: 'set_viewport',
requestId,
scrollToContent,
@@ -1183,7 +1466,7 @@ app.get('/health', (req: Request, res: Response) => {
status: 'healthy',
timestamp: new Date().toISOString(),
elements_count: store.getElementCount(projId),
websocket_clients: clients.size
websocket_clients: wsToConnection.size
});
});
@@ -1198,7 +1481,7 @@ app.get('/api/sync/status', (req: Request, res: Response) => {
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB
},
websocketClients: clients.size
websocketClients: wsToConnection.size
});
});
@@ -1267,7 +1550,7 @@ export function stopCanvasServer(): Promise<void> {
return Promise.resolve();
}
return new Promise((resolve) => {
clients.forEach(c => c.close());
for (const conn of wsToConnection.values()) conn.ws.close();
httpServer.close(() => resolve());
});
}
+40 -1
View File
@@ -193,7 +193,46 @@ export type WebSocketMessageType =
| 'set_viewport'
| 'tenant_switched'
| 'files_added'
| 'file_deleted';
| 'file_deleted'
| 'hello'
| 'hello_ack'
| 'ack';
// Connection registry types
export interface ClientConnection {
ws: import('ws').WebSocket;
tenantId: string;
projectId: string;
connectedAt: number;
identified: boolean; // true after hello handshake
}
export interface BroadcastResult {
delivered: number;
msgId: string;
reason?: string;
}
export interface HelloMessage extends WebSocketMessage {
type: 'hello';
tenantId: string;
projectId: string;
}
export interface HelloAckMessage extends WebSocketMessage {
type: 'hello_ack';
tenantId: string;
projectId: string;
elements: ServerElement[];
}
export interface AckMessage extends WebSocketMessage {
type: 'ack';
msgId: string;
status: 'applied' | 'partial' | 'failed';
elementCount?: number;
expectedCount?: number;
}
export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements';
+158 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting } from '../../src/db.js';
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
@@ -27,6 +27,8 @@ function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-api-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
// Reset module-level active tenant/project to 'default' (may be stale from previous test)
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
@@ -430,3 +432,158 @@ describe('Tenant-scoped requests via X-Tenant-Id', () => {
expect(resB.body.elements[0].type).toBe('ellipse');
});
});
// ─── Sync Version ───────────────────────────────────────────
describe('GET /api/sync/version', () => {
it('returns syncVersion 0 initially', async () => {
const res = await request(app).get('/api/sync/version');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.syncVersion).toBe(0);
});
it('syncVersion increases after element creation', async () => {
await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
const res = await request(app).get('/api/sync/version');
expect(res.status).toBe(200);
expect(res.body.syncVersion).toBeGreaterThan(0);
});
});
// ─── Delta Sync v2 ──────────────────────────────────────────
describe('POST /api/elements/sync/v2', () => {
it('returns currentSyncVersion and empty serverChanges', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: 0, changes: [] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('currentSyncVersion');
expect(typeof res.body.currentSyncVersion).toBe('number');
expect(Array.isArray(res.body.serverChanges)).toBe(true);
expect(res.body.serverChanges.length).toBe(0);
});
it('applies upsert changes', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [
{
id: 'sv2-1',
action: 'upsert',
element: { id: 'sv2-1', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
},
],
});
expect(res.status).toBe(200);
expect(res.body.appliedCount).toBe(1);
const getRes = await request(app).get('/api/elements/sv2-1');
expect(getRes.status).toBe(200);
expect(getRes.body.element.id).toBe('sv2-1');
});
it('applies delete changes', async () => {
setElement('sv2-del', makeElement({ id: 'sv2-del' }));
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [{ id: 'sv2-del', action: 'delete' }],
});
expect(res.status).toBe(200);
expect(res.body.appliedCount).toBe(1);
const getRes = await request(app).get('/api/elements/sv2-del');
expect(getRes.status).toBe(404);
});
it('returns server changes since lastSyncVersion', async () => {
setElement('sv1', makeElement({ id: 'sv1' }));
setElement('sv2', makeElement({ id: 'sv2' }));
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: 0, changes: [] });
expect(res.status).toBe(200);
expect(res.body.serverChanges.length).toBeGreaterThanOrEqual(2);
const ids = res.body.serverChanges.map((c: any) => c.id);
expect(ids).toContain('sv1');
expect(ids).toContain('sv2');
});
it('rejects non-number lastSyncVersion', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: 'bad', changes: [] });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
// ─── canvasStatus in mutation responses ─────────────────────
describe('canvasStatus in mutation responses', () => {
it('POST /api/elements includes syncedToCanvas and canvasStatus', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
expect(res.status).toBe(200);
expect(typeof res.body.syncedToCanvas).toBe('boolean');
expect(res.body.syncedToCanvas).toBe(false);
expect(res.body.canvasStatus).toBeDefined();
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
expect(res.body.canvasStatus).toHaveProperty('reason');
expect(res.body.canvasStatus).toHaveProperty('scope');
});
it('PUT /api/elements/:id includes canvasStatus', async () => {
setElement('cs-put', makeElement({ id: 'cs-put', x: 0 }));
const res = await request(app)
.put('/api/elements/cs-put')
.send({ x: 100 });
expect(res.status).toBe(200);
expect(typeof res.body.syncedToCanvas).toBe('boolean');
expect(res.body.canvasStatus).toBeDefined();
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
expect(res.body.canvasStatus).toHaveProperty('reason');
expect(res.body.canvasStatus).toHaveProperty('scope');
});
it('POST /api/elements/batch includes canvasStatus', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({
elements: [
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
],
});
expect(res.status).toBe(200);
expect(typeof res.body.syncedToCanvas).toBe('boolean');
expect(res.body.canvasStatus).toBeDefined();
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
expect(res.body.canvasStatus).toHaveProperty('reason');
expect(res.body.canvasStatus).toHaveProperty('scope');
});
});
+94
View File
@@ -30,6 +30,9 @@ import {
bulkReplaceElements,
getSetting,
setSetting,
incrementSyncVersion,
getCurrentSyncVersion,
getChangesSince,
} from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
@@ -424,3 +427,94 @@ describe('bulkReplaceElements', () => {
expect(getAllElements()).toEqual([]);
});
});
// ─── Sync Version ───────────────────────────────────────────
describe('Sync Version', () => {
it('getCurrentSyncVersion returns 0 initially', () => {
expect(getCurrentSyncVersion()).toBe(0);
});
it('incrementSyncVersion increments and returns new version', () => {
expect(incrementSyncVersion()).toBe(1);
expect(incrementSyncVersion()).toBe(2);
expect(incrementSyncVersion()).toBe(3);
});
it('setElement increments sync_version', () => {
setElement('sv1', makeElement({ id: 'sv1' }));
expect(getCurrentSyncVersion()).toBeGreaterThan(0);
});
it('setElement returns sync_version', () => {
const sv = setElement('sv2', makeElement({ id: 'sv2' }));
expect(sv).toBeGreaterThan(0);
});
it('deleteElement increments sync_version', () => {
setElement('del-sv', makeElement({ id: 'del-sv' }));
const versionAfterCreate = getCurrentSyncVersion();
deleteElement('del-sv');
expect(getCurrentSyncVersion()).toBeGreaterThan(versionAfterCreate);
});
it('clearElements increments sync_version', () => {
setElement('clr1', makeElement({ id: 'clr1' }));
setElement('clr2', makeElement({ id: 'clr2' }));
const versionAfterCreates = getCurrentSyncVersion();
clearElements();
expect(getCurrentSyncVersion()).toBeGreaterThan(versionAfterCreates);
});
it('getChangesSince returns empty for version 0 when no elements', () => {
const changes = getChangesSince(0);
expect(changes).toEqual([]);
});
it('getChangesSince returns upserts after setElement', () => {
setElement('cs1', makeElement({ id: 'cs1' }));
setElement('cs2', makeElement({ id: 'cs2' }));
const changes = getChangesSince(0);
expect(changes.length).toBe(2);
expect(changes.every(c => c.action === 'upsert')).toBe(true);
});
it('getChangesSince returns delete entries', () => {
setElement('csd1', makeElement({ id: 'csd1' }));
deleteElement('csd1');
const changes = getChangesSince(0);
const deleteChange = changes.find(c => c.action === 'delete');
expect(deleteChange).toBeDefined();
});
it('getChangesSince filters by version', () => {
const sv1 = setElement('fv1', makeElement({ id: 'fv1' }));
setElement('fv2', makeElement({ id: 'fv2' }));
const changes = getChangesSince(sv1);
expect(changes.length).toBe(1);
expect(changes[0]!.id).toBe('fv2');
});
it('sync_version is scoped per project', () => {
const proj1 = createProject('SV-P1');
const proj2 = createProject('SV-P2');
setActiveProject(proj1.id);
setElement('sp1', makeElement({ id: 'sp1' }));
const sv1 = getCurrentSyncVersion(proj1.id);
setActiveProject(proj2.id);
setElement('sp2', makeElement({ id: 'sp2' }));
setElement('sp3', makeElement({ id: 'sp3' }));
const sv2 = getCurrentSyncVersion(proj2.id);
// Each project tracks its own sync_version independently
expect(sv1).toBeGreaterThan(0);
expect(sv2).toBeGreaterThan(0);
// P2 had more mutations so its version should be higher than P1's
expect(sv2).toBeGreaterThan(sv1);
});
});
+198
View File
@@ -253,3 +253,201 @@ describe('WebSocket broadcasts', () => {
ws2.close();
});
});
describe('Hello handshake', () => {
it('client receives hello_ack after sending hello', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
ws.send(JSON.stringify({
type: 'hello',
tenantId: 'default',
projectId: 'default',
}));
const msg = await helloAckPromise;
expect(msg.type).toBe('hello_ack');
expect(msg.tenantId).toBe('default');
expect(msg.projectId).toBe('default');
expect(Array.isArray(msg.elements)).toBe(true);
ws.close();
});
it('hello_ack contains elements for the requested project', async () => {
setElement('hello-el', {
id: 'hello-el', type: 'rectangle', x: 5, y: 10, width: 80, height: 40, version: 1,
} as ServerElement);
const ws = await connectClient();
await drainInitialMessages(ws);
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
ws.send(JSON.stringify({
type: 'hello',
tenantId: 'default',
projectId: 'default',
}));
const msg = await helloAckPromise;
expect(msg.elements.length).toBeGreaterThanOrEqual(1);
const found = msg.elements.find((el: any) => el.id === 'hello-el');
expect(found).toBeDefined();
expect(found.type).toBe('rectangle');
ws.close();
});
});
describe('Scoped broadcast', () => {
it('broadcast reaches all clients in the same default scope', async () => {
const ws1 = await connectClient();
const ws2 = await connectClient();
await drainInitialMessages(ws1);
await drainInitialMessages(ws2);
const promise1 = waitForMessageOfType(ws1, 'element_created');
const promise2 = waitForMessageOfType(ws2, 'element_created');
await fetch(`http://localhost:${port}/api/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 30, height: 30 }),
});
const [msg1, msg2] = await Promise.all([promise1, promise2]);
expect(msg1.element.type).toBe('rectangle');
expect(msg2.element.type).toBe('rectangle');
// Both messages should have the same msgId since they came from the same broadcast
expect(msg1.msgId).toBe(msg2.msgId);
ws1.close();
ws2.close();
});
});
describe('ACK model', () => {
it('mutation broadcasts include msgId', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const createdPromise = waitForMessageOfType(ws, 'element_created');
await fetch(`http://localhost:${port}/api/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }),
});
const msg = await createdPromise;
expect(msg).toHaveProperty('msgId');
expect(typeof msg.msgId).toBe('string');
expect(msg.msgId.length).toBeGreaterThan(0);
ws.close();
});
it('server accepts ack messages without error', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const createdPromise = waitForMessageOfType(ws, 'element_created');
await fetch(`http://localhost:${port}/api/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'ellipse', x: 10, y: 10, width: 40, height: 40 }),
});
const msg = await createdPromise;
// Send ACK back — should not cause any errors or disconnection
ws.send(JSON.stringify({
type: 'ack',
msgId: msg.msgId,
status: 'applied',
}));
// Wait briefly to ensure server processes the ack without crashing
await new Promise((resolve) => setTimeout(resolve, 200));
// Verify the connection is still open (readyState 1 = OPEN)
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
});
});
describe('sync_version in broadcasts', () => {
it('element_created broadcast includes sync_version', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const createdPromise = waitForMessageOfType(ws, 'element_created');
await fetch(`http://localhost:${port}/api/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }),
});
const msg = await createdPromise;
expect(msg).toHaveProperty('sync_version');
expect(typeof msg.sync_version).toBe('number');
expect(msg.sync_version).toBeGreaterThan(0);
ws.close();
});
it('element_updated broadcast includes sync_version', async () => {
setElement('sv-upd', {
id: 'sv-upd', type: 'rectangle', x: 0, y: 0, width: 50, height: 50, version: 1,
} as ServerElement);
const ws = await connectClient();
await drainInitialMessages(ws);
const updatedPromise = waitForMessageOfType(ws, 'element_updated');
await fetch(`http://localhost:${port}/api/elements/sv-upd`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: 500 }),
});
const msg = await updatedPromise;
expect(msg).toHaveProperty('sync_version');
expect(typeof msg.sync_version).toBe('number');
expect(msg.sync_version).toBeGreaterThan(0);
ws.close();
});
it('elements_batch_created broadcast includes sync_version', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const batchPromise = waitForMessageOfType(ws, 'elements_batch_created');
await fetch(`http://localhost:${port}/api/elements/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
elements: [
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
],
}),
});
const msg = await batchPromise;
expect(msg).toHaveProperty('sync_version');
expect(typeof msg.sync_version).toBe('number');
expect(msg.sync_version).toBeGreaterThan(0);
ws.close();
});
});
+188
View File
@@ -271,3 +271,191 @@ test.describe('Settings via API', () => {
expect(body.value).toBeNull();
});
});
// ─── Sync Version API ───────────────────────────────────────
test.describe('Sync Version API', () => {
test('GET /api/sync/version returns initial version', async ({ request }) => {
const res = await request.get(`${API}/api/sync/version`);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
expect(typeof body.syncVersion).toBe('number');
});
test('sync version increases after element creation', async ({ request }) => {
const beforeRes = await request.get(`${API}/api/sync/version`);
const beforeBody = await beforeRes.json();
const versionBefore = beforeBody.syncVersion;
await request.post(`${API}/api/elements`, {
data: {
id: 'sync-ver-el',
type: 'rectangle',
x: 10,
y: 10,
width: 100,
height: 50,
},
});
const afterRes = await request.get(`${API}/api/sync/version`);
const afterBody = await afterRes.json();
expect(afterBody.syncVersion).toBeGreaterThan(versionBefore);
});
});
// ─── Delta Sync v2 API ──────────────────────────────────────
test.describe('Delta Sync v2 API', () => {
test('accepts empty changes and returns current state', async ({ request }) => {
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: { lastSyncVersion: 0, changes: [] },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
expect(typeof body.currentSyncVersion).toBe('number');
expect(Array.isArray(body.serverChanges)).toBe(true);
});
test('applies upsert changes via delta sync', async ({ request }) => {
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: 0,
changes: [
{
id: 'delta-upsert-1',
action: 'upsert',
element: {
id: 'delta-upsert-1',
type: 'rectangle',
x: 50,
y: 50,
width: 120,
height: 60,
},
},
],
},
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.appliedCount).toBeGreaterThanOrEqual(1);
// Verify the element exists via GET
const getRes = await request.get(`${API}/api/elements/delta-upsert-1`);
expect(getRes.ok()).toBe(true);
const getBody = await getRes.json();
expect(getBody.element.id).toBe('delta-upsert-1');
});
test('returns server changes for elements created via normal API', async ({ request }) => {
// Create an element via the normal REST API
await request.post(`${API}/api/elements`, {
data: {
id: 'normal-api-el',
type: 'ellipse',
x: 200,
y: 200,
width: 80,
height: 80,
},
});
// Now call delta sync with lastSyncVersion: 0 to get all server changes
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
data: { lastSyncVersion: 0, changes: [] },
});
expect(syncRes.ok()).toBe(true);
const syncBody = await syncRes.json();
expect(syncBody.serverChanges.some((el: any) => el.id === 'normal-api-el')).toBe(true);
});
});
// ─── canvasStatus in API responses ──────────────────────────
test.describe('canvasStatus in API responses', () => {
test('element creation response includes canvasStatus', async ({ request }) => {
const createRes = await request.post(`${API}/api/elements`, {
data: {
id: 'status-check-el',
type: 'rectangle',
x: 300,
y: 300,
width: 150,
height: 75,
},
});
expect(createRes.ok()).toBe(true);
const body = await createRes.json();
// syncedToCanvas should be a boolean
expect(typeof body.syncedToCanvas).toBe('boolean');
// canvasStatus object should be present with expected fields
expect(body.canvasStatus).toBeDefined();
expect(typeof body.canvasStatus.connectedBrowsers).toBe('number');
expect(typeof body.canvasStatus.ackedBy).toBe('number');
expect(typeof body.canvasStatus.reason).toBe('string');
expect(typeof body.canvasStatus.scope).toBe('string');
});
});
// ─── Real-time Sync with ACK ────────────────────────────────
test.describe('Real-time Sync with ACK', () => {
test('syncedToCanvas is true when browser is connected', async ({ page, request }) => {
// Open the page and wait for WebSocket connection
await page.goto('/');
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
await page.waitForTimeout(500);
// Create an element via API while browser is connected
const createRes = await request.post(`${API}/api/elements`, {
data: {
id: 'ack-test-rect',
type: 'rectangle',
x: 400,
y: 400,
width: 200,
height: 100,
backgroundColor: '#4ecdc4',
},
});
expect(createRes.ok()).toBe(true);
const body = await createRes.json();
// Browser should have ACKed, so syncedToCanvas should be true
expect(body.syncedToCanvas).toBe(true);
// Also verify the element exists in the backend
const verifyRes = await request.get(`${API}/api/elements/ack-test-rect`);
expect(verifyRes.ok()).toBe(true);
const verifyBody = await verifyRes.json();
expect(verifyBody.element.id).toBe('ack-test-rect');
});
test('batch create with browser connected gets ACK', async ({ page, request }) => {
// Open the page and wait for WebSocket connection
await page.goto('/');
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
await page.waitForTimeout(500);
// Batch create elements via API while browser is connected
const batchRes = await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'ack-batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'ack-batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
],
},
});
expect(batchRes.ok()).toBe(true);
const body = await batchRes.json();
// Browser should have ACKed the batch broadcast
expect(body.syncedToCanvas).toBe(true);
});
});
+160
View File
@@ -3,6 +3,10 @@ import {
cleanElementForExcalidraw,
validateAndFixBindings,
computeElementHash,
isImageElement,
isShapeContainerType,
normalizeImageElement,
restoreBindings,
} from '../../frontend/src/utils/elementHelpers.js';
import type { ServerElement } from '../../frontend/src/utils/elementHelpers.js';
@@ -216,3 +220,159 @@ describe('computeElementHash', () => {
expect(hash.startsWith('1')).toBe(true);
});
});
// ─── isImageElement ─────────────────────────────────────────
describe('isImageElement', () => {
it('returns true for image type', () => {
expect(isImageElement({ type: 'image' } as any)).toBe(true);
});
it('returns false for non-image types', () => {
expect(isImageElement({ type: 'rectangle' } as any)).toBe(false);
expect(isImageElement({ type: 'text' } as any)).toBe(false);
expect(isImageElement({ type: 'arrow' } as any)).toBe(false);
});
});
// ─── isShapeContainerType ───────────────────────────────────
describe('isShapeContainerType', () => {
it('returns true for container types', () => {
expect(isShapeContainerType('rectangle')).toBe(true);
expect(isShapeContainerType('ellipse')).toBe(true);
expect(isShapeContainerType('diamond')).toBe(true);
expect(isShapeContainerType('arrow')).toBe(true);
expect(isShapeContainerType('line')).toBe(true);
});
it('returns false for non-container types', () => {
expect(isShapeContainerType('text')).toBe(false);
expect(isShapeContainerType('image')).toBe(false);
expect(isShapeContainerType('freedraw')).toBe(false);
});
});
// ─── normalizeImageElement ──────────────────────────────────
describe('normalizeImageElement', () => {
it('fills in default values for missing properties', () => {
const el = { id: 'img1', type: 'image', x: 0, y: 0, width: 100, height: 100 };
const result = normalizeImageElement(el);
expect(result.status).toBe('saved');
expect(result.fileId).toBeNull();
expect(result.scale).toEqual([1, 1]);
expect(result.angle).toBe(0);
expect(result.roughness).toBe(1);
expect(result.opacity).toBe(100);
expect(result.isDeleted).toBe(false);
expect(result.locked).toBe(false);
});
it('preserves existing values', () => {
const el = {
id: 'img2',
type: 'image',
x: 0,
y: 0,
width: 100,
height: 100,
status: 'pending',
fileId: 'abc',
scale: [2, 2] as [number, number],
opacity: 50,
};
const result = normalizeImageElement(el);
expect(result.status).toBe('pending');
expect(result.fileId).toBe('abc');
expect(result.scale).toEqual([2, 2]);
expect(result.opacity).toBe(50);
});
});
// ─── restoreBindings ────────────────────────────────────────
describe('restoreBindings', () => {
it('restores startBinding and endBinding from originals', () => {
const converted = [
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
];
const originals = [
{
id: 'arrow1',
type: 'arrow',
x: 0,
y: 0,
startBinding: { elementId: 'rect1', focus: 0, gap: 5 },
endBinding: { elementId: 'rect2', focus: 0, gap: 5 },
},
];
const result = restoreBindings(converted, originals);
expect(result[0].startBinding).toEqual({ elementId: 'rect1', focus: 0, gap: 5 });
expect(result[0].endBinding).toEqual({ elementId: 'rect2', focus: 0, gap: 5 });
});
it('restores boundElements from originals', () => {
const converted = [
{ id: 'rect1', type: 'rectangle', x: 0, y: 0 },
];
const originals = [
{
id: 'rect1',
type: 'rectangle',
x: 0,
y: 0,
boundElements: [{ id: 'arrow1', type: 'arrow' }],
},
];
const result = restoreBindings(converted, originals);
expect(result[0].boundElements).toEqual([{ id: 'arrow1', type: 'arrow' }]);
});
it('restores elbowed property from originals', () => {
const converted = [
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
];
const originals = [
{ id: 'arrow1', type: 'arrow', x: 0, y: 0, elbowed: true },
];
const result = restoreBindings(converted, originals);
expect(result[0].elbowed).toBe(true);
});
it('does not overwrite existing bindings', () => {
const existingBinding = { elementId: 'rect99', focus: 1, gap: 10 };
const converted = [
{ id: 'arrow1', type: 'arrow', x: 0, y: 0, startBinding: existingBinding },
];
const originals = [
{
id: 'arrow1',
type: 'arrow',
x: 0,
y: 0,
startBinding: { elementId: 'rect1', focus: 0, gap: 5 },
},
];
const result = restoreBindings(converted, originals);
expect(result[0].startBinding).toEqual(existingBinding);
});
it('handles elements not found in originals', () => {
const converted = [
{ id: 'new1', type: 'rectangle', x: 0, y: 0 },
];
const originals = [
{ id: 'other', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'a', type: 'arrow' }] },
];
const result = restoreBindings(converted, originals);
expect(result[0]).toEqual({ id: 'new1', type: 'rectangle', x: 0, y: 0 });
});
});