mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
69fb432893
commit
2d26228ca8
4 changed files with 302 additions and 14 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
188
src/app/op-log/core/operation.types.spec.ts
Normal file
188
src/app/op-log/core/operation.types.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue