mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
Issue #7330 ("Data damage detected ... Repair attempted but failed") recurred on SIMPLE_COUNTER for users already on >= v18.6.0, where the original TASK-only fix didn't reach. Root cause is the same partial-LWW-recreate path: a concurrent delete-vs-update across devices resurrects a counter (LWW resolves local-delete + remote-update to 'remote'), and lwwUpdateMetaReducer recreated it from the {id}-only delete payload. Because SIMPLE_COUNTER had no RECREATE_FALLBACK entry, the recreated counter was missing required fields - most often `type`, an enum typia rejects and that dataRepair/autoFixTypiaErrors had no rule for - so post-sync validation dead-ended on the repair dialog every sync. Fix mirrors the TASK fix, two layers kept in lockstep by requiredKeys: - Register SIMPLE_COUNTER in RECREATE_FALLBACK (defaults from EMPTY_SIMPLE_COUNTER, type=ClickCounter) so the meta-reducer recreate path backfills required fields and the bad state is never created. - Add a simpleCounter.<id>.<field>===undefined branch to autoFixTypiaErrors to heal copies already corrupt on disk. Tests: auto-fix + meta-reducer unit specs (incl. a real-typia appDataValidators.simpleCounter proof), a full validate->repair->validate integration repro, and a deterministic SuperSync delete-vs-update e2e asserting no native repair dialog fires.
This commit is contained in:
parent
a9df055bc1
commit
44030bb06e
6 changed files with 527 additions and 11 deletions
|
|
@ -0,0 +1,201 @@
|
|||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
/**
|
||||
* SuperSync regression test for issue #7330 (recurrence on SIMPLE_COUNTER).
|
||||
*
|
||||
* Scenario from ruckusvol's logs (both devices ≥ v18.6.0): a simple counter is
|
||||
* deleted on one device while it is concurrently incremented on another. The
|
||||
* LWW rule resurrects the counter (update wins over a concurrent delete —
|
||||
* `suggestConflictResolution` returns 'remote' for local-delete-vs-remote-
|
||||
* update), so the deleting client recreates the entity from its `{id}`-only
|
||||
* delete payload. Before the fix that recreated counter was missing required
|
||||
* fields — most often `type`, which typia rejects and dataRepair could not
|
||||
* heal — so the deleting client dead-ended on a native "Data Cleanup Needed" /
|
||||
* "Repair attempted but failed" dialog after every sync.
|
||||
*
|
||||
* This test fails (the dialog fires) without the RECREATE_FALLBACK +
|
||||
* auto-fix-typia-errors changes and passes with them.
|
||||
*
|
||||
* NOTE: the data-damage prompts are NATIVE confirm()/alert() dialogs. The
|
||||
* default E2E handler only auto-dismisses devError dialogs, so this test
|
||||
* attaches its own listener to capture (and dismiss, to avoid a hang) any
|
||||
* repair/cleanup dialog and assert none fired.
|
||||
*/
|
||||
|
||||
const REPAIR_DIALOG_RE =
|
||||
/Repair attempted but failed|Data Cleanup Needed|automatic cleanup|references are inconsistent/i;
|
||||
|
||||
/**
|
||||
* Captures native data-repair/cleanup dialogs for one client. Returns the
|
||||
* collected messages array (asserted empty after sync convergence). Dismisses
|
||||
* the dialog so the blocked page can continue instead of hanging the test.
|
||||
*/
|
||||
const captureRepairDialogs = (client: SimulatedE2EClient): string[] => {
|
||||
const messages: string[] = [];
|
||||
client.page.on('dialog', async (dialog) => {
|
||||
if (!REPAIR_DIALOG_RE.test(dialog.message())) {
|
||||
// Not ours (e.g. a devError dialog handled by the default listener).
|
||||
return;
|
||||
}
|
||||
messages.push(dialog.message());
|
||||
try {
|
||||
await dialog.dismiss();
|
||||
} catch {
|
||||
// Already handled by another listener — ignore.
|
||||
}
|
||||
});
|
||||
return messages;
|
||||
};
|
||||
|
||||
const createClickCounter = async (
|
||||
client: SimulatedE2EClient,
|
||||
title: string,
|
||||
): Promise<void> => {
|
||||
await client.page.goto('/#/habits');
|
||||
await client.page.waitForURL(/habits/);
|
||||
|
||||
const addBtn = client.page.locator('.add-habit-btn');
|
||||
await addBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await addBtn.click();
|
||||
|
||||
const dialog = client.page.locator('dialog-simple-counter-edit-settings');
|
||||
await dialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
const titleInput = dialog.locator('formly-form input').first();
|
||||
await titleInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await titleInput.fill(title);
|
||||
|
||||
const typeSelect = dialog.locator('mat-select').first();
|
||||
await typeSelect.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await typeSelect.click();
|
||||
await client.page.locator('mat-option:has-text("Click Counter")').click();
|
||||
|
||||
await dialog.locator('button[type="submit"]').click();
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
|
||||
await client.page.goto('/#/tag/TODAY/tasks');
|
||||
await client.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
|
||||
};
|
||||
|
||||
/** Increment the (single) click counter shown in the header by one. */
|
||||
const incrementCounter = async (client: SimulatedE2EClient): Promise<void> => {
|
||||
const counter = client.page
|
||||
.locator(
|
||||
'.counters-action-group simple-counter-button, .mobile-dropdown simple-counter-button',
|
||||
)
|
||||
.first();
|
||||
await counter.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await counter.locator('.main-btn').click();
|
||||
};
|
||||
|
||||
/** Delete the counter titled `title` via its edit-settings dialog. */
|
||||
const deleteCounter = async (
|
||||
client: SimulatedE2EClient,
|
||||
title: string,
|
||||
): Promise<void> => {
|
||||
await client.page.goto('/#/habits');
|
||||
await client.page.waitForURL(/habits/);
|
||||
|
||||
const habitTitle = client.page.locator('.habit-title', { hasText: title }).first();
|
||||
await habitTitle.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await habitTitle.click();
|
||||
|
||||
const dialog = client.page.locator('dialog-simple-counter-edit-settings');
|
||||
await dialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
// The delete button is the first action button in the dialog.
|
||||
await dialog.locator('mat-dialog-actions button').first().click();
|
||||
|
||||
const confirmBtn = client.page.locator('dialog-confirm button[e2e="confirmBtn"]');
|
||||
await confirmBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await confirmBtn.click();
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
|
||||
await client.page.goto('/#/tag/TODAY/tasks');
|
||||
await client.page.waitForURL(/(active\/tasks|tag\/TODAY\/tasks)/);
|
||||
};
|
||||
|
||||
/** Number of counters visible in the header on this client. */
|
||||
const counterCount = async (client: SimulatedE2EClient): Promise<number> => {
|
||||
await client.page.waitForTimeout(500);
|
||||
return client.page
|
||||
.locator(
|
||||
'.counters-action-group simple-counter-button, .mobile-dropdown simple-counter-button',
|
||||
)
|
||||
.count();
|
||||
};
|
||||
|
||||
test.describe('@supersync Simple Counter delete-vs-update (#7330)', () => {
|
||||
test('concurrent delete + update resurrects a valid counter without a repair dialog', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
}) => {
|
||||
const counterTitle = `C7330-${Date.now()}`;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const syncConfig = getSuperSyncConfig(user);
|
||||
|
||||
// ===== Phase 1: A creates + seeds the counter, syncs to server =====
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
const repairDialogsA = captureRepairDialogs(clientA);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
await createClickCounter(clientA, counterTitle);
|
||||
await incrementCounter(clientA);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// ===== Phase 2: B downloads the counter =====
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
const repairDialogsB = captureRepairDialogs(clientB);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
await clientB.sync.syncAndWait();
|
||||
expect(await counterCount(clientB)).toBe(1);
|
||||
|
||||
// ===== Phase 3: concurrent edits (offline) =====
|
||||
// A deletes the counter; B increments it. Neither has synced yet.
|
||||
await deleteCounter(clientA, counterTitle);
|
||||
await incrementCounter(clientB);
|
||||
|
||||
// ===== Phase 4: B's update reaches the server first, then A pulls it =====
|
||||
// This guarantees A resolves local-DELETE vs remote-UPDATE → 'remote'
|
||||
// wins → A recreates the counter from its {id}-only delete payload (the
|
||||
// exact path that produced `type === undefined` in the report).
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// A re-uploads the resurrected counter; B converges on it.
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// ===== Phase 5: assertions =====
|
||||
// THE regression net: NO native data-repair/cleanup dialog fired. Before
|
||||
// the fix, A's recreated typeless counter fails post-sync validation and
|
||||
// pops the "Data Cleanup Needed" / "Repair attempted but failed" native
|
||||
// dialog; with the fix the recreated counter is valid, so nothing fires.
|
||||
expect(repairDialogsA).toEqual([]);
|
||||
expect(repairDialogsB).toEqual([]);
|
||||
|
||||
// No-data-loss sanity check on B: update wins over delete
|
||||
// (suggestConflictResolution → 'local' for B's local-update-vs-remote-
|
||||
// delete), and B never disabled the counter, so it stays visible in B's
|
||||
// header. NOTE: we deliberately do NOT assert A's header here — A's
|
||||
// counter is resurrected from a {id}-only payload, so it comes back
|
||||
// `isEnabled: false` and lives in the (collapsed) "Disabled Habits"
|
||||
// section, not the header. That disabled-resurrection is the documented
|
||||
// known limitation, not a failure.
|
||||
expect(await counterCount(clientB)).toBe(1);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -2,25 +2,46 @@ import { EntityType } from './operation.types';
|
|||
import { DEFAULT_TASK } from '../../features/tasks/task.model';
|
||||
import { DEFAULT_PROJECT, INBOX_PROJECT } from '../../features/project/project.const';
|
||||
import { DEFAULT_TAG } from '../../features/tag/tag.const';
|
||||
import { EMPTY_SIMPLE_COUNTER } from '../../features/simple-counter/simple-counter.const';
|
||||
|
||||
/**
|
||||
* Per-entity-type fallback used when an LWW Update recreates an entity that
|
||||
* was deleted locally (issue #7330). When the LWW payload is partial — e.g.
|
||||
* from `_convertToLWWUpdatesIfNeeded`'s fallback path or a local DELETE op
|
||||
* that carried only `{id}` — `defaults` fills in required fields so the
|
||||
* recreated entity passes Typia validation and the user does not dead-end on
|
||||
* the "Repair attempted but failed" dialog. `requiredKeys` drives both:
|
||||
* (a) the diagnostic warn in the meta-reducer (fires only for missing
|
||||
* schema-required fields) and (b) the auto-fix branch in
|
||||
* `auto-fix-typia-errors.ts` (covers exactly the same fields, sourcing
|
||||
* default values from `defaults` so the two layers cannot drift).
|
||||
* that carried only `{id}` — the recreated entity would otherwise fail Typia
|
||||
* validation and dead-end the user on the "Repair attempted but failed"
|
||||
* dialog. The two fields play distinct roles:
|
||||
*
|
||||
* Pairing both fields per type makes the lockstep invariant structural: a new
|
||||
* entity type added to the registry must declare both at once.
|
||||
* - `defaults` is the source of truth for the actual backfill. The
|
||||
* meta-reducer recreate path spreads the WHOLE object
|
||||
* (`{ ...defaults, ...nonNullPayloadFields }`), so every entity type listed
|
||||
* here gets drift-resistant recreate backfill for free, regardless of
|
||||
* `requiredKeys`.
|
||||
* - `requiredKeys` drives only (a) the meta-reducer's diagnostic warn (which
|
||||
* missing schema-required fields to name in the log) and (b) the per-type
|
||||
* on-disk heal branch in `auto-fix-typia-errors.ts`. Only TASK and
|
||||
* SIMPLE_COUNTER have such a branch today; PROJECT/TAG rely on the generic
|
||||
* recreate backfill alone, so their `requiredKeys` feed only the warn.
|
||||
* List the schema-required fields that are NOT already coerced by an
|
||||
* earlier generic branch in `autoFixTypiaErrors` (booleans → false,
|
||||
* nullable → null) — mirroring TASK's curated list.
|
||||
*
|
||||
* IMPORTANT: adding a new type here gives you the generic recreate backfill,
|
||||
* but the on-disk DEFENSE-IN-DEPTH heal stays absent until you ALSO add a
|
||||
* matching branch in `auto-fix-typia-errors.ts` (or generalize that file).
|
||||
*
|
||||
* TASK is the type the original report hit; PROJECT and TAG are defense in
|
||||
* depth because they share the same recreate code path. NOTE,
|
||||
* SIMPLE_COUNTER, TASK_REPEAT_CFG, METRIC, ISSUE_PROVIDER fall through to the
|
||||
* depth because they share the same recreate code path. SIMPLE_COUNTER was
|
||||
* added after #7330 recurred on it: a concurrent delete-vs-update across
|
||||
* devices recreated a counter with `type === undefined`, which typia rejects
|
||||
* and dataRepair/auto-fix had no rule for, leaving the user stuck on the
|
||||
* "Repair attempted but failed" dialog. Defaults come from
|
||||
* EMPTY_SIMPLE_COUNTER. KNOWN LIMITATION: `type` is unrecoverable from a
|
||||
* `{id}`-only delete, so the counter comes back as ClickCounter and disabled
|
||||
* (`isEnabled: false`) on the deleting device only — the holder keeps its real
|
||||
* type via `updateOne` merge, so the fleet diverges on `type`. Acceptable vs.
|
||||
* the previous dead-end; a full fix needs a tombstone (snapshot) delete op.
|
||||
* NOTE, TASK_REPEAT_CFG, METRIC, ISSUE_PROVIDER still fall through to the
|
||||
* legacy behavior — add an entry here when there is evidence the
|
||||
* partial-payload path fires for them.
|
||||
*
|
||||
|
|
@ -49,4 +70,12 @@ export const RECREATE_FALLBACK: Partial<Record<EntityType, RecreateFallback>> =
|
|||
},
|
||||
PROJECT: { defaults: DEFAULT_PROJECT, requiredKeys: ['title', 'taskIds'] },
|
||||
TAG: { defaults: DEFAULT_TAG, requiredKeys: ['title', 'taskIds'] },
|
||||
SIMPLE_COUNTER: {
|
||||
defaults: EMPTY_SIMPLE_COUNTER,
|
||||
// Curated like TASK: omit `icon` (nullable, healed by the undefined→null
|
||||
// branch), `isEnabled`/`isOn` (booleans, healed by the falsey→false
|
||||
// branch). Listing them would also make the meta-reducer warn misreport a
|
||||
// legitimately-null `icon` as "missing". `defaults` still backfills them.
|
||||
requiredKeys: ['title', 'type', 'countOnDay'],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { validateFull } from '../../validation/validation-fn';
|
||||
import { dataRepair } from '../../validation/data-repair';
|
||||
import { createAppDataCompleteMock } from '../../../util/app-data-mock';
|
||||
import { EMPTY_SIMPLE_COUNTER } from '../../../features/simple-counter/simple-counter.const';
|
||||
import { SimpleCounterType } from '../../../features/simple-counter/simple-counter.model';
|
||||
import { AppDataComplete } from '../../model/model-config';
|
||||
|
||||
/**
|
||||
* Integration repro for issue #7330's recurrence on SIMPLE_COUNTER
|
||||
* (ruckusvol's logs, both devices ≥ v18.6.0).
|
||||
*
|
||||
* A concurrent delete-vs-update across devices recreated a counter from a
|
||||
* partial LWW payload, leaving `simpleCounter.entities.<id>.type === undefined`.
|
||||
* typia rejects the enum, and the previous dataRepair/auto-fix pipeline had no
|
||||
* rule for it, so post-sync validation looped on:
|
||||
*
|
||||
* [validation-fn] Validation failed firstErrorPath: ...simpleCounter...type
|
||||
* [ValidateStateService] State still invalid after repair
|
||||
*
|
||||
* and the user dead-ended on the "Repair attempted but failed" dialog. This
|
||||
* drives the SAME pipeline ValidateStateService uses (real `validateFull` →
|
||||
* real `dataRepair` → real `validateFull`) and asserts the corrupt counter is
|
||||
* now healed end-to-end rather than re-failing. It is the regression net that
|
||||
* fails loudly if either repair layer is removed.
|
||||
*/
|
||||
describe('SimpleCounter undefined-type post-sync repair (#7330) — integration', () => {
|
||||
const COUNTER_ID = 'cnt_TMfJh3tw15FP4gRcNTx9O';
|
||||
|
||||
const makeStateWithCounter = (counter: Record<string, unknown>): AppDataComplete => {
|
||||
const state = createAppDataCompleteMock();
|
||||
(
|
||||
state as unknown as { simpleCounter: { ids: string[]; entities: unknown } }
|
||||
).simpleCounter = {
|
||||
ids: [COUNTER_ID],
|
||||
entities: { [COUNTER_ID]: counter },
|
||||
};
|
||||
return state;
|
||||
};
|
||||
|
||||
it('baseline mock validates (harness sanity)', () => {
|
||||
expect(validateFull(createAppDataCompleteMock()).isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('reproduces the failure and heals it through the real validate→repair→validate pipeline', () => {
|
||||
// The exact on-disk shape from the report: a counter complete except for
|
||||
// `type` (errorCount: 1), with its accumulated count data intact.
|
||||
const corruptCounter: Record<string, unknown> = {
|
||||
...EMPTY_SIMPLE_COUNTER,
|
||||
id: COUNTER_ID,
|
||||
title: 'Coffee',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
countOnDay: { '2026-06-29': 7 },
|
||||
// Stamped onto every recreated entity by lwwUpdateMetaReducer; harmless
|
||||
// excess property under typia createValidate, mirrors the real state.
|
||||
modified: 123,
|
||||
};
|
||||
delete corruptCounter['type'];
|
||||
|
||||
const corruptState = makeStateWithCounter(corruptCounter);
|
||||
|
||||
// 1. Reproduce: full validation fails on exactly the reported path.
|
||||
const before = validateFull(corruptState);
|
||||
expect(before.isValid).toBe(false);
|
||||
const firstError = before.typiaResult.success
|
||||
? undefined
|
||||
: before.typiaResult.errors[0];
|
||||
expect(firstError?.path).toContain('simpleCounter');
|
||||
expect(firstError?.path).toContain('type');
|
||||
|
||||
// 2. Repair via the same entry point ValidateStateService uses.
|
||||
const errors = before.typiaResult.success ? [] : before.typiaResult.errors;
|
||||
const repaired = dataRepair(corruptState, errors).data;
|
||||
|
||||
// 3. The previously-fatal state is now valid (no "still invalid after repair").
|
||||
expect(validateFull(repaired).isValid).toBe(true);
|
||||
|
||||
const healed = (
|
||||
repaired as unknown as {
|
||||
simpleCounter: { entities: Record<string, Record<string, unknown>> };
|
||||
}
|
||||
).simpleCounter.entities[COUNTER_ID];
|
||||
// `type` is backfilled to the harmless ClickCounter default...
|
||||
expect(healed['type']).toBe(SimpleCounterType.ClickCounter);
|
||||
// ...and the user's count history is preserved, not wiped.
|
||||
expect(healed['countOnDay']).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'2026-06-29': 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -340,6 +340,84 @@ describe('autoFixTypiaErrors', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Issue #7330 recurred on SIMPLE_COUNTER: a concurrent delete-vs-update
|
||||
// across devices recreated a counter with `type === undefined` (and possibly
|
||||
// other required scalars), which typia rejects and dataRepair had no rule
|
||||
// for — dead-ending the user on "Repair attempted but failed". These verify
|
||||
// the on-disk heal mirrors the task fix.
|
||||
describe('issue #7330 — partial simpleCounter entities from LWW recreate', () => {
|
||||
it('should fix undefined simpleCounter.type to the ClickCounter default', () => {
|
||||
const mockData = createAppDataCompleteMock();
|
||||
(mockData as any).simpleCounter = {
|
||||
ids: ['cnt1'],
|
||||
entities: {
|
||||
// A counter recreated from a partial payload: id + the count data
|
||||
// survived, but `type` (and isEnabled/isOn) never made it in.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
cnt1: { id: 'cnt1', countOnDay: { '2026-06-29': 3 } },
|
||||
},
|
||||
};
|
||||
const errors = [
|
||||
createTypiaError(
|
||||
'$input.simpleCounter.entities["cnt1"].type',
|
||||
'("ClickCounter" | "RepeatedCountdownReminder" | "StopWatch")',
|
||||
undefined,
|
||||
),
|
||||
];
|
||||
|
||||
const result = autoFixTypiaErrors(mockData, errors as any);
|
||||
|
||||
expect((result as any).simpleCounter.entities['cnt1'].type).toBe('ClickCounter');
|
||||
// The surviving count data must be preserved, not clobbered by defaults.
|
||||
expect((result as any).simpleCounter.entities['cnt1'].countOnDay).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'2026-06-29': 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fix undefined simpleCounter boolean/string required fields', () => {
|
||||
const mockData = createAppDataCompleteMock();
|
||||
(mockData as any).simpleCounter = {
|
||||
ids: ['cnt1'],
|
||||
entities: {
|
||||
cnt1: { id: 'cnt1', type: 'StopWatch' },
|
||||
},
|
||||
};
|
||||
const errors = [
|
||||
createTypiaError(
|
||||
'$input.simpleCounter.entities["cnt1"].title',
|
||||
'string',
|
||||
undefined,
|
||||
),
|
||||
createTypiaError(
|
||||
'$input.simpleCounter.entities["cnt1"].isEnabled',
|
||||
'boolean',
|
||||
undefined,
|
||||
),
|
||||
createTypiaError(
|
||||
'$input.simpleCounter.entities["cnt1"].isOn',
|
||||
'boolean',
|
||||
undefined,
|
||||
),
|
||||
createTypiaError(
|
||||
'$input.simpleCounter.entities["cnt1"].countOnDay',
|
||||
'Record<string, number>',
|
||||
undefined,
|
||||
),
|
||||
];
|
||||
|
||||
const result = autoFixTypiaErrors(mockData, errors as any);
|
||||
|
||||
const counter = (result as any).simpleCounter.entities['cnt1'];
|
||||
expect(counter.title).toBe('');
|
||||
expect(counter.isEnabled).toBe(false);
|
||||
expect(counter.isOn).toBe(false);
|
||||
expect(counter.countOnDay).toEqual({});
|
||||
// A user-chosen field present in the payload must not be overwritten.
|
||||
expect(counter.type).toBe('StopWatch');
|
||||
});
|
||||
});
|
||||
|
||||
// Discussion #8022: a Nextcloud-synced state imported from MS Todos had 84
|
||||
// taskRepeatCfg entities with undefined `quickSetting` (required field) and
|
||||
// a TODAY tag with undefined `created`. dataRepair couldn't repair them so
|
||||
|
|
|
|||
|
|
@ -186,6 +186,34 @@ export const autoFixTypiaErrors = (
|
|||
// Fix for issue #4593: simpleCounter countOnDay null value
|
||||
setValueByPath(data, keys, 0);
|
||||
logAutoFixApplied(path, keys, 'simple-counter-countOnDay-null-to-zero', value, 0);
|
||||
} else if (
|
||||
// Issue #7330 (recurrence on SIMPLE_COUNTER): a counter recreated from a
|
||||
// partial LWW Update (concurrent delete-vs-update across devices) can be
|
||||
// missing required scalar fields — most often `type`, an enum with no
|
||||
// value typia will accept, so dataRepair previously dead-ended on the
|
||||
// "Repair attempted but failed" dialog. Primary fix is the
|
||||
// RECREATE_FALLBACK backfill in lwwUpdateMetaReducer; this branch is
|
||||
// defense-in-depth for state already corrupted on disk. Field list and
|
||||
// defaults come from RECREATE_FALLBACK.SIMPLE_COUNTER so this heal can't
|
||||
// drift from the recreate defaults. Only `title`/`type`/`countOnDay`
|
||||
// reach here; undefined `icon`/`isEnabled`/`isOn` are already coerced by
|
||||
// the generic null/boolean branches above.
|
||||
keys[0] === 'simpleCounter' &&
|
||||
keys[1] === 'entities' &&
|
||||
keys.length === 4 &&
|
||||
value === undefined &&
|
||||
RECREATE_FALLBACK.SIMPLE_COUNTER?.requiredKeys.includes(keys[3] as string)
|
||||
) {
|
||||
const field = keys[3] as string;
|
||||
const defaultValue = RECREATE_FALLBACK.SIMPLE_COUNTER.defaults[field];
|
||||
setValueByPath(data, keys, defaultValue);
|
||||
logAutoFixApplied(
|
||||
path,
|
||||
keys,
|
||||
'simple-counter-required-field-default',
|
||||
value,
|
||||
defaultValue,
|
||||
);
|
||||
} else if (
|
||||
keys[0] === 'taskRepeatCfg' &&
|
||||
keys[1] === 'entities' &&
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ import { CONFIG_FEATURE_NAME } from '../../../features/config/store/global-confi
|
|||
import { TIME_TRACKING_FEATURE_KEY } from '../../../features/time-tracking/store/time-tracking.reducer';
|
||||
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { SIMPLE_COUNTER_FEATURE_NAME } from '../../../features/simple-counter/store/simple-counter.reducer';
|
||||
import {
|
||||
SimpleCounter,
|
||||
SimpleCounterType,
|
||||
} from '../../../features/simple-counter/simple-counter.model';
|
||||
|
||||
describe('lwwUpdateMetaReducer', () => {
|
||||
const mockReducer = jasmine.createSpy('reducer');
|
||||
|
|
@ -640,6 +645,91 @@ describe('lwwUpdateMetaReducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Issue #7330 recurred on SIMPLE_COUNTER (ruckusvol's logs, both clients
|
||||
// ≥ v18.6.0): a concurrent delete-vs-update across devices recreated a
|
||||
// counter from a partial payload missing `type` — an enum typia rejects and
|
||||
// dataRepair/auto-fix had no rule for — dead-ending the user on "Repair
|
||||
// attempted but failed". SIMPLE_COUNTER was added to RECREATE_FALLBACK so the
|
||||
// generic recreate path backfills required fields.
|
||||
describe('[SIMPLE_COUNTER] LWW Update (#7330)', () => {
|
||||
const makeStateWithCounters = (
|
||||
entities: Record<string, unknown> = {},
|
||||
): Partial<RootState> =>
|
||||
({
|
||||
[SIMPLE_COUNTER_FEATURE_NAME]: {
|
||||
ids: Object.keys(entities),
|
||||
entities,
|
||||
},
|
||||
}) as unknown as Partial<RootState>;
|
||||
|
||||
it('backfills type (and other required fields) when recreating from a partial payload', () => {
|
||||
// Counter was deleted locally; a remote UPDATE won via LWW.
|
||||
const state = makeStateWithCounters();
|
||||
const action = {
|
||||
type: '[SIMPLE_COUNTER] LWW Update',
|
||||
id: 'cnt_partial',
|
||||
// The remote UPDATE only touched the count; `type` never made it in.
|
||||
countOnDay: { [getDbDateStr()]: 4 },
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'SIMPLE_COUNTER',
|
||||
entityId: 'cnt_partial',
|
||||
},
|
||||
};
|
||||
|
||||
spyOn(OpLog, 'warn');
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const recreated = updatedState[SIMPLE_COUNTER_FEATURE_NAME]?.entities[
|
||||
'cnt_partial'
|
||||
] as SimpleCounter;
|
||||
|
||||
expect(recreated).toBeDefined();
|
||||
// The missing enum is backfilled to the harmless ClickCounter default.
|
||||
expect(recreated.type).toBe(SimpleCounterType.ClickCounter);
|
||||
expect(recreated.isEnabled).toBe(false);
|
||||
expect(recreated.isOn).toBe(false);
|
||||
// The remote-changed field the payload carried is preserved.
|
||||
expect(recreated.countOnDay).toEqual({ [getDbDateStr()]: 4 });
|
||||
expect(OpLog.warn).toHaveBeenCalledWith(
|
||||
jasmine.stringMatching(/missing required fields/),
|
||||
);
|
||||
});
|
||||
|
||||
// The proof the user's dialog can't fire from this upstream path: run the
|
||||
// recreated SimpleCounterState through the real Typia validator.
|
||||
it('produces a Typia-valid SimpleCounterState when recreating from a partial payload', () => {
|
||||
const state = makeStateWithCounters();
|
||||
const action = {
|
||||
type: '[SIMPLE_COUNTER] LWW Update',
|
||||
id: 'cnt_producer_shape',
|
||||
countOnDay: { [getDbDateStr()]: 2 },
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'SIMPLE_COUNTER',
|
||||
entityId: 'cnt_producer_shape',
|
||||
},
|
||||
};
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const updatedState = mockReducer.calls.mostRecent().args[0] as Partial<RootState>;
|
||||
const counterState = updatedState[SIMPLE_COUNTER_FEATURE_NAME];
|
||||
|
||||
const result = appDataValidators.simpleCounter(counterState as never);
|
||||
if (!result.success) {
|
||||
// Surface the typia errors so any future regression is debuggable.
|
||||
fail(
|
||||
`SimpleCounterState failed Typia validation: ${JSON.stringify(
|
||||
(result as { errors?: unknown }).errors,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('[PROJECT] LWW Update', () => {
|
||||
it('should update project entity with LWW winning state', () => {
|
||||
const state = createMockState();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue