From 60c0ba4e4254672ce41bd9d42f66fb8a2ff75468 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sat, 9 May 2026 17:48:40 +0200 Subject: [PATCH] refactor(sync): share SuperSync HTTP contract --- package-lock.json | 3 + packages/shared-schema/package.json | 3 + packages/shared-schema/src/index.ts | 50 +++++ .../src/supersync-http-contract.ts | 212 ++++++++++++++++++ .../tests/supersync-http-contract.spec.ts | 102 +++++++++ .../super-sync-server/src/sync/sync.const.ts | 6 +- .../super-sync-server/src/sync/sync.routes.ts | 86 +------ .../super-sync-server/src/sync/sync.types.ts | 18 +- .../sync-providers/provider.interface.ts | 5 + .../super-sync/response-validators.spec.ts | 26 ++- .../super-sync/response-validators.ts | 209 ++++++----------- 11 files changed, 489 insertions(+), 231 deletions(-) create mode 100644 packages/shared-schema/src/supersync-http-contract.ts create mode 100644 packages/shared-schema/tests/supersync-http-contract.spec.ts diff --git a/package-lock.json b/package-lock.json index e6392fb800..fdcea5ee2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29215,6 +29215,9 @@ "packages/shared-schema": { "name": "@sp/shared-schema", "version": "1.0.0", + "dependencies": { + "zod": "^4.3.5" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0", diff --git a/packages/shared-schema/package.json b/packages/shared-schema/package.json index fca2b7f861..428f5433e6 100644 --- a/packages/shared-schema/package.json +++ b/packages/shared-schema/package.json @@ -17,6 +17,9 @@ } } }, + "dependencies": { + "zod": "^4.3.5" + }, "scripts": { "build": "tsup", "build:tsc": "tsc", diff --git a/packages/shared-schema/src/index.ts b/packages/shared-schema/src/index.ts index 207de01574..5938621d65 100644 --- a/packages/shared-schema/src/index.ts +++ b/packages/shared-schema/src/index.ts @@ -39,3 +39,53 @@ export { // Entity types (shared between client and server) export type { EntityType } from './entity-types'; export { ENTITY_TYPES } from './entity-types'; + +// SuperSync HTTP contract (shared between client and server) +export { + SUPER_SYNC_CLIENT_ID_REGEX, + SUPER_SYNC_MAX_CLIENT_ID_LENGTH, + SUPER_SYNC_MAX_OPS_PER_UPLOAD, + SUPER_SYNC_OP_TYPES, + SUPER_SYNC_IMPORT_REASONS, + SUPER_SYNC_SNAPSHOT_REASONS, + SUPER_SYNC_SNAPSHOT_OP_TYPES, + SuperSyncVectorClockSchema, + SuperSyncClientIdSchema, + SuperSyncOperationSchema, + SuperSyncUploadOpsRequestSchema, + SuperSyncDownloadOpsQuerySchema, + SuperSyncUploadSnapshotRequestSchema, + SuperSyncOperationResponseSchema, + SuperSyncServerOperationSchema, + SuperSyncUploadResultSchema, + SuperSyncUploadOpsResponseSchema, + SuperSyncDownloadOpsResponseSchema, + SuperSyncSnapshotResponseSchema, + SuperSyncSnapshotUploadResponseSchema, + SuperSyncStatusResponseSchema, + SuperSyncRestorePointSchema, + SuperSyncRestorePointsResponseSchema, + SuperSyncRestoreSnapshotResponseSchema, + SuperSyncDeleteAllDataResponseSchema, +} from './supersync-http-contract'; +export type { + SuperSyncOpType, + SuperSyncImportReason, + SuperSyncSnapshotReason, + SuperSyncSnapshotOpType, + SuperSyncOperation, + SuperSyncUploadOpsRequest, + SuperSyncDownloadOpsQuery, + SuperSyncUploadSnapshotRequest, + SuperSyncServerOperation, + SuperSyncUploadResult, + SuperSyncUploadOpsResponse, + SuperSyncDownloadOpsResponse, + SuperSyncSnapshotResponse, + SuperSyncSnapshotUploadResponse, + SuperSyncStatusResponse, + SuperSyncRestorePoint, + SuperSyncRestorePointsResponse, + SuperSyncRestoreSnapshotResponse, + SuperSyncDeleteAllDataResponse, +} from './supersync-http-contract'; diff --git a/packages/shared-schema/src/supersync-http-contract.ts b/packages/shared-schema/src/supersync-http-contract.ts new file mode 100644 index 0000000000..0068298d2e --- /dev/null +++ b/packages/shared-schema/src/supersync-http-contract.ts @@ -0,0 +1,212 @@ +import { z } from 'zod'; + +export const SUPER_SYNC_CLIENT_ID_REGEX = /^[a-zA-Z0-9_-]+$/; +export const SUPER_SYNC_MAX_CLIENT_ID_LENGTH = 255; +export const SUPER_SYNC_MAX_OPS_PER_UPLOAD = 100; + +export const SUPER_SYNC_OP_TYPES = [ + 'CRT', + 'UPD', + 'DEL', + 'MOV', + 'BATCH', + 'SYNC_IMPORT', + 'BACKUP_IMPORT', + 'REPAIR', +] as const; + +export const SUPER_SYNC_IMPORT_REASONS = [ + 'PASSWORD_CHANGED', + 'FILE_IMPORT', + 'BACKUP_RESTORE', + 'FORCE_UPLOAD', + 'SERVER_MIGRATION', + 'REPAIR', +] as const; + +export const SUPER_SYNC_SNAPSHOT_REASONS = ['initial', 'recovery', 'migration'] as const; + +export const SUPER_SYNC_SNAPSHOT_OP_TYPES = [ + 'SYNC_IMPORT', + 'BACKUP_IMPORT', + 'REPAIR', +] as const; + +export const SuperSyncVectorClockSchema = z.record(z.string(), z.number()); + +export const SuperSyncClientIdSchema = z + .string() + .min(1) + .max(SUPER_SYNC_MAX_CLIENT_ID_LENGTH) + .regex( + SUPER_SYNC_CLIENT_ID_REGEX, + 'clientId must be alphanumeric with underscores/hyphens only', + ); + +export const SuperSyncOperationSchema = z.object({ + id: z.string().min(1).max(255), + clientId: SuperSyncClientIdSchema, + actionType: z.string().min(1).max(255), + opType: z.enum(SUPER_SYNC_OP_TYPES), + entityType: z.string().min(1).max(255), + entityId: z.string().max(255).optional(), + entityIds: z.array(z.string().max(255)).optional(), + payload: z.unknown(), + vectorClock: SuperSyncVectorClockSchema, + timestamp: z.number(), + schemaVersion: z.number(), + isPayloadEncrypted: z.boolean().optional(), + syncImportReason: z.enum(SUPER_SYNC_IMPORT_REASONS).optional(), +}); + +export const SuperSyncUploadOpsRequestSchema = z.object({ + ops: z.array(SuperSyncOperationSchema).min(1).max(SUPER_SYNC_MAX_OPS_PER_UPLOAD), + clientId: SuperSyncClientIdSchema, + lastKnownServerSeq: z.number().optional(), + requestId: z.string().min(1).max(64).optional(), + isCleanSlate: z.boolean().optional(), +}); + +export const SuperSyncDownloadOpsQuerySchema = z.object({ + sinceSeq: z.coerce.number().int().min(0), + limit: z.coerce.number().int().min(1).max(1000).optional(), + excludeClient: SuperSyncClientIdSchema.optional(), +}); + +export const SuperSyncUploadSnapshotRequestSchema = z.object({ + state: z.unknown(), + clientId: SuperSyncClientIdSchema, + reason: z.enum(SUPER_SYNC_SNAPSHOT_REASONS), + vectorClock: SuperSyncVectorClockSchema, + schemaVersion: z.number().optional(), + isPayloadEncrypted: z.boolean().optional(), + syncImportReason: z.enum(SUPER_SYNC_IMPORT_REASONS).optional(), + opId: z.string().uuid().optional(), + isCleanSlate: z.boolean().optional(), + snapshotOpType: z.enum(SUPER_SYNC_SNAPSHOT_OP_TYPES).optional(), +}); + +export const SuperSyncOperationResponseSchema = SuperSyncOperationSchema.passthrough(); + +export const SuperSyncServerOperationSchema = z + .object({ + serverSeq: z.number(), + op: SuperSyncOperationResponseSchema, + receivedAt: z.number(), + }) + .passthrough(); + +export const SuperSyncUploadResultSchema = z + .object({ + opId: z.string(), + accepted: z.boolean(), + serverSeq: z.number().optional(), + error: z.string().optional(), + errorCode: z.string().optional(), + existingClock: SuperSyncVectorClockSchema.optional(), + }) + .passthrough(); + +export const SuperSyncUploadOpsResponseSchema = z + .object({ + results: z.array(SuperSyncUploadResultSchema), + newOps: z.array(SuperSyncServerOperationSchema).optional(), + latestSeq: z.number(), + hasMorePiggyback: z.boolean().optional(), + }) + .passthrough(); + +export const SuperSyncDownloadOpsResponseSchema = z + .object({ + ops: z.array(SuperSyncServerOperationSchema), + hasMore: z.boolean(), + latestSeq: z.number(), + gapDetected: z.boolean().optional(), + latestSnapshotSeq: z.number().optional(), + snapshotVectorClock: SuperSyncVectorClockSchema.optional(), + serverTime: z.number().optional(), + }) + .passthrough(); + +export const SuperSyncSnapshotResponseSchema = z + .object({ + state: z.unknown(), + serverSeq: z.number(), + generatedAt: z.number(), + }) + .passthrough(); + +export const SuperSyncSnapshotUploadResponseSchema = z + .object({ + accepted: z.boolean(), + serverSeq: z.number().optional(), + error: z.string().optional(), + }) + .passthrough(); + +export const SuperSyncStatusResponseSchema = z + .object({ + latestSeq: z.number(), + devicesOnline: z.number(), + snapshotAge: z.number().optional(), + storageUsedBytes: z.number(), + storageQuotaBytes: z.number(), + }) + .passthrough(); + +export const SuperSyncRestorePointSchema = z + .object({ + serverSeq: z.number(), + timestamp: z.number(), + type: z.enum(SUPER_SYNC_SNAPSHOT_OP_TYPES), + clientId: z.string(), + description: z.string().optional(), + }) + .passthrough(); + +export const SuperSyncRestorePointsResponseSchema = z + .object({ + restorePoints: z.array(SuperSyncRestorePointSchema), + }) + .passthrough(); + +export const SuperSyncRestoreSnapshotResponseSchema = SuperSyncSnapshotResponseSchema; + +export const SuperSyncDeleteAllDataResponseSchema = z + .object({ + success: z.boolean(), + }) + .passthrough(); + +export type SuperSyncOpType = (typeof SUPER_SYNC_OP_TYPES)[number]; +export type SuperSyncImportReason = (typeof SUPER_SYNC_IMPORT_REASONS)[number]; +export type SuperSyncSnapshotReason = (typeof SUPER_SYNC_SNAPSHOT_REASONS)[number]; +export type SuperSyncSnapshotOpType = (typeof SUPER_SYNC_SNAPSHOT_OP_TYPES)[number]; + +export type SuperSyncOperation = z.infer; +export type SuperSyncUploadOpsRequest = z.infer; +export type SuperSyncDownloadOpsQuery = z.infer; +export type SuperSyncUploadSnapshotRequest = z.infer< + typeof SuperSyncUploadSnapshotRequestSchema +>; +export type SuperSyncServerOperation = z.infer; +export type SuperSyncUploadResult = z.infer; +export type SuperSyncUploadOpsResponse = z.infer; +export type SuperSyncDownloadOpsResponse = z.infer< + typeof SuperSyncDownloadOpsResponseSchema +>; +export type SuperSyncSnapshotResponse = z.infer; +export type SuperSyncSnapshotUploadResponse = z.infer< + typeof SuperSyncSnapshotUploadResponseSchema +>; +export type SuperSyncStatusResponse = z.infer; +export type SuperSyncRestorePoint = z.infer; +export type SuperSyncRestorePointsResponse = z.infer< + typeof SuperSyncRestorePointsResponseSchema +>; +export type SuperSyncRestoreSnapshotResponse = z.infer< + typeof SuperSyncRestoreSnapshotResponseSchema +>; +export type SuperSyncDeleteAllDataResponse = z.infer< + typeof SuperSyncDeleteAllDataResponseSchema +>; diff --git a/packages/shared-schema/tests/supersync-http-contract.spec.ts b/packages/shared-schema/tests/supersync-http-contract.spec.ts new file mode 100644 index 0000000000..e314213240 --- /dev/null +++ b/packages/shared-schema/tests/supersync-http-contract.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + SUPER_SYNC_MAX_OPS_PER_UPLOAD, + SuperSyncDownloadOpsQuerySchema, + SuperSyncDownloadOpsResponseSchema, + SuperSyncUploadOpsRequestSchema, + SuperSyncUploadSnapshotRequestSchema, +} from '../src/supersync-http-contract'; + +const createValidOperation = (clientId: string = 'client_1') => ({ + id: 'op-1', + clientId, + actionType: '[Task] Add task', + opType: 'CRT', + entityType: 'TASK', + entityId: 'task-1', + payload: { title: 'Test' }, + vectorClock: { [clientId]: 1 }, + timestamp: 1234567890, + schemaVersion: 1, +}); + +describe('SuperSync HTTP contract schemas', () => { + it('validates ops upload requests with the shared server limit', () => { + const parsed = SuperSyncUploadOpsRequestSchema.parse({ + ops: [createValidOperation()], + clientId: 'client_1', + lastKnownServerSeq: 12, + requestId: 'request-1', + isCleanSlate: false, + }); + + expect(parsed.ops.length).toBe(1); + expect(SUPER_SYNC_MAX_OPS_PER_UPLOAD).toBe(100); + }); + + it('preserves server request behavior by stripping unknown upload fields', () => { + const parsed = SuperSyncUploadOpsRequestSchema.parse({ + ops: [{ ...createValidOperation(), extraOpField: true }], + clientId: 'client_1', + extraRequestField: true, + }); + + expect('extraRequestField' in parsed).toBe(false); + expect('extraOpField' in parsed.ops[0]).toBe(false); + }); + + it('rejects invalid client IDs in upload requests', () => { + expect(() => + SuperSyncUploadOpsRequestSchema.parse({ + ops: [createValidOperation('invalid client')], + clientId: 'invalid client', + }), + ).toThrow(); + }); + + it('coerces download query numbers like the route-level schema', () => { + const parsed = SuperSyncDownloadOpsQuerySchema.parse({ + sinceSeq: '5', + limit: '50', + excludeClient: 'client-1', + }); + + expect(parsed).toEqual({ + sinceSeq: 5, + limit: 50, + excludeClient: 'client-1', + }); + }); + + it('validates snapshot upload requests', () => { + const parsed = SuperSyncUploadSnapshotRequestSchema.parse({ + state: { tasks: {} }, + clientId: 'client_1', + reason: 'recovery', + vectorClock: { client_1: 2 }, + schemaVersion: 1, + isPayloadEncrypted: true, + syncImportReason: 'BACKUP_RESTORE', + opId: '018f2f0b-1c2d-7a1b-8c3d-123456789abc', + isCleanSlate: true, + snapshotOpType: 'BACKUP_IMPORT', + }); + + expect(parsed.snapshotOpType).toBe('BACKUP_IMPORT'); + }); + + it('validates download responses with latestSnapshotSeq and preserves future fields', () => { + const parsed = SuperSyncDownloadOpsResponseSchema.parse({ + ops: [], + hasMore: false, + latestSeq: 20, + latestSnapshotSeq: 10, + snapshotVectorClock: { client_1: 10 }, + serverTime: 1234567890, + futureServerField: 'kept', + }); + + expect(parsed.latestSnapshotSeq).toBe(10); + expect((parsed as { futureServerField?: string }).futureServerField).toBe('kept'); + }); +}); diff --git a/packages/super-sync-server/src/sync/sync.const.ts b/packages/super-sync-server/src/sync/sync.const.ts index 1358ddb311..845aea290e 100644 --- a/packages/super-sync-server/src/sync/sync.const.ts +++ b/packages/super-sync-server/src/sync/sync.const.ts @@ -1,2 +1,4 @@ -export const CLIENT_ID_REGEX = /^[a-zA-Z0-9_-]+$/; -export const MAX_CLIENT_ID_LENGTH = 255; +export { + SUPER_SYNC_CLIENT_ID_REGEX as CLIENT_ID_REGEX, + SUPER_SYNC_MAX_CLIENT_ID_LENGTH as MAX_CLIENT_ID_LENGTH, +} from '@sp/shared-schema'; diff --git a/packages/super-sync-server/src/sync/sync.routes.ts b/packages/super-sync-server/src/sync/sync.routes.ts index 2846b4319e..98c74e4e16 100644 --- a/packages/super-sync-server/src/sync/sync.routes.ts +++ b/packages/super-sync-server/src/sync/sync.routes.ts @@ -1,6 +1,11 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { uuidv7 } from 'uuidv7'; +import { + SuperSyncDownloadOpsQuerySchema, + SuperSyncUploadOpsRequestSchema, + SuperSyncUploadSnapshotRequestSchema, +} from '@sp/shared-schema'; import { authenticate, getAuthUser } from '../middleware'; import { getSyncService } from './sync.service'; import { getWsConnectionService } from './services/websocket-connection.service'; @@ -12,8 +17,6 @@ import { DownloadOpsResponse, SnapshotResponse, SyncStatusResponse, - DEFAULT_SYNC_CONFIG, - OP_TYPES, SYNC_ERROR_CODES, } from './sync.types'; import { @@ -34,9 +37,6 @@ const createValidationErrorResponse = ( return { error: 'Validation failed', details: zodIssues }; }; -// Validation constants -import { CLIENT_ID_REGEX, MAX_CLIENT_ID_LENGTH } from './sync.const'; - // Two-stage protection against zip bombs: // 1. Pre-check: Reject compressed data > limit (typical ratio ~10:1) // 2. Post-check: Reject decompressed data > limit (catches edge cases) @@ -48,76 +48,6 @@ const MAX_COMPRESSED_SIZE_OPS = 10 * 1024 * 1024; // 10MB for /ops const MAX_COMPRESSED_SIZE_SNAPSHOT = 30 * 1024 * 1024; // 30MB for /snapshot (matches bodyLimit) const MAX_DECOMPRESSED_SIZE = 100 * 1024 * 1024; // 100MB - catches malicious high-ratio compression -// Zod Schemas -const ClientIdSchema = z - .string() - .min(1) - .max(MAX_CLIENT_ID_LENGTH) - .regex(CLIENT_ID_REGEX, 'clientId must be alphanumeric with underscores/hyphens only'); - -const OperationSchema = z.object({ - id: z.string().min(1).max(255), - clientId: ClientIdSchema, - actionType: z.string().min(1).max(255), - opType: z.enum(OP_TYPES), - entityType: z.string().min(1).max(255), - entityId: z.string().max(255).optional(), - entityIds: z.array(z.string().max(255)).optional(), // For batch operations - payload: z.unknown(), - vectorClock: z.record(z.string(), z.number()), - timestamp: z.number(), - schemaVersion: z.number(), - isPayloadEncrypted: z.boolean().optional(), // True if payload is E2E encrypted - syncImportReason: z - .enum([ - 'PASSWORD_CHANGED', - 'FILE_IMPORT', - 'BACKUP_RESTORE', - 'FORCE_UPLOAD', - 'SERVER_MIGRATION', - 'REPAIR', - ]) - .optional(), -}); - -const UploadOpsSchema = z.object({ - ops: z.array(OperationSchema).min(1).max(DEFAULT_SYNC_CONFIG.maxOpsPerUpload), - clientId: ClientIdSchema, - lastKnownServerSeq: z.number().optional(), - requestId: z.string().min(1).max(64).optional(), // For request deduplication - isCleanSlate: z.boolean().optional(), // If true, server deletes all user data before accepting ops -}); - -const DownloadOpsQuerySchema = z.object({ - sinceSeq: z.coerce.number().int().min(0), - limit: z.coerce.number().int().min(1).max(1000).optional(), - excludeClient: ClientIdSchema.optional(), -}); - -const UploadSnapshotSchema = z.object({ - state: z.unknown(), - clientId: ClientIdSchema, - reason: z.enum(['initial', 'recovery', 'migration']), - vectorClock: z.record(z.string(), z.number()), - schemaVersion: z.number().optional(), - isPayloadEncrypted: z.boolean().optional(), - syncImportReason: z - .enum([ - 'PASSWORD_CHANGED', - 'FILE_IMPORT', - 'BACKUP_RESTORE', - 'FORCE_UPLOAD', - 'SERVER_MIGRATION', - 'REPAIR', - ]) - .optional(), - // Client's operation ID - server MUST use this to prevent ID mismatch bugs - opId: z.string().uuid().optional(), - isCleanSlate: z.boolean().optional(), - // Client-provided opType for correct operation typing (optional for backward compat) - snapshotOpType: z.enum(['SYNC_IMPORT', 'BACKUP_IMPORT', 'REPAIR']).optional(), -}); - // Error helper const errorMessage = (err: unknown): string => err instanceof Error ? err.message : 'Unknown error'; @@ -296,7 +226,7 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise => { } // Validate request body - const parseResult = UploadOpsSchema.safeParse(body); + const parseResult = SuperSyncUploadOpsRequestSchema.safeParse(body); if (!parseResult.success) { Logger.warn( `[user:${userId}] Upload validation failed`, @@ -504,7 +434,7 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise => { const userId = getAuthUser(req).userId; // Validate query params - const parseResult = DownloadOpsQuerySchema.safeParse(req.query); + const parseResult = SuperSyncDownloadOpsQuerySchema.safeParse(req.query); if (!parseResult.success) { Logger.warn( `[user:${userId}] Download validation failed`, @@ -640,7 +570,7 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise => { } // Validate request body - const parseResult = UploadSnapshotSchema.safeParse(body); + const parseResult = SuperSyncUploadSnapshotRequestSchema.safeParse(body); if (!parseResult.success) { Logger.warn( `[user:${userId}] Snapshot upload validation failed`, diff --git a/packages/super-sync-server/src/sync/sync.types.ts b/packages/super-sync-server/src/sync/sync.types.ts index eda6d07b2e..fc975e30af 100644 --- a/packages/super-sync-server/src/sync/sync.types.ts +++ b/packages/super-sync-server/src/sync/sync.types.ts @@ -1,5 +1,8 @@ import { Logger } from '../logger'; import { + SUPER_SYNC_MAX_OPS_PER_UPLOAD, + SUPER_SYNC_OP_TYPES, + type SuperSyncOpType, VectorClock, VectorClockComparison, compareVectorClocks, @@ -68,18 +71,9 @@ export interface ConflictResult { } // Operation types - single source of truth -export const OP_TYPES = [ - 'CRT', - 'UPD', - 'DEL', - 'MOV', - 'BATCH', - 'SYNC_IMPORT', - 'BACKUP_IMPORT', - 'REPAIR', -] as const; +export const OP_TYPES = SUPER_SYNC_OP_TYPES; -export type OpType = (typeof OP_TYPES)[number]; +export type OpType = SuperSyncOpType; // VectorClock, VectorClockComparison, and compareVectorClocks are imported from @sp/shared-schema // and re-exported above. This ensures client and server use identical implementations. @@ -393,7 +387,7 @@ export const RETENTION_MS = RETENTION_DAYS * MS_PER_DAY; export const ONLINE_DEVICE_THRESHOLD_MS = 5 * MS_PER_MINUTE; // 5 minutes export const DEFAULT_SYNC_CONFIG: SyncConfig = { - maxOpsPerUpload: 100, + maxOpsPerUpload: SUPER_SYNC_MAX_OPS_PER_UPLOAD, maxPayloadSizeBytes: 20 * 1024 * 1024, // 20MB - needed for large imports downloadLimit: 1000, uploadRateLimit: { max: 100, windowMs: MS_PER_MINUTE }, diff --git a/src/app/op-log/sync-providers/provider.interface.ts b/src/app/op-log/sync-providers/provider.interface.ts index 442ae4bb6d..2e0d253447 100644 --- a/src/app/op-log/sync-providers/provider.interface.ts +++ b/src/app/op-log/sync-providers/provider.interface.ts @@ -228,6 +228,11 @@ export interface OpDownloadResponse { ops: ServerSyncOperation[]; hasMore: boolean; latestSeq: number; + /** + * Server sequence of the latest full-state operation (SYNC_IMPORT, BACKUP_IMPORT, REPAIR). + * Fresh clients can use this to understand where the effective state starts. + */ + latestSnapshotSeq?: number; /** * Whether a gap was detected in the operation sequence. * This can happen when: diff --git a/src/app/op-log/sync-providers/super-sync/response-validators.spec.ts b/src/app/op-log/sync-providers/super-sync/response-validators.spec.ts index bcd88947da..a557a26ec5 100644 --- a/src/app/op-log/sync-providers/super-sync/response-validators.spec.ts +++ b/src/app/op-log/sync-providers/super-sync/response-validators.spec.ts @@ -67,6 +67,18 @@ describe('response-validators', () => { }; expect(() => validateOpUploadResponse(response)).not.toThrow(); }); + + it('should preserve forward-compatible extra response fields', () => { + const response = { + results: [], + latestSeq: 50, + deduplicated: true, + }; + + const validated = validateOpUploadResponse(response); + + expect((validated as { deduplicated?: boolean }).deduplicated).toBe(true); + }); }); describe('validateOpDownloadResponse', () => { @@ -85,10 +97,12 @@ describe('response-validators', () => { hasMore: true, latestSeq: 150, gapDetected: false, + latestSnapshotSeq: 120, snapshotVectorClock: { client1: 10 }, serverTime: 1234567890, }; - expect(() => validateOpDownloadResponse(response)).not.toThrow(); + const validated = validateOpDownloadResponse(response); + expect(validated.latestSnapshotSeq).toBe(120); }); it('should throw if not an object', () => { @@ -187,7 +201,15 @@ describe('response-validators', () => { it('should accept response with restore points', () => { const response = { - restorePoints: [{ serverSeq: 100, type: 'SYNC_IMPORT', createdAt: '2024-01-01' }], + restorePoints: [ + { + serverSeq: 100, + timestamp: 1234567890, + type: 'SYNC_IMPORT', + clientId: 'client-1', + description: 'Initial sync', + }, + ], }; expect(() => validateRestorePointsResponse(response)).not.toThrow(); }); diff --git a/src/app/op-log/sync-providers/super-sync/response-validators.ts b/src/app/op-log/sync-providers/super-sync/response-validators.ts index 05c84d5817..d6d2f220ae 100644 --- a/src/app/op-log/sync-providers/super-sync/response-validators.ts +++ b/src/app/op-log/sync-providers/super-sync/response-validators.ts @@ -1,3 +1,11 @@ +import { + SuperSyncDeleteAllDataResponseSchema, + SuperSyncDownloadOpsResponseSchema, + SuperSyncRestorePointsResponseSchema, + SuperSyncRestoreSnapshotResponseSchema, + SuperSyncSnapshotUploadResponseSchema, + SuperSyncUploadOpsResponseSchema, +} from '@sp/shared-schema'; import { OpUploadResponse, OpDownloadResponse, @@ -7,171 +15,98 @@ import { } from '../provider.interface'; import { InvalidDataSPError } from '../../core/errors/sync-errors'; -/** - * Validates that a value is an object (not null, array, or primitive). - */ -const isObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); +type ValidationIssue = { + path: readonly PropertyKey[]; + message: string; +}; -/** - * Validates that a value is an array. - */ -const isArray = (value: unknown): value is unknown[] => Array.isArray(value); +type SafeParseResult = + | { success: true; data: T } + | { success: false; error: { issues: ValidationIssue[] } }; + +type SafeParseSchema = { + safeParse: (data: unknown) => SafeParseResult; +}; + +const formatIssuePath = (path: readonly PropertyKey[]): string => + path.length > 0 ? `.${path.map((segment) => String(segment)).join('.')}` : ''; + +const parseResponse = ( + schema: SafeParseSchema, + data: unknown, + responseName: string, +): T => { + const parseResult = schema.safeParse(data); + + if (parseResult.success) { + return parseResult.data; + } + + const details = parseResult.error.issues + .map((issue) => `${responseName}${formatIssuePath(issue.path)}: ${issue.message}`) + .join('; '); + + throw new InvalidDataSPError(details || `${responseName} is invalid`); +}; /** * Validates OpUploadResponse from server. * Throws InvalidDataSPError if the response structure is invalid. */ -export const validateOpUploadResponse = (data: unknown): OpUploadResponse => { - if (!isObject(data)) { - throw new InvalidDataSPError('OpUploadResponse must be an object'); - } - - if (!isArray(data.results)) { - throw new InvalidDataSPError('OpUploadResponse.results must be an array'); - } - - if (typeof data.latestSeq !== 'number') { - throw new InvalidDataSPError('OpUploadResponse.latestSeq must be a number'); - } - - // Optional fields: newOps (array), hasMorePiggyback (boolean) - if (data.newOps !== undefined && !isArray(data.newOps)) { - throw new InvalidDataSPError('OpUploadResponse.newOps must be an array if present'); - } - - if (data.hasMorePiggyback !== undefined && typeof data.hasMorePiggyback !== 'boolean') { - throw new InvalidDataSPError( - 'OpUploadResponse.hasMorePiggyback must be a boolean if present', - ); - } - - return data as unknown as OpUploadResponse; -}; +export const validateOpUploadResponse = (data: unknown): OpUploadResponse => + parseResponse( + SuperSyncUploadOpsResponseSchema, + data, + 'OpUploadResponse', + ) as unknown as OpUploadResponse; /** * Validates OpDownloadResponse from server. * Throws InvalidDataSPError if the response structure is invalid. */ -export const validateOpDownloadResponse = (data: unknown): OpDownloadResponse => { - if (!isObject(data)) { - throw new InvalidDataSPError('OpDownloadResponse must be an object'); - } - - if (!isArray(data.ops)) { - throw new InvalidDataSPError('OpDownloadResponse.ops must be an array'); - } - - if (typeof data.hasMore !== 'boolean') { - throw new InvalidDataSPError('OpDownloadResponse.hasMore must be a boolean'); - } - - if (typeof data.latestSeq !== 'number') { - throw new InvalidDataSPError('OpDownloadResponse.latestSeq must be a number'); - } - - // Optional fields: gapDetected, snapshotVectorClock, serverTime, snapshotState - if (data.gapDetected !== undefined && typeof data.gapDetected !== 'boolean') { - throw new InvalidDataSPError( - 'OpDownloadResponse.gapDetected must be a boolean if present', - ); - } - - if (data.snapshotVectorClock !== undefined && !isObject(data.snapshotVectorClock)) { - throw new InvalidDataSPError( - 'OpDownloadResponse.snapshotVectorClock must be an object if present', - ); - } - - if (data.serverTime !== undefined && typeof data.serverTime !== 'number') { - throw new InvalidDataSPError( - 'OpDownloadResponse.serverTime must be a number if present', - ); - } - - return data as unknown as OpDownloadResponse; -}; +export const validateOpDownloadResponse = (data: unknown): OpDownloadResponse => + parseResponse( + SuperSyncDownloadOpsResponseSchema, + data, + 'OpDownloadResponse', + ) as unknown as OpDownloadResponse; /** * Validates SnapshotUploadResponse from server. * Throws InvalidDataSPError if the response structure is invalid. */ -export const validateSnapshotUploadResponse = (data: unknown): SnapshotUploadResponse => { - if (!isObject(data)) { - throw new InvalidDataSPError('SnapshotUploadResponse must be an object'); - } - - if (typeof data.accepted !== 'boolean') { - throw new InvalidDataSPError('SnapshotUploadResponse.accepted must be a boolean'); - } - - // Optional fields: serverSeq (number), error (string) - if (data.serverSeq !== undefined && typeof data.serverSeq !== 'number') { - throw new InvalidDataSPError( - 'SnapshotUploadResponse.serverSeq must be a number if present', - ); - } - - if (data.error !== undefined && typeof data.error !== 'string') { - throw new InvalidDataSPError( - 'SnapshotUploadResponse.error must be a string if present', - ); - } - - return data as unknown as SnapshotUploadResponse; -}; +export const validateSnapshotUploadResponse = (data: unknown): SnapshotUploadResponse => + parseResponse( + SuperSyncSnapshotUploadResponseSchema, + data, + 'SnapshotUploadResponse', + ) as SnapshotUploadResponse; /** * Validates RestorePointsResponse from server. * Throws InvalidDataSPError if the response structure is invalid. */ -export const validateRestorePointsResponse = (data: unknown): RestorePointsResponse => { - if (!isObject(data)) { - throw new InvalidDataSPError('RestorePointsResponse must be an object'); - } - - if (!isArray(data.restorePoints)) { - throw new InvalidDataSPError('RestorePointsResponse.restorePoints must be an array'); - } - - return data as unknown as RestorePointsResponse; -}; +export const validateRestorePointsResponse = (data: unknown): RestorePointsResponse => + parseResponse( + SuperSyncRestorePointsResponseSchema, + data, + 'RestorePointsResponse', + ) as RestorePointsResponse; /** * Validates RestoreSnapshotResponse from server. * Throws InvalidDataSPError if the response structure is invalid. */ -export const validateRestoreSnapshotResponse = ( - data: unknown, -): RestoreSnapshotResponse => { - if (!isObject(data)) { - throw new InvalidDataSPError('RestoreSnapshotResponse must be an object'); - } - - // serverSeq and generatedAt are required, state can be any value (including null) - if (typeof data.serverSeq !== 'number') { - throw new InvalidDataSPError('RestoreSnapshotResponse.serverSeq must be a number'); - } - - if (typeof data.generatedAt !== 'number') { - throw new InvalidDataSPError('RestoreSnapshotResponse.generatedAt must be a number'); - } - - return data as unknown as RestoreSnapshotResponse; -}; +export const validateRestoreSnapshotResponse = (data: unknown): RestoreSnapshotResponse => + parseResponse( + SuperSyncRestoreSnapshotResponseSchema, + data, + 'RestoreSnapshotResponse', + ) as RestoreSnapshotResponse; /** * Validates DeleteAllDataResponse from server. * Throws InvalidDataSPError if the response structure is invalid. */ -export const validateDeleteAllDataResponse = (data: unknown): { success: boolean } => { - if (!isObject(data)) { - throw new InvalidDataSPError('DeleteAllDataResponse must be an object'); - } - - if (typeof data.success !== 'boolean') { - throw new InvalidDataSPError('DeleteAllDataResponse.success must be a boolean'); - } - - return data as unknown as { success: boolean }; -}; +export const validateDeleteAllDataResponse = (data: unknown): { success: boolean } => + parseResponse(SuperSyncDeleteAllDataResponseSchema, data, 'DeleteAllDataResponse');