mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-28 10:13:52 +00:00
fix(sync): handle singleton entity LWW updates in lwwUpdateMetaReducer
Singleton entities (GLOBAL_CONFIG, TIME_TRACKING) were silently dropped by lwwUpdateMetaReducer because it only handled adapter entities. Add a singleton branch that replaces the entire feature state with the winning LWW data, and use devError for programming error paths.
This commit is contained in:
parent
181665ee7b
commit
91e5011f59
3 changed files with 399 additions and 13 deletions
241
e2e/tests/sync/supersync-lww-singleton.spec.ts
Normal file
241
e2e/tests/sync/supersync-lww-singleton.spec.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
/**
|
||||
* SuperSync LWW Singleton Entity Conflict Resolution E2E Tests
|
||||
*
|
||||
* Tests that singleton entities (GLOBAL_CONFIG) correctly resolve conflicts
|
||||
* via LWW when the lwwUpdateMetaReducer handles them.
|
||||
*
|
||||
* Before the fix: singleton LWW Update actions were silently dropped because
|
||||
* lwwUpdateMetaReducer only handled adapter entities.
|
||||
*
|
||||
* After the fix: singleton entities replace the entire feature state with the
|
||||
* winning LWW data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Navigate to Misc Settings section in the config page (General tab)
|
||||
* and expand it if collapsed.
|
||||
*
|
||||
* When `forceReload` is true, navigates away first then back to ensure the
|
||||
* OnPush config component is fully re-created with fresh store data.
|
||||
*/
|
||||
const navigateToMiscSettings = async (page: Page, forceReload = false): Promise<void> => {
|
||||
if (forceReload) {
|
||||
// Navigate away first to destroy the config component, then back
|
||||
await page.goto('/#/tag/TODAY/tasks');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await page.goto('/#/config');
|
||||
await page.waitForURL(/config/);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// "Misc Settings" is a collapsible section in the General tab (default tab).
|
||||
const miscCollapsible = page.locator(
|
||||
'collapsible:has(.collapsible-title:has-text("Misc"))',
|
||||
);
|
||||
await miscCollapsible.scrollIntoViewIfNeeded();
|
||||
await miscCollapsible.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Expand if collapsed (host element gets .isExpanded class when expanded)
|
||||
const isExpanded = await miscCollapsible.evaluate((el: Element) =>
|
||||
el.classList.contains('isExpanded'),
|
||||
);
|
||||
if (!isExpanded) {
|
||||
await miscCollapsible.locator('.collapsible-header').click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a slide-toggle setting by its label text.
|
||||
*/
|
||||
const toggleSetting = async (page: Page, labelText: string): Promise<void> => {
|
||||
const toggle = page
|
||||
.locator('mat-slide-toggle, mat-checkbox')
|
||||
.filter({ hasText: labelText })
|
||||
.first();
|
||||
await toggle.scrollIntoViewIfNeeded();
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a slide-toggle is currently checked (ON).
|
||||
*/
|
||||
const isSettingChecked = async (page: Page, labelText: string): Promise<boolean> => {
|
||||
const toggle = page
|
||||
.locator('mat-slide-toggle, mat-checkbox')
|
||||
.filter({ hasText: labelText })
|
||||
.first();
|
||||
await toggle.scrollIntoViewIfNeeded();
|
||||
// mat-slide-toggle adds 'mat-mdc-slide-toggle-checked' when checked
|
||||
// mat-checkbox adds 'mat-mdc-checkbox-checked' when checked
|
||||
const classes = (await toggle.getAttribute('class')) ?? '';
|
||||
return classes.includes('checked');
|
||||
};
|
||||
|
||||
test.describe('@supersync SuperSync LWW Singleton Conflict Resolution', () => {
|
||||
/**
|
||||
* Scenario: LWW Singleton Global Config Conflict Resolution
|
||||
*
|
||||
* Tests that when two clients make concurrent changes to globalConfig,
|
||||
* LWW correctly resolves the conflict and all clients converge to the
|
||||
* winning state via the lwwUpdateMetaReducer singleton fix.
|
||||
*
|
||||
* Key insight: For LOCAL to win the LWW conflict (and trigger creation of
|
||||
* a `[GLOBAL_CONFIG] LWW Update` operation), the client with the later
|
||||
* timestamp must sync AFTER the earlier client's op is already on the server.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Client A + Client B set up with SuperSync
|
||||
* 2. Client A toggles both settings ON, syncs
|
||||
* 3. Client B syncs (downloads config), verifies both toggles are ON
|
||||
* 4. Client B toggles "Disable all animations" OFF (earlier timestamp)
|
||||
* 5. Client B syncs first → uploads B's change to server
|
||||
* 6. Wait for timestamp gap
|
||||
* 7. Client A toggles "Disable celebration" OFF (later timestamp)
|
||||
* 8. Client A syncs → upload conflicts with B's server op
|
||||
* → LOCAL A wins (later timestamp) → creates [GLOBAL_CONFIG] LWW Update
|
||||
* 9. Client A syncs again → uploads LWW Update to server
|
||||
* 10. Client B syncs → downloads LWW Update → lwwUpdateMetaReducer applies it
|
||||
* 11. Verify both clients converge to A's winning state
|
||||
*/
|
||||
test('LWW: Global config singleton conflict resolves correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const appUrl = baseURL || 'http://localhost:4242';
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// Setup clients
|
||||
clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Converge after setup: both setupSuperSync calls create GLOBAL_CONFIG:sync
|
||||
// operations that may conflict. Resolve them now so the globalConfig is stable
|
||||
// before we test the misc settings conflict.
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[LWW-Singleton] Setup convergence complete');
|
||||
|
||||
// 2. Client A navigates to Misc settings and toggles both settings ON
|
||||
await navigateToMiscSettings(clientA.page);
|
||||
await toggleSetting(clientA.page, 'Disable all animations');
|
||||
await toggleSetting(clientA.page, 'Disable celebration');
|
||||
console.log('[LWW-Singleton] Client A toggled both settings ON');
|
||||
|
||||
// Wait for state to settle then sync
|
||||
await clientA.page.waitForTimeout(500);
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[LWW-Singleton] Client A synced (initial)');
|
||||
|
||||
// 3. Client B syncs to get the config, then verify UI
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[LWW-Singleton] Client B synced (download)');
|
||||
|
||||
await navigateToMiscSettings(clientB.page, true);
|
||||
const bAnimAfterSync = await isSettingChecked(
|
||||
clientB.page,
|
||||
'Disable all animations',
|
||||
);
|
||||
const bCelebAfterSync = await isSettingChecked(clientB.page, 'Disable celebration');
|
||||
expect(bAnimAfterSync).toBe(true);
|
||||
expect(bCelebAfterSync).toBe(true);
|
||||
console.log('[LWW-Singleton] Client B confirmed both settings ON');
|
||||
|
||||
// 4. Client B toggles "Disable all animations" OFF (earlier timestamp)
|
||||
await toggleSetting(clientB.page, 'Disable all animations');
|
||||
await clientB.page.waitForTimeout(500);
|
||||
console.log(
|
||||
'[LWW-Singleton] Client B toggled animations OFF (B: anim=OFF, celeb=ON)',
|
||||
);
|
||||
|
||||
// 5. Client B syncs FIRST → uploads B's change to server
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[LWW-Singleton] Client B synced (uploaded change)');
|
||||
|
||||
// 6. Wait for timestamp gap to ensure A's change has a later timestamp
|
||||
await clientA.page.waitForTimeout(2000);
|
||||
|
||||
// 7. Client A toggles "Disable celebration" OFF (later timestamp)
|
||||
await toggleSetting(clientA.page, 'Disable celebration');
|
||||
await clientA.page.waitForTimeout(500);
|
||||
console.log(
|
||||
'[LWW-Singleton] Client A toggled celebration OFF (A: anim=ON, celeb=OFF)',
|
||||
);
|
||||
|
||||
// 8. Client A syncs → upload conflicts with B's op on server
|
||||
// LOCAL A wins (later timestamp) → creates [GLOBAL_CONFIG] LWW Update
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(
|
||||
'[LWW-Singleton] Client A synced (conflict → LOCAL wins → LWW Update created)',
|
||||
);
|
||||
|
||||
// 9. Client A syncs again → uploads LWW Update to server
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[LWW-Singleton] Client A synced (LWW Update uploaded)');
|
||||
|
||||
// 10. Client B syncs → downloads LWW Update → meta-reducer applies it
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[LWW-Singleton] Client B synced (LWW Update applied)');
|
||||
|
||||
// Extra convergence round
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[LWW-Singleton] Final convergence sync complete');
|
||||
|
||||
// 11. Verify both clients show the same winning state.
|
||||
// A's state should win (later timestamp): anim=ON, celeb=OFF
|
||||
// Force reload to re-create the OnPush config component with fresh store data.
|
||||
await navigateToMiscSettings(clientA.page, true);
|
||||
const aAnimFinal = await isSettingChecked(clientA.page, 'Disable all animations');
|
||||
const aCelebFinal = await isSettingChecked(clientA.page, 'Disable celebration');
|
||||
|
||||
await navigateToMiscSettings(clientB.page, true);
|
||||
const bAnimFinal = await isSettingChecked(clientB.page, 'Disable all animations');
|
||||
const bCelebFinal = await isSettingChecked(clientB.page, 'Disable celebration');
|
||||
|
||||
console.log(
|
||||
`[LWW-Singleton] A: anim=${aAnimFinal}, celeb=${aCelebFinal}` +
|
||||
` | B: anim=${bAnimFinal}, celeb=${bCelebFinal}`,
|
||||
);
|
||||
|
||||
// Both clients should have converged to the same state
|
||||
expect(aAnimFinal).toBe(bAnimFinal);
|
||||
expect(aCelebFinal).toBe(bCelebFinal);
|
||||
|
||||
// The winning state should be A's state: animations ON, celebration OFF
|
||||
expect(aAnimFinal).toBe(true);
|
||||
expect(aCelebFinal).toBe(false);
|
||||
expect(bAnimFinal).toBe(true);
|
||||
expect(bCelebFinal).toBe(false);
|
||||
|
||||
console.log(
|
||||
'[LWW-Singleton] Global config singleton conflict resolved correctly via LWW',
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -313,12 +313,131 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
};
|
||||
|
||||
spyOn(OpLog, 'warn');
|
||||
reducer(state, action);
|
||||
// devError throws in non-production; catch it so we can verify the warning
|
||||
try {
|
||||
reducer(state, action);
|
||||
} catch {
|
||||
// Expected: devError throws in test environment
|
||||
}
|
||||
|
||||
expect(OpLog.warn).toHaveBeenCalledWith(
|
||||
jasmine.stringMatching(/Unknown or non-adapter entity type: UNKNOWN_ENTITY/),
|
||||
jasmine.stringMatching(/Unknown entity type: UNKNOWN_ENTITY/),
|
||||
);
|
||||
expect(mockReducer).toHaveBeenCalledWith(state, action);
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton entity LWW Updates', () => {
|
||||
const createMockStateWithSingletons = (): Partial<RootState> =>
|
||||
({
|
||||
...createMockState(),
|
||||
globalConfig: {
|
||||
misc: {
|
||||
isDisableAnimations: false,
|
||||
isDisableCelebration: false,
|
||||
},
|
||||
sound: { isEnabled: true },
|
||||
},
|
||||
timeTracking: {
|
||||
currentSessionTime: 0,
|
||||
isTrackingReminder: true,
|
||||
},
|
||||
}) as unknown as Partial<RootState>;
|
||||
|
||||
it('should replace globalConfig state with LWW winning data', () => {
|
||||
const state = createMockStateWithSingletons();
|
||||
const action = {
|
||||
type: '[GLOBAL_CONFIG] LWW Update',
|
||||
misc: {
|
||||
isDisableAnimations: true,
|
||||
isDisableCelebration: true,
|
||||
},
|
||||
sound: { isEnabled: false },
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'GLOBAL_CONFIG',
|
||||
isRemote: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
expect(mockReducer).toHaveBeenCalled();
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const globalConfig = updatedState['globalConfig'] as Record<string, unknown>;
|
||||
expect(globalConfig).toBeDefined();
|
||||
expect(
|
||||
(globalConfig['misc'] as Record<string, unknown>)['isDisableAnimations'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(globalConfig['misc'] as Record<string, unknown>)['isDisableCelebration'],
|
||||
).toBe(true);
|
||||
expect((globalConfig['sound'] as Record<string, unknown>)['isEnabled']).toBe(false);
|
||||
});
|
||||
|
||||
it('should replace timeTracking state with LWW winning data', () => {
|
||||
const state = createMockStateWithSingletons();
|
||||
const action = {
|
||||
type: '[TIME_TRACKING] LWW Update',
|
||||
currentSessionTime: 5000,
|
||||
isTrackingReminder: false,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TIME_TRACKING',
|
||||
isRemote: true,
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
expect(mockReducer).toHaveBeenCalled();
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const timeTracking = updatedState['timeTracking'] as Record<string, unknown>;
|
||||
expect(timeTracking).toBeDefined();
|
||||
expect(timeTracking['currentSessionTime']).toBe(5000);
|
||||
expect(timeTracking['isTrackingReminder']).toBe(false);
|
||||
});
|
||||
|
||||
it('should not require id field for singleton entities', () => {
|
||||
const state = createMockStateWithSingletons();
|
||||
const action = {
|
||||
type: '[GLOBAL_CONFIG] LWW Update',
|
||||
misc: { isDisableAnimations: true },
|
||||
meta: { isPersistent: true, entityType: 'GLOBAL_CONFIG', isRemote: true },
|
||||
};
|
||||
|
||||
spyOn(OpLog, 'warn');
|
||||
reducer(state, action);
|
||||
|
||||
expect(OpLog.warn).not.toHaveBeenCalledWith(
|
||||
jasmine.stringMatching(/Entity data has no id/),
|
||||
);
|
||||
expect(mockReducer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should strip type and meta from applied singleton state', () => {
|
||||
const state = createMockStateWithSingletons();
|
||||
const action = {
|
||||
type: '[GLOBAL_CONFIG] LWW Update',
|
||||
misc: { isDisableAnimations: true },
|
||||
meta: { isPersistent: true, entityType: 'GLOBAL_CONFIG', isRemote: true },
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
expect(mockReducer).toHaveBeenCalled();
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const globalConfig = updatedState['globalConfig'] as Record<string, unknown>;
|
||||
expect(globalConfig['type']).toBeUndefined();
|
||||
expect(globalConfig['meta']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import { Action, ActionReducer, MetaReducer } from '@ngrx/store';
|
|||
import { EntityAdapter } from '@ngrx/entity';
|
||||
import { RootState } from '../../root-state';
|
||||
import { EntityType } from '../../../op-log/core/operation.types';
|
||||
import { getEntityConfig, isAdapterEntity } from '../../../op-log/core/entity-registry';
|
||||
import {
|
||||
getEntityConfig,
|
||||
isAdapterEntity,
|
||||
isSingletonEntity,
|
||||
} from '../../../op-log/core/entity-registry';
|
||||
import { devError } from '../../../util/dev-error';
|
||||
import {
|
||||
PROJECT_FEATURE_NAME,
|
||||
projectAdapter,
|
||||
|
|
@ -377,18 +382,16 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
const entityType = match[1] as EntityType;
|
||||
const config = getEntityConfig(entityType);
|
||||
|
||||
if (!config || !isAdapterEntity(config)) {
|
||||
OpLog.warn(
|
||||
`lwwUpdateMetaReducer: Unknown or non-adapter entity type: ${entityType}`,
|
||||
);
|
||||
if (!config) {
|
||||
OpLog.warn(`lwwUpdateMetaReducer: Unknown entity type: ${entityType}`);
|
||||
devError(`lwwUpdateMetaReducer: Unknown entity type: ${entityType}`);
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
const { featureName, adapter } = config;
|
||||
if (!featureName || !adapter) {
|
||||
OpLog.warn(
|
||||
`lwwUpdateMetaReducer: Missing featureName or adapter for: ${entityType}`,
|
||||
);
|
||||
const { featureName } = config;
|
||||
if (!featureName) {
|
||||
OpLog.warn(`lwwUpdateMetaReducer: Missing featureName for: ${entityType}`);
|
||||
devError(`lwwUpdateMetaReducer: Missing featureName for: ${entityType}`);
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
|
|
@ -397,6 +400,7 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
|
||||
if (!featureState) {
|
||||
OpLog.warn(`lwwUpdateMetaReducer: Feature state not found: ${featureName}`);
|
||||
devError(`lwwUpdateMetaReducer: Feature state not found: ${featureName}`);
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
|
|
@ -409,6 +413,28 @@ export const lwwUpdateMetaReducer: MetaReducer = (
|
|||
}
|
||||
}
|
||||
|
||||
// Singleton entities: replace entire feature state with the winning data
|
||||
if (isSingletonEntity(config)) {
|
||||
const updatedState: RootState = {
|
||||
...rootState,
|
||||
[featureName]: { ...entityData },
|
||||
};
|
||||
return reducer(updatedState, action);
|
||||
}
|
||||
|
||||
if (!isAdapterEntity(config)) {
|
||||
OpLog.warn(`lwwUpdateMetaReducer: Unsupported storage pattern for: ${entityType}`);
|
||||
devError(`lwwUpdateMetaReducer: Unsupported storage pattern for: ${entityType}`);
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
const { adapter } = config;
|
||||
if (!adapter) {
|
||||
OpLog.warn(`lwwUpdateMetaReducer: Missing adapter for: ${entityType}`);
|
||||
devError(`lwwUpdateMetaReducer: Missing adapter for: ${entityType}`);
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
if (!entityData['id']) {
|
||||
OpLog.warn('lwwUpdateMetaReducer: Entity data has no id');
|
||||
return reducer(state, action);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue