refactor(sync): share SuperSync HTTP contract

This commit is contained in:
Johannes Millan 2026-05-09 17:48:40 +02:00
parent 730493d487
commit 60c0ba4e42
11 changed files with 489 additions and 231 deletions

3
package-lock.json generated
View file

@ -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",

View file

@ -17,6 +17,9 @@
}
}
},
"dependencies": {
"zod": "^4.3.5"
},
"scripts": {
"build": "tsup",
"build:tsc": "tsc",

View file

@ -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';

View file

@ -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<typeof SuperSyncOperationSchema>;
export type SuperSyncUploadOpsRequest = z.infer<typeof SuperSyncUploadOpsRequestSchema>;
export type SuperSyncDownloadOpsQuery = z.infer<typeof SuperSyncDownloadOpsQuerySchema>;
export type SuperSyncUploadSnapshotRequest = z.infer<
typeof SuperSyncUploadSnapshotRequestSchema
>;
export type SuperSyncServerOperation = z.infer<typeof SuperSyncServerOperationSchema>;
export type SuperSyncUploadResult = z.infer<typeof SuperSyncUploadResultSchema>;
export type SuperSyncUploadOpsResponse = z.infer<typeof SuperSyncUploadOpsResponseSchema>;
export type SuperSyncDownloadOpsResponse = z.infer<
typeof SuperSyncDownloadOpsResponseSchema
>;
export type SuperSyncSnapshotResponse = z.infer<typeof SuperSyncSnapshotResponseSchema>;
export type SuperSyncSnapshotUploadResponse = z.infer<
typeof SuperSyncSnapshotUploadResponseSchema
>;
export type SuperSyncStatusResponse = z.infer<typeof SuperSyncStatusResponseSchema>;
export type SuperSyncRestorePoint = z.infer<typeof SuperSyncRestorePointSchema>;
export type SuperSyncRestorePointsResponse = z.infer<
typeof SuperSyncRestorePointsResponseSchema
>;
export type SuperSyncRestoreSnapshotResponse = z.infer<
typeof SuperSyncRestoreSnapshotResponseSchema
>;
export type SuperSyncDeleteAllDataResponse = z.infer<
typeof SuperSyncDeleteAllDataResponseSchema
>;

View file

@ -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');
});
});

View file

@ -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';

View file

@ -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<void> => {
}
// 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<void> => {
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<void> => {
}
// Validate request body
const parseResult = UploadSnapshotSchema.safeParse(body);
const parseResult = SuperSyncUploadSnapshotRequestSchema.safeParse(body);
if (!parseResult.success) {
Logger.warn(
`[user:${userId}] Snapshot upload validation failed`,

View file

@ -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 },

View file

@ -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:

View file

@ -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();
});

View file

@ -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<string, unknown> =>
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<T> =
| { success: true; data: T }
| { success: false; error: { issues: ValidationIssue[] } };
type SafeParseSchema<T> = {
safeParse: (data: unknown) => SafeParseResult<T>;
};
const formatIssuePath = (path: readonly PropertyKey[]): string =>
path.length > 0 ? `.${path.map((segment) => String(segment)).join('.')}` : '';
const parseResponse = <T>(
schema: SafeParseSchema<T>,
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');