fix(op-log): backfill SIMPLE_COUNTER fields on LWW recreate (#7330) (#8646)

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:
Johannes Millan 2026-06-30 15:40:25 +02:00 committed by GitHub
parent a9df055bc1
commit 44030bb06e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 527 additions and 11 deletions

View file

@ -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);
}
});
});

View file

@ -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'],
},
};

View file

@ -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,
});
});
});

View file

@ -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

View file

@ -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' &&

View file

@ -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();