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>
This commit is contained in:
Sanjib Devnath
2026-03-17 23:44:17 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent 4e410f1205
commit 2cca18153f
11 changed files with 1486 additions and 121 deletions
+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();
});
});