fix(sync): harden vector clock sanitization and improve E2E test robustness

- Cap sanitizeVectorClock values at 100M instead of MAX_SAFE_INTEGER (prevents adversarial clock inflation)
- Add early-exit optimization in compareVectorClocks for CONCURRENT detection
- Add test.setTimeout(180000) to token-expiry and lastseq-preservation E2E tests
- Remove dead code (unreachable guard) in compaction E2E test
- Extract shared config helpers (navigateToMiscSettings, toggleSetting, etc.) into e2e/utils/config-helpers.ts
- Fix Client B cleanup in provider-switch test (move to finally block)
- Add clarifying comments for raw SQL schema coupling, CORS header, and mergeRemoteOpClocks caller contract
- Extract enforceStorageQuota helper in sync routes to reduce duplication
- Add conflictType to conflict detection for structured error handling
- Add MAX_CLEANUP_ITERATIONS guard to freeStorageForUpload
This commit is contained in:
Johannes Millan 2026-02-12 16:10:35 +01:00
parent 1c27223278
commit 43b5808a3f
13 changed files with 250 additions and 268 deletions

View file

@ -154,9 +154,8 @@ test.describe('@supersync Compaction/Snapshot Resilience', () => {
// ============ PHASE 5: Verify B gets complete state ============
console.log('[Compaction] Phase 5: Verifying Client B has complete state');
// Check undone tasks are visible
// Check undone tasks are visible (first 3 were marked done)
for (let i = 3; i < taskNames.length; i++) {
if (i === 0 || i === 1 || i === 2) continue; // Skip done tasks
await waitForTask(clientB.page, taskNames[i]);
}

View file

@ -1,5 +1,4 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import type { Page } from '@playwright/test';
import {
createTestUser,
getSuperSyncConfig,
@ -7,6 +6,12 @@ import {
closeClient,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import {
navigateToMiscSettings,
toggleSetting,
isSettingChecked,
ensureSettingState,
} from '../../utils/config-helpers';
/**
* SuperSync Global Config Sync Edge Cases E2E Tests
@ -21,74 +26,6 @@ import {
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-global-config-edge-cases.spec.ts
*/
// --- Helpers (same as supersync-lww-singleton.spec.ts) ---
const navigateToMiscSettings = async (page: Page, forceReload = false): Promise<void> => {
if (forceReload) {
await page.goto('/#/tag/TODAY/tasks');
await page.waitForURL(/tag\/TODAY/);
}
await page.goto('/#/config');
await page.waitForURL(/config/);
const miscCollapsible = page.locator(
'collapsible:has(.collapsible-title:has-text("Misc"))',
);
await miscCollapsible.waitFor({ state: 'visible', timeout: 10000 });
await miscCollapsible.scrollIntoViewIfNeeded();
const isExpanded = await miscCollapsible.evaluate((el: Element) =>
el.classList.contains('isExpanded'),
);
if (!isExpanded) {
await miscCollapsible.locator('.collapsible-header').click();
await miscCollapsible
.locator('.collapsible-panel')
.waitFor({ state: 'visible', timeout: 5000 });
}
};
const toggleSetting = async (page: Page, labelText: string): Promise<void> => {
const toggle = page
.locator('mat-slide-toggle, mat-checkbox')
.filter({ hasText: labelText })
.first();
await toggle.scrollIntoViewIfNeeded();
const wasChecked = await toggle.evaluate((el: Element) =>
el.className.includes('checked'),
);
await toggle.click();
if (wasChecked) {
await expect(toggle).not.toHaveClass(/checked/, { timeout: 5000 });
} else {
await expect(toggle).toHaveClass(/checked/, { timeout: 5000 });
}
};
const isSettingChecked = async (page: Page, labelText: string): Promise<boolean> => {
const toggle = page
.locator('mat-slide-toggle, mat-checkbox')
.filter({ hasText: labelText })
.first();
await toggle.scrollIntoViewIfNeeded();
const classes = (await toggle.getAttribute('class')) ?? '';
return classes.includes('checked');
};
/**
* Helper to set a setting to a specific state (ON/OFF).
*/
const ensureSettingState = async (
page: Page,
labelText: string,
wantChecked: boolean,
): Promise<void> => {
const current = await isSettingChecked(page, labelText);
if (current !== wantChecked) {
await toggleSetting(page, labelText);
}
};
test.describe('@supersync Global Config Sync Edge Cases', () => {
/**
* Scenario: Three-client concurrent config changes converge

View file

@ -45,6 +45,7 @@ test.describe('@supersync lastSeq Preservation After Import', () => {
baseURL,
testRunId,
}) => {
test.setTimeout(180000);
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;

View file

@ -1,5 +1,4 @@
import { test, expect } from '../../fixtures/supersync.fixture';
import type { Page } from '@playwright/test';
import {
createTestUser,
getSuperSyncConfig,
@ -7,6 +6,11 @@ import {
closeClient,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
import {
navigateToMiscSettings,
toggleSetting,
isSettingChecked,
} from '../../utils/config-helpers';
/**
* SuperSync LWW Singleton Entity Conflict Resolution E2E Tests
@ -21,79 +25,6 @@ import {
* winning LWW data.
*/
/**
* Navigate to Misc Settings section in the config page (General tab)
* and expand it if collapsed.
*
* When `forceReload` is true, navigates away first then back to ensure the
* OnPush config component is fully re-created with fresh store data.
*/
const navigateToMiscSettings = async (page: Page, forceReload = false): Promise<void> => {
if (forceReload) {
// Navigate away first to destroy the config component, then back
await page.goto('/#/tag/TODAY/tasks');
await page.waitForURL(/tag\/TODAY/);
}
await page.goto('/#/config');
await page.waitForURL(/config/);
// "Misc Settings" is a collapsible section in the General tab (default tab).
const miscCollapsible = page.locator(
'collapsible:has(.collapsible-title:has-text("Misc"))',
);
await miscCollapsible.waitFor({ state: 'visible', timeout: 10000 });
await miscCollapsible.scrollIntoViewIfNeeded();
// Expand if collapsed (host element gets .isExpanded class when expanded)
const isExpanded = await miscCollapsible.evaluate((el: Element) =>
el.classList.contains('isExpanded'),
);
if (!isExpanded) {
await miscCollapsible.locator('.collapsible-header').click();
// Wait for the collapsible panel to appear (conditionally rendered via @if)
await miscCollapsible
.locator('.collapsible-panel')
.waitFor({ state: 'visible', timeout: 5000 });
}
};
/**
* Toggle a slide-toggle setting by its label text.
*/
const toggleSetting = async (page: Page, labelText: string): Promise<void> => {
const toggle = page
.locator('mat-slide-toggle, mat-checkbox')
.filter({ hasText: labelText })
.first();
await toggle.scrollIntoViewIfNeeded();
// Capture current checked state before clicking
const wasChecked = await toggle.evaluate((el: Element) =>
el.className.includes('checked'),
);
await toggle.click();
// Wait for toggle state to change
if (wasChecked) {
await expect(toggle).not.toHaveClass(/checked/, { timeout: 5000 });
} else {
await expect(toggle).toHaveClass(/checked/, { timeout: 5000 });
}
};
/**
* Check whether a slide-toggle is currently checked (ON).
*/
const isSettingChecked = async (page: Page, labelText: string): Promise<boolean> => {
const toggle = page
.locator('mat-slide-toggle, mat-checkbox')
.filter({ hasText: labelText })
.first();
await toggle.scrollIntoViewIfNeeded();
// mat-slide-toggle adds 'mat-mdc-slide-toggle-checked' when checked
// mat-checkbox adds 'mat-mdc-checkbox-checked' when checked
const classes = (await toggle.getAttribute('class')) ?? '';
return classes.includes('checked');
};
test.describe('@supersync SuperSync LWW Singleton Conflict Resolution', () => {
/**
* Scenario: LWW Singleton Global Config Conflict Resolution

View file

@ -72,6 +72,7 @@ test.describe('@supersync Provider Switch (WebDAV ↔ SuperSync)', () => {
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let contextB: Awaited<ReturnType<typeof browser.newContext>> | null = null;
try {
const user = await createTestUser(testRunId);
@ -117,7 +118,7 @@ test.describe('@supersync Provider Switch (WebDAV ↔ SuperSync)', () => {
// ============ PHASE 3: New client joins via WebDAV ============
console.log('[ProviderSwitch] Phase 3: New client joins via WebDAV');
const contextB = await browser.newContext({
contextB = await browser.newContext({
storageState: undefined,
viewport: { width: 1920, height: 1080 },
});
@ -147,12 +148,9 @@ test.describe('@supersync Provider Switch (WebDAV ↔ SuperSync)', () => {
console.log('[ProviderSwitch] ✓ Client B received all tasks via WebDAV');
console.log('[ProviderSwitch] ✓ SuperSync → WebDAV provider switch test PASSED!');
// Clean up Client B context manually
await contextB.close().catch(() => {});
} finally {
if (contextB) await contextB.close().catch(() => {});
if (clientA) await closeClient(clientA);
// clientB is cleaned up above via contextB.close()
}
});
});

View file

@ -37,6 +37,7 @@ test.describe('@supersync Token Expiry Recovery', () => {
baseURL,
testRunId,
}) => {
test.setTimeout(180000);
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
@ -122,6 +123,7 @@ test.describe('@supersync Token Expiry Recovery', () => {
baseURL,
testRunId,
}) => {
test.setTimeout(180000);
const appUrl = baseURL || 'http://localhost:4242';
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;

View file

@ -0,0 +1,95 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
/**
* Navigate to Misc Settings section in the config page (General tab)
* and expand it if collapsed.
*
* When `forceReload` is true, navigates away first then back to ensure the
* OnPush config component is fully re-created with fresh store data.
*/
export const navigateToMiscSettings = async (
page: Page,
forceReload = false,
): Promise<void> => {
if (forceReload) {
// Navigate away first to destroy the config component, then back
await page.goto('/#/tag/TODAY/tasks');
await page.waitForURL(/tag\/TODAY/);
}
await page.goto('/#/config');
await page.waitForURL(/config/);
// "Misc Settings" is a collapsible section in the General tab (default tab).
const miscCollapsible = page.locator(
'collapsible:has(.collapsible-title:has-text("Misc"))',
);
await miscCollapsible.waitFor({ state: 'visible', timeout: 10000 });
await miscCollapsible.scrollIntoViewIfNeeded();
// Expand if collapsed (host element gets .isExpanded class when expanded)
const isExpanded = await miscCollapsible.evaluate((el: Element) =>
el.classList.contains('isExpanded'),
);
if (!isExpanded) {
await miscCollapsible.locator('.collapsible-header').click();
// Wait for the collapsible panel to appear (conditionally rendered via @if)
await miscCollapsible
.locator('.collapsible-panel')
.waitFor({ state: 'visible', timeout: 5000 });
}
};
/**
* Toggle a slide-toggle setting by its label text.
*/
export const toggleSetting = async (page: Page, labelText: string): Promise<void> => {
const toggle = page
.locator('mat-slide-toggle, mat-checkbox')
.filter({ hasText: labelText })
.first();
await toggle.scrollIntoViewIfNeeded();
// Capture current checked state before clicking
const wasChecked = await toggle.evaluate((el: Element) =>
el.className.includes('checked'),
);
await toggle.click();
// Wait for toggle state to change
if (wasChecked) {
await expect(toggle).not.toHaveClass(/checked/, { timeout: 5000 });
} else {
await expect(toggle).toHaveClass(/checked/, { timeout: 5000 });
}
};
/**
* Check whether a slide-toggle is currently checked (ON).
*/
export const isSettingChecked = async (
page: Page,
labelText: string,
): Promise<boolean> => {
const toggle = page
.locator('mat-slide-toggle, mat-checkbox')
.filter({ hasText: labelText })
.first();
await toggle.scrollIntoViewIfNeeded();
// mat-slide-toggle adds 'mat-mdc-slide-toggle-checked' when checked
// mat-checkbox adds 'mat-mdc-checkbox-checked' when checked
const classes = (await toggle.getAttribute('class')) ?? '';
return classes.includes('checked');
};
/**
* Set a setting to a specific state (ON/OFF).
*/
export const ensureSettingState = async (
page: Page,
labelText: string,
wantChecked: boolean,
): Promise<void> => {
const current = await isSettingChecked(page, labelText);
if (current !== wantChecked) {
await toggleSetting(page, labelText);
}
};

View file

@ -65,6 +65,7 @@ export const compareVectorClocks = (
const bVal = b[key] ?? 0;
if (aVal > bVal) aGreater = true;
if (bVal > aVal) bGreater = true;
if (aGreater && bGreater) return 'CONCURRENT';
}
if (aGreater && bGreater) return 'CONCURRENT';

View file

@ -133,7 +133,7 @@ export const createServer = (
'Authorization',
'Content-Type',
'Content-Encoding',
'Content-Transfer-Encoding',
'Content-Transfer-Encoding', // Used by some HTTP clients for binary/base64 payloads
'X-Expected-Rev',
'X-Force-Overwrite',
'X-Requested-With',

View file

@ -121,6 +121,68 @@ const decompressBody = async (
return gunzipAsync(rawBody);
};
/**
* Check storage quota, recalculate if stale, and auto-cleanup if needed.
* @returns `true` if quota is OK (caller can proceed), `false` if reply was sent with 413 error.
*/
async function enforceStorageQuota(
userId: number,
payloadSize: number,
reply: FastifyReply,
): Promise<boolean> {
const syncService = getSyncService();
let quotaCheck = await syncService.checkStorageQuota(userId, payloadSize);
if (quotaCheck.allowed) return true;
// Cache might be stale - recalculate actual storage before taking action
Logger.info(
`[user:${userId}] Quota check failed (cached: ${quotaCheck.currentUsage}/${quotaCheck.quota}). Recalculating actual storage...`,
);
await syncService.updateStorageUsage(userId);
quotaCheck = await syncService.checkStorageQuota(userId, payloadSize);
if (quotaCheck.allowed) {
Logger.info(
`[user:${userId}] Quota OK after recalculation: ${quotaCheck.currentUsage}/${quotaCheck.quota} bytes`,
);
return true;
}
Logger.warn(
`[user:${userId}] Storage quota exceeded: ${quotaCheck.currentUsage}/${quotaCheck.quota} bytes. Attempting auto-cleanup...`,
);
// Iteratively delete old data until we have enough space
const cleanupResult = await syncService.freeStorageForUpload(userId, payloadSize);
if (cleanupResult.success) {
Logger.info(
`[user:${userId}] Auto-cleanup freed ${Math.round(cleanupResult.freedBytes / 1024)}KB ` +
`(${cleanupResult.deletedRestorePoints} restore points, ${cleanupResult.deletedOps} ops)`,
);
return true;
}
// Truly out of space - return error
const finalQuota = await syncService.checkStorageQuota(userId, payloadSize);
Logger.warn(
`[user:${userId}] Storage quota still exceeded after cleanup: ${finalQuota.currentUsage}/${finalQuota.quota} bytes`,
);
reply.status(413).send({
error: 'Storage quota exceeded',
errorCode: SYNC_ERROR_CODES.STORAGE_QUOTA_EXCEEDED,
storageUsedBytes: finalQuota.currentUsage,
storageQuotaBytes: finalQuota.quota,
autoCleanupAttempted: true,
cleanupStats: {
freedBytes: cleanupResult.freedBytes,
deletedRestorePoints: cleanupResult.deletedRestorePoints,
deletedOps: cleanupResult.deletedOps,
},
});
return false;
}
export const syncRoutes = async (fastify: FastifyInstance): Promise<void> => {
// Add content type parser for gzip-encoded JSON
// This allows clients to send compressed request bodies with Content-Encoding: gzip
@ -313,56 +375,8 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise<void> => {
// Check storage quota before processing (after dedup to allow retries)
const payloadSize = JSON.stringify(body).length;
let quotaCheck = await syncService.checkStorageQuota(userId, payloadSize);
if (!quotaCheck.allowed) {
// Cache might be stale - recalculate actual storage before taking action
Logger.info(
`[user:${userId}] Quota check failed (cached: ${quotaCheck.currentUsage}/${quotaCheck.quota}). Recalculating actual storage...`,
);
await syncService.updateStorageUsage(userId);
quotaCheck = await syncService.checkStorageQuota(userId, payloadSize);
if (!quotaCheck.allowed) {
Logger.warn(
`[user:${userId}] Storage quota exceeded: ${quotaCheck.currentUsage}/${quotaCheck.quota} bytes. Attempting auto-cleanup...`,
);
// Iteratively delete old data until we have enough space
const cleanupResult = await syncService.freeStorageForUpload(
userId,
payloadSize,
);
if (cleanupResult.success) {
Logger.info(
`[user:${userId}] Auto-cleanup freed ${Math.round(cleanupResult.freedBytes / 1024)}KB ` +
`(${cleanupResult.deletedRestorePoints} restore points, ${cleanupResult.deletedOps} ops)`,
);
} else {
// Truly out of space - return error
const finalQuota = await syncService.checkStorageQuota(userId, payloadSize);
Logger.warn(
`[user:${userId}] Storage quota still exceeded after cleanup: ${finalQuota.currentUsage}/${finalQuota.quota} bytes`,
);
return reply.status(413).send({
error: 'Storage quota exceeded',
errorCode: SYNC_ERROR_CODES.STORAGE_QUOTA_EXCEEDED,
storageUsedBytes: finalQuota.currentUsage,
storageQuotaBytes: finalQuota.quota,
autoCleanupAttempted: true,
cleanupStats: {
freedBytes: cleanupResult.freedBytes,
deletedRestorePoints: cleanupResult.deletedRestorePoints,
deletedOps: cleanupResult.deletedOps,
},
});
}
} else {
Logger.info(
`[user:${userId}] Quota OK after recalculation: ${quotaCheck.currentUsage}/${quotaCheck.quota} bytes`,
);
}
}
const quotaOk = await enforceStorageQuota(userId, payloadSize, reply);
if (!quotaOk) return;
// Process operations - cast to Operation[] since Zod validates the structure
const results = await syncService.uploadOps(
@ -656,57 +670,8 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise<void> => {
// Check storage quota before processing
const payloadSize = JSON.stringify(body).length;
let quotaCheck = await syncService.checkStorageQuota(userId, payloadSize);
if (!quotaCheck.allowed) {
// Cache might be stale - recalculate actual storage before taking action
Logger.info(
`[user:${userId}] Snapshot quota check failed (cached: ${quotaCheck.currentUsage}/${quotaCheck.quota}). Recalculating actual storage...`,
);
await syncService.updateStorageUsage(userId);
quotaCheck = await syncService.checkStorageQuota(userId, payloadSize);
if (!quotaCheck.allowed) {
Logger.warn(
`[user:${userId}] Storage quota exceeded for snapshot: ` +
`${quotaCheck.currentUsage}/${quotaCheck.quota} bytes. Attempting auto-cleanup...`,
);
// Iteratively delete old data until we have enough space
const cleanupResult = await syncService.freeStorageForUpload(
userId,
payloadSize,
);
if (cleanupResult.success) {
Logger.info(
`[user:${userId}] Auto-cleanup freed ${Math.round(cleanupResult.freedBytes / 1024)}KB ` +
`(${cleanupResult.deletedRestorePoints} restore points, ${cleanupResult.deletedOps} ops)`,
);
} else {
// Truly out of space - return error
const finalQuota = await syncService.checkStorageQuota(userId, payloadSize);
Logger.warn(
`[user:${userId}] Storage quota still exceeded for snapshot after cleanup: ${finalQuota.currentUsage}/${finalQuota.quota} bytes`,
);
return reply.status(413).send({
error: 'Storage quota exceeded',
errorCode: SYNC_ERROR_CODES.STORAGE_QUOTA_EXCEEDED,
storageUsedBytes: finalQuota.currentUsage,
storageQuotaBytes: finalQuota.quota,
autoCleanupAttempted: true,
cleanupStats: {
freedBytes: cleanupResult.freedBytes,
deletedRestorePoints: cleanupResult.deletedRestorePoints,
deletedOps: cleanupResult.deletedOps,
},
});
}
} else {
Logger.info(
`[user:${userId}] Snapshot quota OK after recalculation: ${quotaCheck.currentUsage}/${quotaCheck.quota} bytes`,
);
}
}
const quotaOk = await enforceStorageQuota(userId, payloadSize, reply);
if (!quotaOk) return;
// FIX: Reject duplicate SYNC_IMPORT to prevent data loss
// Only the FIRST client to sync with an empty server should create SYNC_IMPORT.

View file

@ -24,6 +24,19 @@ import {
SnapshotService,
} from './services';
/**
* Main sync orchestration service.
*
* IMPORTANT: Single-instance deployment assumption
* This service uses process-local in-memory caches for:
* - Rate limiting (RateLimitService)
* - Request deduplication (RequestDeduplicationService)
* - Snapshot caching (SnapshotService)
*
* For multi-instance deployment behind a load balancer, these caches
* would need to be moved to a shared store (e.g., Redis) to ensure
* consistent behavior across instances.
*/
export class SyncService {
private config: SyncConfig;
private validationService: ValidationService;
@ -55,7 +68,12 @@ export class SyncService {
userId: number,
op: Operation,
tx: Prisma.TransactionClient,
): Promise<{ hasConflict: boolean; reason?: string; existingClock?: VectorClock }> {
): Promise<{
hasConflict: boolean;
reason?: string;
conflictType?: 'concurrent' | 'superseded' | 'equal_different_client' | 'unknown';
existingClock?: VectorClock;
}> {
// Skip conflict detection for full-state operations
if (
op.opType === 'SYNC_IMPORT' ||
@ -98,7 +116,12 @@ export class SyncService {
op: Operation,
entityId: string,
tx: Prisma.TransactionClient,
): Promise<{ hasConflict: boolean; reason?: string; existingClock?: VectorClock }> {
): Promise<{
hasConflict: boolean;
reason?: string;
conflictType?: 'concurrent' | 'superseded' | 'equal_different_client' | 'unknown';
existingClock?: VectorClock;
}> {
// Get the latest operation for this entity
const existingOp = await tx.operation.findFirst({
where: {
@ -137,6 +160,7 @@ export class SyncService {
if (comparison === 'EQUAL') {
return {
hasConflict: true,
conflictType: 'equal_different_client',
reason: `Equal vector clocks from different clients for ${op.entityType}:${entityId} (client ${op.clientId} vs ${existingOp.clientId})`,
existingClock,
};
@ -146,6 +170,7 @@ export class SyncService {
if (comparison === 'CONCURRENT') {
return {
hasConflict: true,
conflictType: 'concurrent',
reason: `Concurrent modification detected for ${op.entityType}:${entityId}`,
existingClock,
};
@ -155,6 +180,7 @@ export class SyncService {
if (comparison === 'LESS_THAN') {
return {
hasConflict: true,
conflictType: 'superseded',
reason: `Superseded operation: server has newer version of ${op.entityType}:${entityId}`,
existingClock,
};
@ -164,6 +190,7 @@ export class SyncService {
// But if we do, default to conflict for safety
return {
hasConflict: true,
conflictType: 'unknown',
reason: `Unknown vector clock comparison result for ${op.entityType}:${entityId}`,
existingClock,
};
@ -369,10 +396,11 @@ export class SyncService {
// Check for conflicts with existing operations
const conflict = await this.detectConflict(userId, op, tx);
if (conflict.hasConflict) {
const isConcurrent = conflict.reason?.includes('Concurrent');
const errorCode = isConcurrent
? SYNC_ERROR_CODES.CONFLICT_CONCURRENT
: SYNC_ERROR_CODES.CONFLICT_SUPERSEDED;
const errorCode =
conflict.conflictType === 'concurrent' ||
conflict.conflictType === 'equal_different_client'
? SYNC_ERROR_CODES.CONFLICT_CONCURRENT
: SYNC_ERROR_CODES.CONFLICT_SUPERSEDED;
Logger.audit({
event: 'OP_REJECTED',
userId,
@ -406,10 +434,11 @@ export class SyncService {
// isolation, this ensures no undetected concurrent modifications.
const finalConflict = await this.detectConflict(userId, op, tx);
if (finalConflict.hasConflict) {
const isConcurrent = finalConflict.reason?.includes('Concurrent');
const errorCode = isConcurrent
? SYNC_ERROR_CODES.CONFLICT_CONCURRENT
: SYNC_ERROR_CODES.CONFLICT_SUPERSEDED;
const errorCode =
finalConflict.conflictType === 'concurrent' ||
finalConflict.conflictType === 'equal_different_client'
? SYNC_ERROR_CODES.CONFLICT_CONCURRENT
: SYNC_ERROR_CODES.CONFLICT_SUPERSEDED;
Logger.audit({
event: 'OP_REJECTED',
userId,
@ -770,7 +799,9 @@ export class SyncService {
}
// Calculate approximate size of ops being deleted using SQL aggregate
// to avoid loading potentially large payloads into Node memory
// to avoid loading potentially large payloads into Node memory.
// NOTE: Column names match the Prisma `Operation` model's `@@map("operations")`
// and `@map(...)` annotations (see prisma/schema.prisma).
const sizeResult = await prisma.$queryRaw<[{ total: bigint | null }]>`
SELECT COALESCE(SUM(LENGTH(payload::text) + LENGTH(vector_clock::text)), 0) as total
FROM operations WHERE user_id = ${userId} AND server_seq <= ${deleteUpToSeq}
@ -842,8 +873,13 @@ export class SyncService {
let deletedRestorePoints = 0;
let totalDeletedOps = 0;
const MAX_CLEANUP_ITERATIONS = 50;
let iterations = 0;
// Keep trying until we have enough space or hit minimum
while (true) {
while (iterations < MAX_CLEANUP_ITERATIONS) {
iterations++;
// Check if we now have enough space
const quotaCheck = await this.checkStorageQuota(userId, requiredBytes);
if (quotaCheck.allowed) {
@ -899,6 +935,17 @@ export class SyncService {
`${restorePoints.length - 1} restore points remaining`,
);
}
// Exhausted max iterations without freeing enough space
Logger.warn(
`[user:${userId}] Storage cleanup exceeded max iterations (${MAX_CLEANUP_ITERATIONS})`,
);
return {
success: false,
freedBytes: totalFreedBytes,
deletedRestorePoints,
deletedOps: totalDeletedOps,
};
}
async deleteStaleDevices(beforeTime: number): Promise<number> {

View file

@ -78,7 +78,7 @@ export type OpType = (typeof OP_TYPES)[number];
* Validation rules:
* - Maximum 50 entries (prevents DoS via huge clocks)
* - Keys must be non-empty strings, max 255 characters
* - Values must be non-negative integers within Number.MAX_SAFE_INTEGER
* - Values must be non-negative integers, capped at 100,000,000
* - Invalid entries are removed (not rejected)
*/
export const sanitizeVectorClock = (
@ -119,9 +119,10 @@ export const sanitizeVectorClock = (
typeof value !== 'number' ||
!Number.isInteger(value) ||
value < 0 ||
// JSON-parsed numbers lose precision before reaching MAX_SAFE_INTEGER,
// so this acts as a "no corrupt data" guard rather than a strict cap.
value > Number.MAX_SAFE_INTEGER
// Cap at 100M — impossibly large for normal use (would need ~1 op/second
// for 3+ years) but prevents an adversarial client from sending a huge
// counter that makes all other clocks LESS_THAN it.
value > 100_000_000
) {
strippedCount++;
continue; // Skip invalid values

View file

@ -1286,6 +1286,11 @@ export class OperationLogStoreService {
* 5. These ops are compared as CONCURRENT with the import, not GREATER_THAN
* 6. SyncImportFilterService incorrectly filters them as "invalidated by import"
*
* NOTE: When a full-state op (SYNC_IMPORT/BACKUP_IMPORT/REPAIR) is present,
* its clock REPLACES (not merges with) the local clock. Callers must not mix
* pre-import and post-import ops in a single call all ops in the batch
* should belong to the same "epoch" (post-import or no import).
*
* @param ops Remote operations whose clocks should be merged into local clock
*/
async mergeRemoteOpClocks(ops: Operation[]): Promise<void> {