fix(sync): standardize full-state payload format and add validation

- Fix BackupService to use unwrapped payload format (was wrapping in
  { appDataComplete: ... } while SyncHydrationService used unwrapped)
- Add shared payload utilities to operation.types.ts:
  - FULL_STATE_OP_TYPES set for SyncImport, BackupImport, Repair
  - extractFullStateFromPayload() handles both formats
  - assertValidFullStatePayload() for runtime validation
- Update upload service to use shared utilities and validate payloads
- Add comprehensive unit tests for payload format consistency
This commit is contained in:
Johannes Millan 2026-01-08 18:39:08 +01:00
parent 69fb432893
commit 2d26228ca8
4 changed files with 302 additions and 14 deletions

View file

@ -179,7 +179,7 @@ export class BackupService {
opType: OpType.SyncImport,
entityType: 'ALL',
entityId: opId,
payload: { appDataComplete: importedData },
payload: importedData,
clientId,
vectorClock: newClock,
timestamp: Date.now(),

View file

@ -0,0 +1,188 @@
import {
OpType,
FULL_STATE_OP_TYPES,
isFullStateOpType,
isWrappedFullStatePayload,
extractFullStateFromPayload,
assertValidFullStatePayload,
} from './operation.types';
describe('operation.types full-state payload utilities', () => {
// Sample valid application state
const validState = {
task: { ids: ['t1'], entities: { t1: { id: 't1', title: 'Test' } } },
project: { ids: ['p1'], entities: { p1: { id: 'p1', title: 'Project' } } },
tag: { ids: [], entities: {} },
globalConfig: { sync: { isEnabled: false } },
};
describe('FULL_STATE_OP_TYPES', () => {
it('should contain SyncImport, BackupImport, and Repair', () => {
expect(FULL_STATE_OP_TYPES.has(OpType.SyncImport)).toBe(true);
expect(FULL_STATE_OP_TYPES.has(OpType.BackupImport)).toBe(true);
expect(FULL_STATE_OP_TYPES.has(OpType.Repair)).toBe(true);
});
it('should NOT contain regular operation types', () => {
expect(FULL_STATE_OP_TYPES.has(OpType.Create)).toBe(false);
expect(FULL_STATE_OP_TYPES.has(OpType.Update)).toBe(false);
expect(FULL_STATE_OP_TYPES.has(OpType.Delete)).toBe(false);
expect(FULL_STATE_OP_TYPES.has(OpType.Move)).toBe(false);
expect(FULL_STATE_OP_TYPES.has(OpType.Batch)).toBe(false);
});
});
describe('isFullStateOpType', () => {
it('should return true for full-state op types', () => {
expect(isFullStateOpType(OpType.SyncImport)).toBe(true);
expect(isFullStateOpType(OpType.BackupImport)).toBe(true);
expect(isFullStateOpType(OpType.Repair)).toBe(true);
});
it('should return false for regular op types', () => {
expect(isFullStateOpType(OpType.Create)).toBe(false);
expect(isFullStateOpType(OpType.Update)).toBe(false);
expect(isFullStateOpType(OpType.Delete)).toBe(false);
});
it('should handle string values', () => {
expect(isFullStateOpType('SYNC_IMPORT')).toBe(true);
expect(isFullStateOpType('CRT')).toBe(false);
});
});
describe('isWrappedFullStatePayload', () => {
it('should return true for wrapped payload format', () => {
const wrapped = { appDataComplete: validState };
expect(isWrappedFullStatePayload(wrapped)).toBe(true);
});
it('should return false for unwrapped payload format', () => {
expect(isWrappedFullStatePayload(validState)).toBe(false);
});
it('should return false for null/undefined', () => {
expect(isWrappedFullStatePayload(null)).toBe(false);
expect(isWrappedFullStatePayload(undefined)).toBe(false);
});
it('should return false for non-object payloads', () => {
expect(isWrappedFullStatePayload('string')).toBe(false);
expect(isWrappedFullStatePayload(123)).toBe(false);
expect(isWrappedFullStatePayload([])).toBe(false);
});
it('should return false for object with non-object appDataComplete', () => {
expect(isWrappedFullStatePayload({ appDataComplete: 'string' })).toBe(false);
expect(isWrappedFullStatePayload({ appDataComplete: null })).toBe(false);
});
});
describe('extractFullStateFromPayload', () => {
it('should unwrap wrapped payload format', () => {
const wrapped = { appDataComplete: validState };
const result = extractFullStateFromPayload(wrapped);
expect(result).toEqual(validState);
});
it('should return unwrapped payload as-is', () => {
const result = extractFullStateFromPayload(validState);
expect(result).toEqual(validState);
});
it('should handle deeply nested appDataComplete correctly', () => {
// This is the bug case: wrapped payload with state that also has some key
const wrapped = { appDataComplete: validState };
const result = extractFullStateFromPayload(wrapped);
// Should have task, project, tag, globalConfig at top level
expect('task' in result).toBe(true);
expect('project' in result).toBe(true);
// Should NOT have appDataComplete at top level
expect('appDataComplete' in result).toBe(false);
});
});
describe('assertValidFullStatePayload', () => {
it('should not throw for valid unwrapped state', () => {
expect(() => {
assertValidFullStatePayload(validState, 'test');
}).not.toThrow();
});
it('should not throw for valid wrapped state', () => {
const wrapped = { appDataComplete: validState };
expect(() => {
assertValidFullStatePayload(wrapped, 'test');
}).not.toThrow();
});
it('should throw for null payload', () => {
expect(() => {
assertValidFullStatePayload(null, 'test');
}).toThrowError(/Invalid full-state payload: expected object, got object/);
});
it('should throw for string payload', () => {
expect(() => {
assertValidFullStatePayload('not an object', 'test');
}).toThrowError(/Invalid full-state payload: expected object, got string/);
});
it('should throw for payload missing expected keys', () => {
const invalidState = { someOtherKey: 'value', anotherKey: 123 };
expect(() => {
assertValidFullStatePayload(invalidState, 'test');
}).toThrowError(/Invalid full-state payload: missing expected keys/);
});
it('should include context in error message', () => {
expect(() => {
assertValidFullStatePayload(null, 'MyService.myMethod');
}).toThrowError(/MyService\.myMethod/);
});
it('should pass for state with only some expected keys', () => {
// Payload only needs SOME of the expected keys, not all
const partialState = { task: { ids: [], entities: {} } };
expect(() => {
assertValidFullStatePayload(partialState, 'test');
}).not.toThrow();
});
});
describe('payload consistency between services', () => {
/**
* This test documents the expected payload format for full-state operations.
* Both BackupService and SyncHydrationService should use the UNWRAPPED format.
*
* The bug we're preventing:
* - BackupService was using: { appDataComplete: state }
* - SyncHydrationService was using: state (unwrapped)
* - This caused upload service to upload the wrapper, not the state
*/
it('should handle the unwrapped format (current standard)', () => {
// This is what SyncHydrationService and fixed BackupService produce
const unwrappedPayload = validState;
// extractFullStateFromPayload should return it as-is
const extracted = extractFullStateFromPayload(unwrappedPayload);
expect(extracted).toEqual(validState);
// Validation should pass
expect(() => assertValidFullStatePayload(extracted, 'test')).not.toThrow();
});
it('should still handle legacy wrapped format for backwards compatibility', () => {
// Old BackupService format - should still work via extractFullStateFromPayload
const wrappedPayload = { appDataComplete: validState };
// extractFullStateFromPayload should unwrap it
const extracted = extractFullStateFromPayload(wrappedPayload);
expect(extracted).toEqual(validState);
// Validation should pass
expect(() => assertValidFullStatePayload(extracted, 'test')).not.toThrow();
});
});
});

View file

@ -190,6 +190,100 @@ export interface RepairPayload {
repairSummary: RepairSummary;
}
// =============================================================================
// FULL-STATE OPERATION PAYLOADS
// =============================================================================
/**
* OpTypes that contain full application state in their payload.
* Used for type guards and validation.
*/
export const FULL_STATE_OP_TYPES = new Set<OpType>([
OpType.SyncImport,
OpType.BackupImport,
OpType.Repair,
]);
/**
* Type guard to check if an operation is a full-state operation.
*/
export const isFullStateOpType = (opType: OpType | string): boolean =>
FULL_STATE_OP_TYPES.has(opType as OpType);
/**
* Legacy wrapper format for full-state payloads.
* Some older code wrapped the state in { appDataComplete: ... }.
* New code should use unwrapped format directly.
*/
export interface WrappedFullStatePayload {
appDataComplete: Record<string, unknown>;
}
/**
* Type guard to check if a payload is in the wrapped format.
*/
export const isWrappedFullStatePayload = (
payload: unknown,
): payload is WrappedFullStatePayload =>
typeof payload === 'object' &&
payload !== null &&
'appDataComplete' in payload &&
typeof (payload as WrappedFullStatePayload).appDataComplete === 'object' &&
(payload as WrappedFullStatePayload).appDataComplete !== null;
/**
* Extracts the raw application state from a full-state operation payload.
* Handles both wrapped ({ appDataComplete: ... }) and unwrapped formats.
*
* IMPORTANT: This should be used when uploading snapshots to ensure
* consistent format in sync files.
*
* @param payload - The operation payload (wrapped or unwrapped)
* @returns The raw application state
*/
export const extractFullStateFromPayload = (
payload: unknown,
): Record<string, unknown> => {
if (isWrappedFullStatePayload(payload)) {
return payload.appDataComplete;
}
// Unwrapped format - payload IS the state
return payload as Record<string, unknown>;
};
/**
* Validates that a full-state payload has the expected structure.
* Throws an error if the payload is malformed.
*
* @param payload - The payload to validate
* @param context - Description of where this is being called from (for error messages)
* @throws Error if payload is not a valid full-state payload
*/
export const assertValidFullStatePayload: (
payload: unknown,
context: string,
) => asserts payload is Record<string, unknown> = (payload, context) => {
const state = extractFullStateFromPayload(payload);
if (typeof state !== 'object' || state === null) {
throw new Error(
`[${context}] Invalid full-state payload: expected object, got ${typeof state}`,
);
}
// Check for expected top-level properties (not exhaustive, just key ones)
const expectedKeys = ['task', 'project', 'tag', 'globalConfig'];
const hasExpectedKeys = expectedKeys.some((key) => key in state);
if (!hasExpectedKeys) {
const actualKeys = Object.keys(state).slice(0, 5).join(', ');
throw new Error(
`[${context}] Invalid full-state payload: missing expected keys. ` +
`Expected some of [${expectedKeys.join(', ')}], got [${actualKeys}...]`,
);
}
};
// =============================================================================
// MULTI-ENTITY OPERATIONS
// =============================================================================

View file

@ -1,7 +1,14 @@
import { inject, Injectable } from '@angular/core';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import { LockService } from './lock.service';
import { Operation, OperationLogEntry, OpType } from '../core/operation.types';
import {
Operation,
OperationLogEntry,
OpType,
FULL_STATE_OP_TYPES,
extractFullStateFromPayload,
assertValidFullStatePayload,
} from '../core/operation.types';
import { OpLog } from '../../core/log';
import { LOCK_NAMES } from '../core/operation-log.const';
import { chunkArray } from '../../util/chunk-array';
@ -25,16 +32,6 @@ export type {
UploadOptions,
} from '../core/types/sync-results.types';
/**
* Operation types that contain full application state and should use
* the snapshot endpoint instead of the regular ops endpoint.
*/
const FULL_STATE_OP_TYPES = new Set([
OpType.SyncImport,
OpType.BackupImport,
OpType.Repair,
]);
/**
* Handles uploading local pending operations to remote storage.
*
@ -358,8 +355,17 @@ export class OperationLogUploadService {
`OperationLogUploadService: Uploading ${op.opType} operation via snapshot endpoint`,
);
// The payload for full-state ops IS the complete state
let state = op.payload;
// Extract state from payload, handling both wrapped and unwrapped formats.
// Uses shared utility to ensure consistent handling across the codebase.
let state: unknown = extractFullStateFromPayload(op.payload);
// Validate the payload structure before uploading to catch bugs early.
// This throws if the payload is malformed (e.g., missing expected keys).
assertValidFullStatePayload(
state,
'OperationLogUploadService._uploadFullStateOpAsSnapshot',
);
const isPayloadEncrypted = !!encryptKey;
// If encryption is enabled, encrypt the state