✨ 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:
co-authored by
Claude Opus 4.6
parent
4e410f1205
commit
2cca18153f
+158
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user