mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(sync): heal missing idle config field on schema-migration upgrade (#9124)
A v18.14 snapshot (schema v2) lacks globalConfig.idle.isSuppressIdleDuring FocusMode, which #8965 added as a required field without its own schema bump. Upgrading bumps the snapshot onto the v2->v4 migration path, whose validation gate — uniquely, the only validator that runs on the RAW snapshot before the loadAllData reducer can backfill defaults, and the only one that is fatal rather than repair-or-tolerate — rejects it. Hydration then aborts, recovery refuses because a snapshot still exists, and the app boots to an empty store, deterministically, every launch. Two fixes: - v2->v3 migration backfills the opt-in default (false) when the field is not already a boolean (never clobbering a real user choice), so the migrated snapshot validates and a clean v4 snapshot persists immediately. - The migration-path state-validation gate is now non-fatal: on failure it rolls the on-disk cache back to the pre-migration backup (never persisting an unvalidated snapshot) but returns the migrated snapshot for reducer-healed hydration, instead of throwing into disaster recovery. Genuinely corrupt state is still caught at Checkpoint C and not persisted. Metadata-validation failures remain fatal. Both paths are sabotage-tested to fail without their fix. A known follow-up (noted in code) can persist a fresh snapshot after any migration-then-valid hydration so the safety-net path converges in one boot instead of re-migrating each launch for a not-yet-backfilled field.
This commit is contained in:
parent
f15a20ba8d
commit
d0b7bda01a
4 changed files with 167 additions and 14 deletions
|
|
@ -1,7 +1,11 @@
|
|||
import type { SchemaMigration } from '../migration.types';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
/**
|
||||
* Compatibility barrier for synthetic LWW replacement payloads.
|
||||
* Compatibility barrier for synthetic LWW replacement payloads, plus an idle
|
||||
* config backfill.
|
||||
*
|
||||
* Schema v3 introduces replacement-mode LWW envelopes whose omitted fields
|
||||
* are intentionally cleared. NOTE: this stamp does NOT stop released clients.
|
||||
|
|
@ -13,11 +17,45 @@ import type { SchemaMigration } from '../migration.types';
|
|||
* post-v18.14.0 receivers only, which block any newer-schema op outright. See the
|
||||
* bump policy in schema-version.ts before relying on a version fence.
|
||||
* Historical v2 operations retain their patch semantics when migrated.
|
||||
*
|
||||
* State transform: v2 snapshots (v18.14 and earlier) can lack
|
||||
* `globalConfig.idle.isSuppressIdleDuringFocusMode`, which #8965 added as a
|
||||
* required field WITHOUT its own schema bump. The v4 state validator requires
|
||||
* it, and the migration path validates the raw snapshot BEFORE the loadAllData
|
||||
* reducer can fill its default — so on upgrade the whole snapshot fails
|
||||
* validation and hydration aborts. Backfill the opt-in default (false) here to
|
||||
* match DEFAULT_GLOBAL_CONFIG.idle, but only when the field is not already a
|
||||
* boolean so an existing user choice is never clobbered. A missing `idle`
|
||||
* section is left to the reducer's own default merge.
|
||||
*
|
||||
* The hardcoded `false` is intentionally decoupled from DEFAULT_GLOBAL_CONFIG:
|
||||
* a migration must freeze the historical default even if the live default later
|
||||
* changes (and shared-schema cannot import app code).
|
||||
*/
|
||||
export const LwwReplacementBarrierMigration_v2v3: SchemaMigration = {
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
description: 'Gate replacement-mode LWW operations from older clients.',
|
||||
migrateState: (state: unknown): unknown => state,
|
||||
description:
|
||||
'Gate replacement-mode LWW operations; backfill idle.isSuppressIdleDuringFocusMode default.',
|
||||
migrateState: (state: unknown): unknown => {
|
||||
if (!isRecord(state)) {
|
||||
return state;
|
||||
}
|
||||
const globalConfig = state.globalConfig;
|
||||
if (!isRecord(globalConfig)) {
|
||||
return state;
|
||||
}
|
||||
const idle = globalConfig.idle;
|
||||
if (!isRecord(idle) || typeof idle.isSuppressIdleDuringFocusMode === 'boolean') {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
globalConfig: {
|
||||
...globalConfig,
|
||||
idle: { ...idle, isSuppressIdleDuringFocusMode: false },
|
||||
},
|
||||
};
|
||||
},
|
||||
requiresOperationMigration: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ describe('LWW replacement compatibility barrier v2 -> v3', () => {
|
|||
expect(LwwReplacementBarrierMigration_v2v3.toVersion).toBe(3);
|
||||
});
|
||||
|
||||
it('leaves v2 state data unchanged', () => {
|
||||
it('leaves state without a globalConfig unchanged', () => {
|
||||
const state = { task: { ids: ['task-1'] } };
|
||||
|
||||
const result = migrateState(state, 2, 3);
|
||||
|
|
@ -20,6 +20,81 @@ describe('LWW replacement compatibility barrier v2 -> v3', () => {
|
|||
expect(result.data).toBe(state);
|
||||
});
|
||||
|
||||
it('backfills the missing isSuppressIdleDuringFocusMode default (#8965)', () => {
|
||||
const state = {
|
||||
task: { ids: [] },
|
||||
globalConfig: {
|
||||
idle: {
|
||||
isEnableIdleTimeTracking: true,
|
||||
isOnlyOpenIdleWhenCurrentTask: false,
|
||||
minIdleTime: 5 * 60 * 1000,
|
||||
// isSuppressIdleDuringFocusMode absent (v18.14 snapshot shape)
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = migrateState(state, 2, 3);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const idle = (result.data as { globalConfig: { idle: Record<string, unknown> } })
|
||||
.globalConfig.idle;
|
||||
expect(idle.isSuppressIdleDuringFocusMode).toBe(false);
|
||||
// Existing fields preserved
|
||||
expect(idle.isEnableIdleTimeTracking).toBe(true);
|
||||
expect(idle.minIdleTime).toBe(5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('never clobbers an existing boolean isSuppressIdleDuringFocusMode value', () => {
|
||||
const state = {
|
||||
globalConfig: {
|
||||
idle: {
|
||||
isEnableIdleTimeTracking: true,
|
||||
isOnlyOpenIdleWhenCurrentTask: false,
|
||||
minIdleTime: 5 * 60 * 1000,
|
||||
isSuppressIdleDuringFocusMode: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = migrateState(state, 2, 3);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Identity when the field is already a boolean — same reference, user choice kept
|
||||
expect(result.data).toBe(state);
|
||||
});
|
||||
|
||||
it('backfills when the field is present but not a boolean (null/undefined)', () => {
|
||||
// A `null`/`undefined` value would otherwise survive the reducer's
|
||||
// `{ ...DEFAULT, ...idle }` merge and keep failing validation, so the guard
|
||||
// must treat non-boolean the same as absent.
|
||||
const state = {
|
||||
globalConfig: {
|
||||
idle: {
|
||||
isEnableIdleTimeTracking: true,
|
||||
isOnlyOpenIdleWhenCurrentTask: false,
|
||||
minIdleTime: 5 * 60 * 1000,
|
||||
isSuppressIdleDuringFocusMode: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = migrateState(state, 2, 3);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const idle = (result.data as { globalConfig: { idle: Record<string, unknown> } })
|
||||
.globalConfig.idle;
|
||||
expect(idle.isSuppressIdleDuringFocusMode).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves state whose globalConfig has no idle section unchanged', () => {
|
||||
const state = { globalConfig: { misc: {} } };
|
||||
|
||||
const result = migrateState(state, 2, 3);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBe(state);
|
||||
});
|
||||
|
||||
it('preserves historical v2 operation semantics while stamping schema v3', () => {
|
||||
const operation: OperationLike = {
|
||||
id: 'legacy-lww',
|
||||
|
|
|
|||
|
|
@ -703,7 +703,12 @@ describe('OperationLogSnapshotService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should restore backup and not save when migrated state fails validation', async () => {
|
||||
it('should restore backup, not save, but still return the migrated snapshot when state fails validation', async () => {
|
||||
// Non-fatal path: a validation failure here is often reducer-healable (e.g.
|
||||
// an older snapshot missing a newly-required config default). Bricking to
|
||||
// recovery would empty the store on upgrade, so we roll the on-disk cache
|
||||
// back to the backup, never persist the unvalidated snapshot, yet return it
|
||||
// for reducer-healed hydration (Checkpoint C re-checks and persists).
|
||||
const snapshot = createSnapshot();
|
||||
const migratedSnapshot = { ...snapshot, schemaVersion: CURRENT_SCHEMA_VERSION };
|
||||
mockOpLogStore.saveStateCacheBackup.and.resolveTo(undefined);
|
||||
|
|
@ -714,10 +719,9 @@ describe('OperationLogSnapshotService', () => {
|
|||
});
|
||||
mockOpLogStore.restoreStateCacheFromBackup.and.resolveTo(undefined);
|
||||
|
||||
await expectAsync(
|
||||
service.migrateSnapshotWithBackup(snapshot),
|
||||
).toBeRejectedWithError(/Migrated snapshot validation failed/);
|
||||
const result = await service.migrateSnapshotWithBackup(snapshot);
|
||||
|
||||
expect(result).toBe(migratedSnapshot);
|
||||
expect(mockOpLogStore.saveStateCache).not.toHaveBeenCalled();
|
||||
expect(mockOpLogStore.restoreStateCacheFromBackup).toHaveBeenCalled();
|
||||
expect(mockOpLogStore.clearStateCacheBackup).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -175,9 +175,14 @@ export class OperationLogSnapshotService {
|
|||
* Migrates a snapshot with backup safety (A.7.12).
|
||||
* Creates a backup before migration and restores it if migration fails.
|
||||
*
|
||||
* State-validation failures are non-fatal: the migrated snapshot is returned
|
||||
* for hydration (unpersisted) rather than throwing. See the step-4 note.
|
||||
*
|
||||
* @param snapshot - The snapshot to migrate
|
||||
* @returns The migrated snapshot
|
||||
* @throws If migration fails and rollback also fails
|
||||
* @throws On migration failure or metadata-validation failure. The backup is
|
||||
* rolled back first; if that rollback ALSO fails, a combined error is
|
||||
* thrown instead. State-validation failure does NOT throw (step 4).
|
||||
*/
|
||||
async migrateSnapshotWithBackup(snapshot: StateCache): Promise<StateCache> {
|
||||
OpLog.normal(
|
||||
|
|
@ -197,16 +202,47 @@ export class OperationLogSnapshotService {
|
|||
throw new Error('Migrated snapshot metadata validation failed');
|
||||
}
|
||||
|
||||
// 4. Validate migrated snapshot state before persisting or clearing the backup.
|
||||
// Otherwise an invalid current-schema cache could be trusted on next startup.
|
||||
// 4. Validate migrated snapshot state before PERSISTING it — an invalid
|
||||
// current-schema cache must not be trusted on next startup (Checkpoint B
|
||||
// skips validation for a matching schema version). But a validation
|
||||
// failure here is NOT fatal to hydration: many "invalid" shapes are
|
||||
// reducer-healable — e.g. a snapshot from an older release missing a
|
||||
// newly-required config field the loadAllData reducer backfills by
|
||||
// default. Bailing to disaster recovery in that case bricks the app to an
|
||||
// empty store on upgrade, even though the data is fine. So on failure we
|
||||
// roll the on-disk cache back to the pre-migration backup (never persist
|
||||
// the unvalidated migrated snapshot) yet still RETURN it, letting the
|
||||
// hydrator load it through the reducer (which heals defaults) and boot
|
||||
// with correct data.
|
||||
//
|
||||
// Caveat: this non-fatal path does NOT itself persist a clean snapshot.
|
||||
// Re-persist only happens if the hydrator's post-replay Checkpoint C runs
|
||||
// and its save gate is met (tail ops present, not a full-state-op load,
|
||||
// >10 replayed ops) — otherwise the on-disk cache stays at the old version
|
||||
// and the migration re-runs every boot (correct, but a startup tax). For
|
||||
// fields with a migration backfill (below) validation passes and step 5
|
||||
// persists a clean snapshot immediately, so that path is the real fix;
|
||||
// this branch is the safety net for the not-yet-backfilled case.
|
||||
// TODO(followup): persist a fresh snapshot after a migration ran whenever
|
||||
// Checkpoint C validates, regardless of tail-op count, to converge in one
|
||||
// boot instead of every boot.
|
||||
const validationResult = await this.validateStateService.validateState(
|
||||
migratedSnapshot.state as Record<string, unknown>,
|
||||
);
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(
|
||||
`Migrated snapshot validation failed (${validationResult.typiaErrors.length} typia errors` +
|
||||
`${validationResult.crossModelError ? `, cross-model: ${validationResult.crossModelError}` : ''})`,
|
||||
OpLog.warn(
|
||||
'OperationLogSnapshotService: Migrated snapshot failed validation — ' +
|
||||
'restoring backup and hydrating unpersisted so the reducer can heal it.',
|
||||
{
|
||||
typiaErrorCount: validationResult.typiaErrors.length,
|
||||
crossModelError: validationResult.crossModelError,
|
||||
},
|
||||
);
|
||||
await this.opLogStore.restoreStateCacheFromBackup();
|
||||
OpLog.normal(
|
||||
'OperationLogSnapshotService: Backup restored; returning migrated snapshot for reducer-healed hydration.',
|
||||
);
|
||||
return migratedSnapshot;
|
||||
}
|
||||
|
||||
// 5. Save migrated snapshot
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue