mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
1c27223278
commit
43b5808a3f
13 changed files with 250 additions and 268 deletions
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
95
e2e/utils/config-helpers.ts
Normal file
95
e2e/utils/config-helpers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue