Add server-side operation log synchronization: - Database schema: operations, user_sync_state, sync_devices, tombstones - SyncService: upload/download ops, snapshots, rate limiting, validation - REST API: POST/GET /api/sync/ops, /snapshot, /status, /devices/:id/ack - Cleanup jobs: daily tombstone/old ops cleanup, hourly stale device cleanup - Compression: gzip for snapshots - Security: timestamp validation, payload size limits, idempotency Endpoints: - POST /api/sync/ops - Upload operations batch - GET /api/sync/ops?sinceSeq=N - Download ops since sequence - GET /api/sync/snapshot - Get full state snapshot - POST /api/sync/snapshot - Upload full state (migration/recovery) - GET /api/sync/status - Get sync status - POST /api/sync/devices/:clientId/ack - Acknowledge sequences
39 KiB
Server Sync Implementation Plan (Part C)
Target Package: @super-productivity/super-sync-server
Status: Draft
Prerequisites: Parts A & B (Client-side) complete
Last Updated: December 2, 2025
Overview
This document provides a practical implementation plan for adding operation-log sync to the existing super-sync-server Fastify application. It focuses on what to build, in what order, and how to integrate with the existing codebase.
For architectural decisions and detailed algorithms, see Server Sync Architecture.
1. Database Schema
The implementation will use SQLite (better-sqlite3) to extend the existing database, prioritizing simplicity and low maintenance for self-hosting.
Tables
-- Stores the global operation log (append-only)
CREATE TABLE IF NOT EXISTS operations (
id TEXT PRIMARY KEY, -- UUID v7 (client-generated)
user_id INTEGER NOT NULL, -- Owner (FK to users)
client_id TEXT NOT NULL, -- Device identifier
server_seq INTEGER NOT NULL, -- Global monotonic sequence (The Source of Truth)
action_type TEXT NOT NULL, -- NgRx action type (for replay)
op_type TEXT NOT NULL, -- CRT | UPD | DEL | MOV | BATCH
entity_type TEXT NOT NULL, -- TASK | PROJECT | TAG | etc.
entity_id TEXT, -- Affected entity ID (nullable for batch)
payload TEXT NOT NULL, -- JSON: operation payload
vector_clock TEXT NOT NULL, -- JSON: { clientId: seq } (Preserved for client logic)
schema_version INTEGER NOT NULL, -- Client schema version
client_timestamp INTEGER NOT NULL, -- Wall clock from client
received_at INTEGER NOT NULL, -- Server timestamp
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Indexes for efficient sync queries
CREATE INDEX IF NOT EXISTS idx_ops_user_seq ON operations(user_id, server_seq);
CREATE INDEX IF NOT EXISTS idx_ops_user_entity ON operations(user_id, entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_ops_client ON operations(user_id, client_id);
-- Per-user sequence counter (Critical for ordering)
CREATE TABLE IF NOT EXISTS user_sync_state (
user_id INTEGER PRIMARY KEY,
last_seq INTEGER NOT NULL DEFAULT 0,
last_snapshot_seq INTEGER, -- Sequence at last snapshot
snapshot_data BLOB, -- Compressed (gzip) full state snapshot
snapshot_at INTEGER, -- When snapshot was created
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Ensure unique server_seq per user (critical for sync correctness)
CREATE UNIQUE INDEX IF NOT EXISTS idx_ops_user_seq_unique ON operations(user_id, server_seq);
-- Device/client tracking
CREATE TABLE IF NOT EXISTS sync_devices (
client_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
device_name TEXT,
user_agent TEXT,
last_seen_at INTEGER NOT NULL,
last_acked_seq INTEGER NOT NULL DEFAULT 0, -- Last seq this device confirmed
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, client_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Tombstones for deleted entities (prevents resurrection)
CREATE TABLE IF NOT EXISTS tombstones (
user_id INTEGER NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
deleted_at INTEGER NOT NULL,
deleted_by_op_id TEXT NOT NULL,
expires_at INTEGER NOT NULL, -- 90 days after deletion
PRIMARY KEY (user_id, entity_type, entity_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Cleanup expired tombstones periodically
CREATE INDEX IF NOT EXISTS idx_tombstones_expires ON tombstones(expires_at);
Migration Script
// src/db/migrations/002_add_sync_tables.ts
import Database from 'better-sqlite3';
export function migrate(db: Database.Database): void {
db.exec(`
-- operations table
CREATE TABLE IF NOT EXISTS operations (...);
-- ... rest of schema
`);
}
2. API Endpoints
Route Registration
// src/sync/sync.routes.ts
import { FastifyInstance } from 'fastify';
import { syncService } from './sync.service';
import { requireAuth } from '../auth/auth.middleware';
export async function syncRoutes(fastify: FastifyInstance): Promise<void> {
// All routes require authentication
fastify.addHook('preHandler', requireAuth);
fastify.post('/ops', uploadOpsHandler);
fastify.get('/ops', downloadOpsHandler);
fastify.get('/snapshot', getSnapshotHandler);
fastify.post('/snapshot', uploadSnapshotHandler);
fastify.get('/status', getStatusHandler);
fastify.post('/devices/:clientId/ack', ackHandler);
}
POST /api/sync/ops - Upload Operations
// Request
interface UploadOpsRequest {
ops: Operation[];
clientId: string;
lastKnownServerSeq?: number; // For conflict detection
}
// Response
interface UploadOpsResponse {
results: Array<{
opId: string;
accepted: boolean;
serverSeq?: number;
error?: string;
}>;
newOps?: ServerOperation[]; // Piggyback any new ops since lastKnownServerSeq
latestSeq: number;
}
// Handler
async function uploadOpsHandler(req, reply): Promise<void> {
const userId = req.user.id;
const { ops, clientId, lastKnownServerSeq } = req.body;
// Validate request
if (!ops?.length || !clientId) {
return reply.status(400).send({ error: 'Missing ops or clientId' });
}
// Rate limit check
if (await syncService.isRateLimited(userId)) {
return reply.status(429).send({ error: 'Rate limited' });
}
// Process in transaction
const results = await syncService.uploadOps(userId, clientId, ops);
// Optionally include new ops from other clients
let newOps: ServerOperation[] | undefined;
if (lastKnownServerSeq !== undefined) {
newOps = await syncService.getOpsSince(userId, lastKnownServerSeq, clientId);
}
// Notify other connected clients via WebSocket
await syncService.broadcastToOtherDevices(userId, clientId);
reply.send({
results,
newOps: newOps?.length ? newOps : undefined,
latestSeq: await syncService.getLatestSeq(userId),
});
}
GET /api/sync/ops - Download Operations
// Query params
interface DownloadOpsQuery {
sinceSeq: number; // Required: last known server sequence
limit?: number; // Optional: max ops (default 500, max 1000)
excludeClient?: string; // Optional: exclude ops from this client
}
// Response
interface DownloadOpsResponse {
ops: ServerOperation[];
hasMore: boolean;
latestSeq: number;
}
async function downloadOpsHandler(req, reply): Promise<void> {
const userId = req.user.id;
const { sinceSeq, limit = 500, excludeClient } = req.query;
if (sinceSeq === undefined) {
return reply.status(400).send({ error: 'sinceSeq required' });
}
const maxLimit = Math.min(limit, 1000);
const ops = await syncService.getOpsSince(
userId,
sinceSeq,
excludeClient,
maxLimit + 1,
);
const hasMore = ops.length > maxLimit;
if (hasMore) ops.pop();
reply.send({
ops,
hasMore,
latestSeq: await syncService.getLatestSeq(userId),
});
}
GET /api/sync/snapshot - Get Full State
// Response
interface SnapshotResponse {
state: AllSyncModels;
serverSeq: number;
generatedAt: number;
}
async function getSnapshotHandler(req, reply): Promise<void> {
const userId = req.user.id;
// Check if we have a cached snapshot
const cached = await syncService.getCachedSnapshot(userId);
if (cached && Date.now() - cached.generatedAt < SNAPSHOT_CACHE_TTL) {
return reply.send(cached);
}
// Generate fresh snapshot by replaying ops
const snapshot = await syncService.generateSnapshot(userId);
reply.send(snapshot);
}
POST /api/sync/snapshot - Upload Full State
Used for initial sync or extended offline recovery.
interface UploadSnapshotRequest {
state: AllSyncModels;
clientId: string;
reason: 'initial' | 'recovery' | 'migration';
}
async function uploadSnapshotHandler(req, reply): Promise<void> {
const userId = req.user.id;
const { state, clientId, reason } = req.body;
// Create a special SYNC_IMPORT operation
const op: Operation = {
id: uuidv7(),
clientId,
opType: 'SYNC_IMPORT',
entityType: 'ALL',
payload: state,
vectorClock: {}, // Will be set by server
timestamp: Date.now(),
schemaVersion: state.schemaVersion ?? 1,
};
const result = await syncService.uploadOps(userId, clientId, [op]);
// Cache the snapshot for other devices
await syncService.cacheSnapshot(userId, state, result[0].serverSeq);
reply.send({
accepted: result[0].accepted,
serverSeq: result[0].serverSeq,
});
}
GET /api/sync/status
interface StatusResponse {
latestSeq: number;
devicesOnline: number;
pendingOps: number; // Ops since oldest device's lastAckedSeq
snapshotAge?: number; // Age of cached snapshot
}
3. Core Service Implementation
// src/sync/sync.service.ts
import Database from 'better-sqlite3';
import { v7 as uuidv7 } from 'uuid';
export class SyncService {
private db: Database.Database;
private stmts: PreparedStatements;
constructor(db: Database.Database) {
this.db = db;
this.prepareStatements();
}
private prepareStatements(): void {
this.stmts = {
insertOp: this.db.prepare(`
INSERT INTO operations (
id, user_id, client_id, server_seq, action_type, op_type,
entity_type, entity_id, payload, vector_clock, schema_version,
client_timestamp, received_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
getNextSeq: this.db.prepare(`
UPDATE user_sync_state
SET last_seq = last_seq + 1
WHERE user_id = ?
RETURNING last_seq
`),
initUserSeq: this.db.prepare(`
INSERT OR IGNORE INTO user_sync_state (user_id, last_seq)
VALUES (?, 0)
`),
getOpsSince: this.db.prepare(`
SELECT * FROM operations
WHERE user_id = ? AND server_seq > ?
ORDER BY server_seq ASC
LIMIT ?
`),
getOpsSinceExcludeClient: this.db.prepare(`
SELECT * FROM operations
WHERE user_id = ? AND server_seq > ? AND client_id != ?
ORDER BY server_seq ASC
LIMIT ?
`),
updateDevice: this.db.prepare(`
INSERT INTO sync_devices (client_id, user_id, last_seen_at, last_acked_seq, created_at)
VALUES (?, ?, ?, 0, ?)
ON CONFLICT(user_id, client_id) DO UPDATE SET
last_seen_at = excluded.last_seen_at
`),
getLatestSeq: this.db.prepare(`
SELECT last_seq FROM user_sync_state WHERE user_id = ?
`),
// Snapshot management
getCachedSnapshot: this.db.prepare(`
SELECT snapshot_data, last_snapshot_seq, snapshot_at
FROM user_sync_state WHERE user_id = ?
`),
cacheSnapshot: this.db.prepare(`
UPDATE user_sync_state
SET snapshot_data = ?, last_snapshot_seq = ?, snapshot_at = ?
WHERE user_id = ?
`),
getAllOps: this.db.prepare(`
SELECT * FROM operations
WHERE user_id = ?
ORDER BY server_seq ASC
`),
// Device acknowledgment
updateDeviceAck: this.db.prepare(`
UPDATE sync_devices
SET last_acked_seq = ?, last_seen_at = ?
WHERE user_id = ? AND client_id = ?
`),
// Tombstone management
insertTombstone: this.db.prepare(`
INSERT OR REPLACE INTO tombstones
(user_id, entity_type, entity_id, deleted_at, deleted_by_op_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`),
getTombstone: this.db.prepare(`
SELECT * FROM tombstones
WHERE user_id = ? AND entity_type = ? AND entity_id = ?
`),
deleteExpiredTombstones: this.db.prepare(`
DELETE FROM tombstones WHERE expires_at < ?
`),
// Cleanup
deleteOldSyncedOps: this.db.prepare(`
DELETE FROM operations
WHERE user_id = ? AND server_seq < ? AND received_at < ?
`),
getMinAckedSeq: this.db.prepare(`
SELECT MIN(last_acked_seq) as min_seq FROM sync_devices WHERE user_id = ?
`),
};
}
async uploadOps(
userId: number,
clientId: string,
ops: Operation[],
): Promise<UploadResult[]> {
const results: UploadResult[] = [];
const now = Date.now();
// Ensure user has sync state row
this.stmts.initUserSeq.run(userId);
// Process in transaction for atomicity
const tx = this.db.transaction(() => {
for (const op of ops) {
try {
// Validate operation
const validation = this.validateOp(op);
if (!validation.valid) {
results.push({
opId: op.id,
accepted: false,
error: validation.error,
});
continue;
}
// Get next sequence number
const { last_seq: serverSeq } = this.stmts.getNextSeq.get(userId);
// Insert operation
this.stmts.insertOp.run(
op.id,
userId,
clientId,
serverSeq,
op.actionType,
op.opType,
op.entityType,
op.entityId ?? null,
JSON.stringify(op.payload),
JSON.stringify(op.vectorClock),
op.schemaVersion,
op.timestamp,
now,
);
results.push({
opId: op.id,
accepted: true,
serverSeq,
});
} catch (err) {
// Duplicate ID (already processed)
if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
results.push({
opId: op.id,
accepted: false,
error: 'Duplicate operation ID',
});
} else {
throw err;
}
}
}
// Update device last seen
this.stmts.updateDevice.run(clientId, userId, now, now);
});
tx();
return results;
}
async getOpsSince(
userId: number,
sinceSeq: number,
excludeClient?: string,
limit: number = 500,
): Promise<ServerOperation[]> {
const stmt = excludeClient
? this.stmts.getOpsSinceExcludeClient
: this.stmts.getOpsSince;
const args = excludeClient
? [userId, sinceSeq, excludeClient, limit]
: [userId, sinceSeq, limit];
const rows = stmt.all(...args);
return rows.map((row) => ({
serverSeq: row.server_seq,
op: {
id: row.id,
clientId: row.client_id,
actionType: row.action_type,
opType: row.op_type,
entityType: row.entity_type,
entityId: row.entity_id,
payload: JSON.parse(row.payload),
vectorClock: JSON.parse(row.vector_clock),
schemaVersion: row.schema_version,
timestamp: row.client_timestamp,
},
receivedAt: row.received_at,
}));
}
async getLatestSeq(userId: number): Promise<number> {
const row = this.stmts.getLatestSeq.get(userId);
return row?.last_seq ?? 0;
}
// --- Snapshot Management ---
async getCachedSnapshot(
userId: number,
): Promise<{ state: AllSyncModels; serverSeq: number; generatedAt: number } | null> {
const row = this.stmts.getCachedSnapshot.get(userId);
if (!row?.snapshot_data) return null;
// Decompress snapshot
const decompressed = zlib.gunzipSync(row.snapshot_data).toString('utf-8');
return {
state: JSON.parse(decompressed),
serverSeq: row.last_snapshot_seq,
generatedAt: row.snapshot_at,
};
}
async cacheSnapshot(
userId: number,
state: AllSyncModels,
serverSeq: number,
): Promise<void> {
const now = Date.now();
// Compress snapshot to reduce storage
const compressed = zlib.gzipSync(JSON.stringify(state));
this.stmts.cacheSnapshot.run(compressed, serverSeq, now, userId);
}
async generateSnapshot(
userId: number,
): Promise<{ state: AllSyncModels; serverSeq: number; generatedAt: number }> {
// Get all operations for this user
const allOps = this.stmts.getAllOps.all(userId);
const latestSeq = await this.getLatestSeq(userId);
// Replay operations to build current state
const state = this.replayOpsToState(allOps);
const generatedAt = Date.now();
// Cache for future requests
await this.cacheSnapshot(userId, state, latestSeq);
return { state, serverSeq: latestSeq, generatedAt };
}
private replayOpsToState(ops: DbOperation[]): AllSyncModels {
// Initialize empty state
const state: AllSyncModels = {
task: {},
project: {},
tag: {},
simpleCounter: {},
globalConfig: {} as any,
// ... other entity types
};
// Apply operations in order
for (const row of ops) {
const op = {
opType: row.op_type,
entityType: row.entity_type.toLowerCase(),
entityId: row.entity_id,
payload: JSON.parse(row.payload),
};
switch (op.opType) {
case 'CRT':
case 'UPD':
if (op.entityId && state[op.entityType]) {
state[op.entityType][op.entityId] = {
...state[op.entityType][op.entityId],
...op.payload,
};
}
break;
case 'DEL':
if (op.entityId && state[op.entityType]) {
delete state[op.entityType][op.entityId];
}
break;
case 'SYNC_IMPORT':
// Full state import - replace everything
Object.assign(state, op.payload);
break;
}
}
return state;
}
// --- Device Acknowledgment ---
async updateDeviceAck(
userId: number,
clientId: string,
lastAckedSeq: number,
): Promise<void> {
this.stmts.updateDeviceAck.run(lastAckedSeq, Date.now(), userId, clientId);
}
// --- Rate Limiting ---
private rateLimitCounters: Map<number, { count: number; resetAt: number }> = new Map();
isRateLimited(userId: number): boolean {
const now = Date.now();
const counter = this.rateLimitCounters.get(userId);
const limit = RATE_LIMITS.uploadOps;
if (!counter || now > counter.resetAt) {
this.rateLimitCounters.set(userId, {
count: 1,
resetAt: now + limit.windowMs,
});
return false;
}
if (counter.count >= limit.max) {
return true;
}
counter.count++;
return false;
}
// --- Tombstone Management ---
async createTombstone(
userId: number,
entityType: string,
entityId: string,
deletedByOpId: string,
): Promise<void> {
const now = Date.now();
const expiresAt = now + TOMBSTONE_RETENTION_MS;
this.stmts.insertTombstone.run(
userId,
entityType,
entityId,
now,
deletedByOpId,
expiresAt,
);
}
async isTombstoned(
userId: number,
entityType: string,
entityId: string,
): Promise<boolean> {
const row = this.stmts.getTombstone.get(userId, entityType, entityId);
return !!row;
}
// --- Validation ---
private validateOp(op: Operation): { valid: boolean; error?: string } {
if (!op.id || typeof op.id !== 'string') {
return { valid: false, error: 'Invalid operation ID' };
}
if (
!op.opType ||
!['CRT', 'UPD', 'DEL', 'MOV', 'BATCH', 'SYNC_IMPORT'].includes(op.opType)
) {
return { valid: false, error: 'Invalid opType' };
}
if (!op.entityType) {
return { valid: false, error: 'Missing entityType' };
}
if (op.payload === undefined) {
return { valid: false, error: 'Missing payload' };
}
// Size limit
const payloadSize = JSON.stringify(op.payload).length;
if (payloadSize > MAX_PAYLOAD_SIZE) {
return { valid: false, error: 'Payload too large' };
}
// Timestamp validation (based on HLC best practices)
// Reject timestamps too far in the future or past
const now = Date.now();
const MAX_CLOCK_DRIFT = 60_000; // 60 seconds tolerance
const MAX_OP_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
if (op.timestamp > now + MAX_CLOCK_DRIFT) {
return { valid: false, error: 'Timestamp too far in future' };
}
if (op.timestamp < now - MAX_OP_AGE) {
return { valid: false, error: 'Operation too old' };
}
return { valid: true };
}
}
4. WebSocket Implementation
Setup
// src/sync/sync.ws.ts
import { FastifyInstance } from 'fastify';
import WebSocket from 'ws';
interface ConnectedClient {
ws: WebSocket;
userId: number;
clientId: string;
}
class SyncWebSocketManager {
private clients: Map<string, ConnectedClient> = new Map();
// Key: `${userId}:${clientId}`
private getKey(userId: number, clientId: string): string {
return `${userId}:${clientId}`;
}
register(ws: WebSocket, userId: number, clientId: string): void {
const key = this.getKey(userId, clientId);
this.clients.set(key, { ws, userId, clientId });
ws.on('close', () => {
this.clients.delete(key);
});
}
// Notify all devices for a user except the sender
broadcastToUser(userId: number, excludeClientId: string, message: object): void {
for (const [key, client] of this.clients) {
if (client.userId === userId && client.clientId !== excludeClientId) {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify(message));
}
}
}
}
// Get online device count for a user
getOnlineCount(userId: number): number {
let count = 0;
for (const client of this.clients.values()) {
if (client.userId === userId) count++;
}
return count;
}
}
export const wsManager = new SyncWebSocketManager();
Route Handler
// src/sync/sync.routes.ts (WebSocket endpoint)
export async function registerWebSocket(fastify: FastifyInstance): Promise<void> {
fastify.get('/ws', { websocket: true }, (connection, req) => {
const ws = connection.socket;
// Auth via query param or header
const token = req.query.token || req.headers.authorization?.replace('Bearer ', '');
const user = await verifyToken(token);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
let clientId: string | null = null;
ws.on('message', async (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
switch (message.type) {
case 'SUBSCRIBE':
clientId = message.clientId;
wsManager.register(ws, user.id, clientId);
ws.send(JSON.stringify({ type: 'SUBSCRIBED' }));
break;
case 'PING':
ws.send(JSON.stringify({ type: 'PONG' }));
break;
case 'ACK':
// Client acknowledges received sequences
if (clientId && message.lastSeq) {
await syncService.updateDeviceAck(user.id, clientId, message.lastSeq);
}
break;
}
} catch (err) {
ws.send(JSON.stringify({ type: 'ERROR', error: 'Invalid message' }));
}
});
// Heartbeat
const heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 30000);
ws.on('close', () => {
clearInterval(heartbeat);
});
});
}
Notification Helper
// In sync.service.ts
async broadcastToOtherDevices(userId: number, excludeClientId: string): Promise<void> {
const latestSeq = await this.getLatestSeq(userId);
wsManager.broadcastToUser(userId, excludeClientId, {
type: 'NEW_OPS',
latestSeq,
});
}
5. Server Integration
Update server.ts
// src/server.ts
import fastifyWebsocket from '@fastify/websocket';
import { syncRoutes, registerWebSocket } from './sync/sync.routes';
async function start(): Promise<void> {
const fastify = Fastify({ logger: true });
// ... existing setup ...
// WebSocket support
await fastify.register(fastifyWebsocket);
// Sync routes
await fastify.register(syncRoutes, { prefix: '/api/sync' });
await registerWebSocket(fastify);
// ... start server ...
}
Rate Limiting
// src/sync/rate-limiter.ts
const RATE_LIMITS = {
uploadOps: { max: 100, windowMs: 60_000 }, // 100 uploads/min
downloadOps: { max: 200, windowMs: 60_000 }, // 200 downloads/min
};
class RateLimiter {
private counters: Map<string, { count: number; resetAt: number }> = new Map();
check(key: string, limit: typeof RATE_LIMITS.uploadOps): boolean {
const now = Date.now();
const counter = this.counters.get(key);
if (!counter || now > counter.resetAt) {
this.counters.set(key, { count: 1, resetAt: now + limit.windowMs });
return true;
}
if (counter.count >= limit.max) {
return false;
}
counter.count++;
return true;
}
}
6. Implementation Phases
Phase 1: Database & Basic Upload/Download (Week 1)
- Create database migration script
- Implement
SyncServiceusingbetter-sqlite3directly - Add
POST /api/sync/opsendpoint - Add
GET /api/sync/opsendpoint - Add basic validation
- Write unit tests
Deliverable: Can upload and download operations via REST API
Phase 2: WebSocket & Real-Time (Week 2)
- Add
@fastify/websocketdependency - Implement
SyncWebSocketManager - Add
/api/sync/wsendpoint - Implement subscribe/ping/ack protocol
- Broadcast notifications on upload
- Write integration tests
Deliverable: Connected clients notified of new operations in real-time
Phase 3: Snapshot & Recovery (Week 3)
- Add
GET /api/sync/snapshotendpoint - Add
POST /api/sync/snapshotendpoint - Implement snapshot caching in
user_sync_state - Add
GET /api/sync/statusendpoint - Add tombstone table and cleanup job
Deliverable: New devices can bootstrap from snapshot
Phase 4: Hardening (Week 4)
- Add rate limiting
- Add request validation schemas (Zod/TypeBox)
- Add error handling middleware
- Add logging and metrics
- Add cleanup job for old tombstones
- Load testing
Deliverable: Production-ready server
7. Testing Strategy
Unit Tests
// tests/sync.service.test.ts
describe('SyncService', () => {
let db: Database.Database;
let service: SyncService;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
service = new SyncService(db);
});
test('uploadOps assigns sequential server_seq', async () => {
const ops = [createTestOp(), createTestOp()];
const results = await service.uploadOps(1, 'client-1', ops);
expect(results[0].serverSeq).toBe(1);
expect(results[1].serverSeq).toBe(2);
});
test('uploadOps rejects duplicate op IDs', async () => {
const op = createTestOp();
await service.uploadOps(1, 'client-1', [op]);
const results = await service.uploadOps(1, 'client-1', [op]);
expect(results[0].accepted).toBe(false);
expect(results[0].error).toContain('Duplicate');
});
test('getOpsSince excludes specified client', async () => {
await service.uploadOps(1, 'client-1', [createTestOp()]);
await service.uploadOps(1, 'client-2', [createTestOp()]);
const ops = await service.getOpsSince(1, 0, 'client-1');
expect(ops).toHaveLength(1);
expect(ops[0].op.clientId).toBe('client-2');
});
});
Integration Tests
// tests/sync.routes.test.ts
describe('Sync API', () => {
let app: FastifyInstance;
let authToken: string;
beforeAll(async () => {
app = await buildApp();
authToken = await createTestUserAndLogin(app);
});
test('POST /api/sync/ops uploads and returns serverSeq', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sync/ops',
headers: { Authorization: `Bearer ${authToken}` },
payload: {
ops: [createTestOp()],
clientId: 'test-client',
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.results[0].accepted).toBe(true);
expect(body.results[0].serverSeq).toBeDefined();
});
test('GET /api/sync/ops returns ops since sequence', async () => {
// Upload some ops first
await uploadTestOps(app, authToken, 5);
const response = await app.inject({
method: 'GET',
url: '/api/sync/ops?sinceSeq=2&limit=10',
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.ops).toHaveLength(3); // ops 3, 4, 5
});
});
8. Configuration
// src/config.ts
export const syncConfig = {
// Limits
maxOpsPerUpload: 100,
maxPayloadSizeBytes: 1_000_000, // 1MB
downloadLimit: 1000,
// Rate limiting
uploadRateLimit: { max: 100, windowMs: 60_000 },
downloadRateLimit: { max: 200, windowMs: 60_000 },
// Tombstones
tombstoneRetentionDays: 90,
tombstoneRetentionMs: 90 * 24 * 60 * 60 * 1000, // 90 days in ms
// Operations retention
opRetentionDays: 90,
opRetentionMs: 90 * 24 * 60 * 60 * 1000, // 90 days in ms
// Snapshots
snapshotCacheTtlMs: 5 * 60 * 1000, // 5 minutes
// WebSocket
wsHeartbeatIntervalMs: 30_000,
wsMaxConnectionsPerUser: 10,
// Compression
compressionThreshold: 10_000, // Compress payloads > 10KB
// Idempotency
idempotencyTtlMs: 5 * 60 * 1000, // 5 minutes
// Device cleanup
staleDeviceThresholdDays: 90,
staleDeviceThresholdMs: 90 * 24 * 60 * 60 * 1000,
};
// Export commonly used constants
export const TOMBSTONE_RETENTION_MS = syncConfig.tombstoneRetentionMs;
export const COMPRESSION_THRESHOLD = syncConfig.compressionThreshold;
export const MAX_PAYLOAD_SIZE = syncConfig.maxPayloadSizeBytes;
export const SNAPSHOT_CACHE_TTL = syncConfig.snapshotCacheTtlMs;
export const TTL_5_MIN = syncConfig.idempotencyTtlMs;
export const RATE_LIMITS = {
uploadOps: syncConfig.uploadRateLimit,
downloadOps: syncConfig.downloadRateLimit,
};
9. Error Handling
// src/sync/sync.errors.ts
export class SyncError extends Error {
constructor(
public code: string,
public httpStatus: number,
message: string,
) {
super(message);
}
}
export const SyncErrors = {
INVALID_OP: new SyncError('INVALID_OP', 400, 'Invalid operation format'),
RATE_LIMITED: new SyncError('RATE_LIMITED', 429, 'Too many requests'),
PAYLOAD_TOO_LARGE: new SyncError(
'PAYLOAD_TOO_LARGE',
400,
'Payload exceeds size limit',
),
SCHEMA_MISMATCH: new SyncError(
'SCHEMA_MISMATCH',
400,
'Client schema version not supported',
),
};
// Error handler middleware
export function syncErrorHandler(error: Error, req, reply): void {
if (error instanceof SyncError) {
reply.status(error.httpStatus).send({
error: error.code,
message: error.message,
});
} else {
req.log.error(error);
reply.status(500).send({
error: 'INTERNAL_ERROR',
message: 'Internal server error',
});
}
}
10. Idempotency
Ensure operations are processed exactly once, even with network retries.
Operation Idempotency
// Operation IDs (UUID v7) serve as natural idempotency keys
// The PRIMARY KEY constraint on operations.id prevents duplicates
async function uploadOpsHandler(req, reply): Promise<void> {
const userId = req.user.id;
const { ops, clientId, idempotencyKey } = req.body;
// Check for request-level idempotency (optional)
if (idempotencyKey) {
const cached = await idempotencyCache.get(userId, idempotencyKey);
if (cached) {
return reply.send(cached);
}
}
const results = await syncService.uploadOps(userId, clientId, ops);
// Cache response for idempotent retries
if (idempotencyKey) {
await idempotencyCache.set(userId, idempotencyKey, results, TTL_5_MIN);
}
reply.send(results);
}
Idempotency Cache
// Simple in-memory cache with TTL (use Redis for multi-server)
class IdempotencyCache {
private cache: Map<string, { response: unknown; expiresAt: number }> = new Map();
private getKey(userId: number, key: string): string {
return `${userId}:${key}`;
}
async get(userId: number, key: string): Promise<unknown | null> {
const cacheKey = this.getKey(userId, key);
const entry = this.cache.get(cacheKey);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(cacheKey);
return null;
}
return entry.response;
}
async set(
userId: number,
key: string,
response: unknown,
ttlMs: number,
): Promise<void> {
const cacheKey = this.getKey(userId, key);
this.cache.set(cacheKey, {
response,
expiresAt: Date.now() + ttlMs,
});
}
// Cleanup expired entries periodically
startCleanup(intervalMs: number = 60_000): void {
setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.cache) {
if (now > entry.expiresAt) {
this.cache.delete(key);
}
}
}, intervalMs);
}
}
export const idempotencyCache = new IdempotencyCache();
11. Scheduled Cleanup Jobs
Job Registration
// src/jobs/cleanup.jobs.ts
import { CronJob } from 'cron';
import { syncService } from '../sync/sync.service';
export function registerCleanupJobs(): void {
// Daily tombstone cleanup (2 AM UTC)
new CronJob('0 2 * * *', () => cleanupExpiredTombstones(), null, true);
// Daily old operations cleanup (3 AM UTC)
new CronJob('0 3 * * *', () => cleanupOldOperations(), null, true);
// Hourly stale device cleanup
new CronJob('0 * * * *', () => cleanupStaleDevices(), null, true);
// Every 10 minutes: idempotency cache cleanup
idempotencyCache.startCleanup(10 * 60 * 1000);
}
Tombstone Cleanup
async function cleanupExpiredTombstones(): Promise<void> {
const now = Date.now();
try {
const result = await syncService.deleteExpiredTombstones(now);
console.log(`Tombstone cleanup: deleted ${result.changes} expired tombstones`);
} catch (error) {
console.error('Tombstone cleanup failed:', error);
}
}
// In SyncService:
async deleteExpiredTombstones(now: number): Promise<{ changes: number }> {
const result = this.stmts.deleteExpiredTombstones.run(now);
return { changes: result.changes };
}
Old Operations Cleanup
Based on Cassandra tombstone best practices, safe cleanup requires:
- Grace Period - 90 days minimum (prevents "zombie data" resurrection)
- All Devices Acked - Every registered device must have seen the operation
- Tombstone Awareness - Tombstones must outlive related operations
async function cleanupOldOperations(): Promise<void> {
const config = syncConfig;
const cutoffTime = Date.now() - config.opRetentionMs; // e.g., 90 days
try {
// Get all users with operations
const users = await syncService.getAllUserIds();
for (const userId of users) {
// CRITICAL: Find minimum acknowledged sequence across ALL devices
// This prevents data resurrection from offline devices
const minAckedSeq = await syncService.getMinAckedSeq(userId);
if (minAckedSeq === null) {
// No devices have acknowledged - skip this user
// Safety: Never delete if we can't confirm all devices saw it
continue;
}
// Only delete operations that BOTH:
// 1. Are older than grace period (90 days)
// 2. Have been acknowledged by all devices (seq < minAckedSeq)
//
// This dual requirement prevents:
// - Zombie data: offline device syncs old data after deletion
// - Lost updates: device hasn't seen operation yet
const deleted = await syncService.deleteOldSyncedOps(
userId,
minAckedSeq,
cutoffTime,
);
if (deleted > 0) {
console.log(`User ${userId}: deleted ${deleted} old operations`);
}
}
} catch (error) {
console.error('Old operations cleanup failed:', error);
}
}
// In SyncService:
async deleteOldSyncedOps(
userId: number,
beforeSeq: number,
beforeTime: number,
): Promise<number> {
const result = this.stmts.deleteOldSyncedOps.run(userId, beforeSeq, beforeTime);
return result.changes;
}
async getMinAckedSeq(userId: number): Promise<number | null> {
const row = this.stmts.getMinAckedSeq.get(userId);
return row?.min_seq ?? null;
}
Stale Device Cleanup
async function cleanupStaleDevices(): Promise<void> {
const staleThreshold = Date.now() - 90 * 24 * 60 * 60 * 1000; // 90 days
try {
const result = await syncService.deleteStaleDevices(staleThreshold);
if (result.changes > 0) {
console.log(`Stale device cleanup: removed ${result.changes} devices`);
}
} catch (error) {
console.error('Stale device cleanup failed:', error);
}
}
// In SyncService (add prepared statement and method):
// Statement:
deleteStaleDevices: this.db.prepare(`
DELETE FROM sync_devices WHERE last_seen_at < ?
`),
// Method:
async deleteStaleDevices(beforeTime: number): Promise<{ changes: number }> {
const result = this.stmts.deleteStaleDevices.run(beforeTime);
return { changes: result.changes };
}
12. Compression
Large payloads are compressed to reduce bandwidth.
Request/Response Compression
// src/server.ts
import fastifyCompress from '@fastify/compress';
async function start(): Promise<void> {
const fastify = Fastify({ logger: true });
// Enable compression for responses
await fastify.register(fastifyCompress, {
global: true,
encodings: ['gzip', 'deflate'],
threshold: 1024, // Only compress responses > 1KB
});
// ... rest of setup
}
Snapshot Compression
Snapshots are always compressed (see cacheSnapshot method in Section 3).
13. Conflict Strategy (Server-Side)
The server acts as the Linearization Point (Source of Truth).
- Ordering: The server assigns a strictly increasing
server_seqto every incoming operation. This defines the global order of events. - Acceptance: The server generally accepts all valid operations (schema valid, authorized). It does not try to "merge" JSON payloads itself.
- Resolution (Client-Side):
- When a client uploads an op, if the server accepts it, that op is now part of the history.
- When a client downloads ops, it sees operations from others.
- If a downloaded op has a
server_seqthat conflicts with a local pending op (i.e., the client was basing its work on an olderserver_seq), the Client is responsible for "rebasing" or "merging" its local change on top of the new server state.
Why this approach? It keeps the server fast, simple, and agnostic to domain logic. It supports End-to-End Encryption (E2EE) in the future, as the server doesn't need to read the payload to order it.
References
- Server Sync Architecture - Detailed algorithms and design
- Operation Log Best Practices - Industry standards and architectural decisions
- Operation Log Architecture - Client-side system
- Execution Plan - Client implementation tasks