✨ 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user