mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
refactor(sync): share SuperSync HTTP contract
This commit is contained in:
parent
730493d487
commit
60c0ba4e42
11 changed files with 489 additions and 231 deletions
3
package-lock.json
generated
3
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:tsc": "tsc",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
212
packages/shared-schema/src/supersync-http-contract.ts
Normal file
212
packages/shared-schema/src/supersync-http-contract.ts
Normal 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
|
||||
>;
|
||||
102
packages/shared-schema/tests/supersync-http-contract.spec.ts
Normal file
102
packages/shared-schema/tests/supersync-http-contract.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue