test(sync): pin 3-client disjoint-merge composition (shared-field reconciliation + replace-mode convergence) (#8933)

* test(sync): pin three-client convergence after a merged op loses LWW

Follow-up to #8874 (the retracted-then-adjudicated composition finding):
after a disjoint-merged op loses whole-op LWW to a newer overlapping
edit from a third client, the remote-win holders transiently keep the
merged delta's other field while the local-win holder never applied it.
Convergence depends ENTIRELY on _createLocalWinUpdateOp emitting a
FULL-SNAPSHOT reconciling op with a dominating clock.

This spec pins that closure property end-to-end (merge -> overlapping
newer edit -> whole-op LWW on both holders -> snapshot propagation): if
local-win ops ever become partial deltas - the same direction the merge
path moved for analogous reasons - the three clients diverge permanently
and this test goes red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(sync): reframe 3-client convergence as a focused composition test

Addresses maintainer review on #8933:
- Drops the '(e2e)/all-clients' framing. It is a composition test of
  ConflictResolutionService's emitted ops applied via a local LWW helper, not a
  transport e2e; the comment now states the scope and that cross-client
  propagation is modelled (justified by the dominating-clock assertion), not
  executed. Client B contributes only an input op.
- Adds the two assertions that make the convergence claim real: the local-win op
  reconstructs C's COMPLETE reviewable entity from a bare base (full-snapshot
  closure), and its clock is GREATER_THAN both inputs (opC and the merged op),
  so it propagates as a plain non-conflicting remote op.
- Updates the local resolveCapturing helper to the #8900 mixed-source-batch API
  (appendMixedSourceBatchSkipDuplicates); the rebase onto current master
  surfaced that the prior appendWithVectorClockUpdate mock no longer matches the
  service.

SPAP-39

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sync): scope 3-client claim to shared-field reconciliation; prove receiver-only-field divergence

Addresses maintainer review on #8933. The three-client composition test previously
claimed full-snapshot closure (stateA === stateC / identical entities). johannesjo
gave a verified counterexample: applying C's full local-win snapshot to A cannot clear
a field C never had (SuperSync JSON-serializes ops so absent fields don't travel as
clears; lwwUpdateMetaReducer applies via a shallow updateOne), so A keeps it and the
clients diverge — with a dominating clock, permanently.

- Narrow the active composition test to the property that actually holds: present-field
  reconstruction + a dominating clock + shared-field reconciliation. It no longer
  asserts byte-identical whole-state equality.
- Replace the pending() aspiration with an ENABLED test asserting today's ACTUAL
  behavior: run the real resolution service to get C's local-win op, JSON round-trip
  its payload, apply it through the PRODUCTION lwwUpdateMetaReducer, and assert A
  retains the receiver-only dueDay while C lacks it (divergence). Flip the dueDay
  assertions when the runtime reconciles receiver-only fields.

The underlying runtime data-integrity bug is tracked in SPAP-43.

SPAP-39

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sync): migrate disjoint-merge spec to production op-apply seam; pin convergence

Rebasing #8933 onto current master surfaced that upstream #8980/#8990 reshaped
the LWW op payload (adapter `{ task: { changes } }` -> nested
`{ actionPayload, entityChanges, lwwUpdateMode }`) and #9007/SPAP-43 made
`_createLocalWinUpdateOp` stamp `lwwUpdateMode: 'replace'`, applied via setOne.

Per johannesjo's #8933 review:

- Content-model applyOp: also read the nested `actionPayload` shape so the
  no-receiver-only-field test reconstructs the op's carried fields correctly.
  (This limb only asserts WHICH fields the op transports; no clearing in play.)

- Receiver-only test: build the action via the real `convertOpToAction` instead
  of hand-rolling it. The hand-built action spread `lwwUpdateMode` at the top
  level, so it never reached `action.meta` and silently took the updateOne
  (shallow-merge) branch — masking production. Through the real seam the
  'replace' op applies via setOne: A's absorbed receiver-only `dueDay` and B's
  `notes` are CLEARED and the clients CONVERGE. Flipped the assertions and
  reframed the test from "diverge" to "converge" — the SPAP-43 replace/setOne
  work closed the gap the test previously (incorrectly) documented as open.

Verified end-to-end: 28/28 in-file green on current master.

SPAP-39

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sync): fix stale op-log-store mock; reconcile flipped convergence comments

Round-4 review fixes for #8933 after rebase onto current master:

- resolveCapturing helper's createSpyObj was a third, un-maintained copy of
  the OperationLogStoreService mock and had rotted: renamed
  appendWithVectorClockUpdate -> appendWithVectorClockOverwrite (#9080) and
  added the missing getOpById (#9086). This was the CI-red blocker
  (TypeError: opLogStore.getOpById is not a function).
- Flip the two describe-level headers that still carried pre-flip divergence
  framing. Master now stamps lwwUpdateMode: 'replace' on local-win ops and the
  reducer applies replace-mode via setOne, so test 2 asserts the receiver-only
  field IS cleared and the clients converge -- the headers said the opposite.
- Add a receiver-version scope note: the disjoint merge is enabled in
  production (unfrozen by #9095), but only replace-mode-aware receivers
  converge; older clients apply the snapshot via updateOne and still diverge.

29/29 SUCCESS in conflict-resolution.disjoint-merge.spec.ts.

SPAP-39

* chore(sync): scrub private tracker keys from disjoint-merge spec

The spec ships to the public upstream PR and the maintainer is GitHub-only,
so replace internal tracker keys with neutral descriptive wording:
- top-level describe + file header: drop the key, keep "disjoint-field merge"
- (a3)/(a4)/(a5) section comments: "disjoint-merge fix:" instead of the key
- 3-client test comments: "the replace/setOne behavior/work" without the key

No behavioural change; 29/29 SUCCESS.

SPAP-39

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
aakhter 2026-07-18 06:26:54 -04:00 committed by GitHub
parent b902edb77b
commit bd7c2f2cee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -2,8 +2,9 @@ import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ConflictResolutionService } from './conflict-resolution.service';
import { ConflictJournalService } from './conflict-journal.service';
import { Store } from '@ngrx/store';
import { Action, Store } from '@ngrx/store';
import { OperationApplierService } from '../apply/operation-applier.service';
import { convertOpToAction } from '../apply/operation-converter.util';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import { SnackService } from '../../core/snack/snack.service';
import { ValidateStateService } from '../validation/validate-state.service';
@ -27,9 +28,51 @@ import {
synthesizeMergedChanges,
isDisjointMergeEligible,
} from './conflict-disjoint-merge.util';
import { lwwUpdateMetaReducer } from '../../root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer';
import { TASK_FEATURE_NAME } from '../../features/tasks/store/task.reducer';
import { PROJECT_FEATURE_NAME } from '../../features/project/store/project.reducer';
import { TAG_FEATURE_NAME } from '../../features/tag/store/tag.reducer';
import { INBOX_PROJECT } from '../../features/project/project.const';
import { TODAY_TAG } from '../../features/tag/tag.const';
import { appStateFeatureKey } from '../../root-store/app-state/app-state.reducer';
import { getDbDateStr } from '../../util/get-db-date-str';
/**
* SPAP-14 disjoint-field auto-merge acceptance tests.
* Minimal RootState for exercising the PRODUCTION `lwwUpdateMetaReducer` on a
* single TASK. Includes the slices its relationship-repair reads (project INBOX,
* TODAY tag, appState) so applying an LWW Update never throws on a missing slice.
*/
const buildRootStateWithTask = (task: Record<string, unknown>): unknown => ({
[TASK_FEATURE_NAME]: {
ids: [task['id']],
entities: { [task['id'] as string]: task },
currentTaskId: null,
selectedTaskId: null,
taskDetailTargetPanel: null,
isDataLoaded: true,
lastCurrentTaskId: null,
},
[PROJECT_FEATURE_NAME]: {
ids: [INBOX_PROJECT.id],
entities: {
[INBOX_PROJECT.id]: {
id: INBOX_PROJECT.id,
title: 'Inbox',
taskIds: [],
backlogTaskIds: [],
noteIds: [],
},
},
},
[TAG_FEATURE_NAME]: {
ids: [TODAY_TAG.id],
entities: { [TODAY_TAG.id]: { ...TODAY_TAG, taskIds: [] } },
},
[appStateFeatureKey]: { todayStr: getDbDateStr(), startOfNextDayDiffMs: 0 },
});
/**
* Disjoint-field auto-merge acceptance tests.
*
* (a) title-vs-notes concurrent edit merged entity keeps BOTH; journal
* merged/disjoint-merge/info; not in unreviewed.
@ -40,7 +83,7 @@ import {
* (e) two-client convergence: both orderings yield identical entity + dominating
* clocks.
*/
describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
describe('ConflictResolutionService — disjoint-field merge', () => {
let service: ConflictResolutionService;
let journal: ConflictJournalService;
let mockStore: jasmine.SpyObj<Store>;
@ -952,7 +995,7 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
expect(result.localWinOpsCreated).toBe(1);
});
// ── (a3) SPAP-14 fix: partial-delta merged op, no un-conflicted ride-along ──
// ── (a3) disjoint-merge fix: partial-delta merged op, no un-conflicted ride-along ──
it('(a3) synthesizes a partial-delta merged op that excludes un-conflicted fields', async () => {
// Current entity carries a field NEITHER side touched (timeSpentOnDay). A
// full-entity snapshot would embed it and diverge across clients whose
@ -994,7 +1037,7 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
expect('timeSpentOnDay' in payload).toBe(false);
});
// ── (a4) SPAP-14 fix: refuse merge when the entity has >1 conflict this batch
// ── (a4) disjoint-merge fix: refuse merge when the entity has >1 conflict this batch
it('(a4) refuses disjoint-merge for an entity with multiple conflicts (falls back to LWW)', async () => {
mockStore.select.and.returnValue(
of({ id: 'task-1', title: 'base', notes: 'base', timeEstimate: 5 }),
@ -1064,7 +1107,7 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
expect(await journal.list('history')).toEqual([]);
});
// ── (a5) SPAP-14 fix: refuse merge for fallback-less entity types ───────────
// ── (a5) disjoint-merge fix: refuse merge for fallback-less entity types ───────────
it('(a5) refuses disjoint-merge for a type without a RECREATE_FALLBACK (NOTE → LWW)', async () => {
// A partial-delta merged op that later wins over a concurrent delete would
// recreate a schema-INVALID NOTE (no RECREATE_FALLBACK). So NOTE disjoint
@ -1331,7 +1374,7 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
});
});
it("the merged DELTA is independent of each client's divergent current state (SPAP-14 divergence fix)", () => {
it("the merged DELTA is independent of each client's divergent current state (disjoint-merge divergence fix)", () => {
// The two clients' current entities differ on an UN-conflicted field
// (timeSpentOnDay) — e.g. one already applied a third device's edit the
// other has not. A full-entity snapshot would drag that field along and
@ -1533,4 +1576,440 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => {
).toBe(false);
});
});
// A FOCUSED COMPOSITION TEST, not a transport e2e: it drives
// ConflictResolutionService with hand-built EntityConflicts and composes the
// ops it emits with a local LWW `applyOp` helper. It does NOT exercise real
// conflict detection, the OperationApplierService, or any sync transport —
// cross-client propagation is MODELLED (justified by the dominating-clock
// assertions below), not executed. Client B contributes only an input op, not
// a tracked client. Its value: it goes red if `_createLocalWinUpdateOp` ever
// emits a partial delta instead of a full snapshot — a NECESSARY (not
// sufficient) condition for the local-win side to reconstruct every field.
// The first test pins that op shape and checks the fields both sides share.
// The second test goes further: it drives the real production seam
// (convertOpToAction + lwwUpdateMetaReducer) and shows that a receiver-only
// field the loser absorbed IS cleared, because the local-win op carries
// `lwwUpdateMode: 'replace'` and the reducer applies it via setOne — so the
// clients CONVERGE at the task entity, but ONLY for receivers that honour
// replace-mode (see the scope note on the describe below). Neither test makes
// a whole-normalized-state or all-orderings convergence claim.
describe('composition (3-client): a later overlapping edit beats the merged op', () => {
// Adjudicates the "partial merged delta is not closed under later LWW
// composition" concern: after a merged op loses whole-op LWW to a newer
// overlapping edit from a third client, the clients are TRANSIENTLY apart
// (the remote-win side keeps the merged delta's other field; the local-win
// side never applied it) — reconciling the SHARED fields depends on the
// local-win side emitting a FULL-SNAPSHOT op. The first test pins that op
// property (full snapshot + dominating clock) and checks the shared fields
// line up; it does NOT assert whole-state cross-client convergence. The
// second test drives the production seam and pins the CONVERGENT outcome:
// because the local-win op carries `lwwUpdateMode: 'replace'`, the reducer
// applies it via setOne, CLEARING a receiver-only field the loser absorbed,
// so A converges onto C for this upload ordering.
//
// SCOPE — this convergence is receiver-version-dependent. The disjoint merge
// that sets up the scenario is enabled in production (unfrozen by #9095), and
// its merged op is an ordinary 'patch' that any client applies via updateOne.
// But the replace-mode local-win op is newer: a receiver predating replace-mode
// treats it as a plain LWW Update, applies C's snapshot via updateOne, keeps
// the absorbed field, and the receiver-only-field divergence persists. So in a
// mixed fleet this pins replace-aware behaviour only, not universal convergence;
// the residual older-client divergence is tracked separately as a runtime fix.
const resolveCapturing = async (
clientId: string,
currentState: Record<string, unknown>,
conflict: EntityConflict,
): Promise<{ appended: Operation[]; applied: Operation[] }> => {
TestBed.resetTestingModule();
const store = jasmine.createSpyObj('Store', ['select']);
store.select.and.returnValue(of(currentState));
const applier = jasmine.createSpyObj('OperationApplierService', [
'applyOperations',
]);
applier.applyOperations.and.resolveTo({ appliedOps: [] });
const opLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'appendBatchSkipDuplicates',
'appendMixedSourceBatchSkipDuplicates',
'appendWithVectorClockOverwrite',
'markApplied',
'markRejected',
'markFailed',
'getUnsyncedByEntity',
'getOpById',
'mergeRemoteOpClocks',
'markReducersCommittedAndMergeClocks',
]);
opLogStore.mergeRemoteOpClocks.and.resolveTo(undefined);
opLogStore.markReducersCommittedAndMergeClocks.and.resolveTo(undefined);
opLogStore.getUnsyncedByEntity.and.resolveTo(new Map());
opLogStore.getOpById.and.resolveTo(undefined);
opLogStore.markRejected.and.resolveTo(undefined);
opLogStore.markApplied.and.resolveTo(undefined);
opLogStore.markFailed.and.resolveTo(undefined);
opLogStore.appendWithVectorClockOverwrite.and.resolveTo(1);
opLogStore.appendBatchSkipDuplicates.and.callFake((ops: Operation[]) =>
Promise.resolve({
seqs: ops.map((_, i) => i + 1),
writtenOps: ops,
skippedCount: 0,
}),
);
// #8900 seam: local-win / merged ops now persist through the atomic
// mixed-source batch, mirroring the outer suite's setup.
opLogStore.appendMixedSourceBatchSkipDuplicates.and.callFake(async (batches) => ({
written: batches.flatMap((batch) =>
batch.ops.map((batchOp, index) => ({
seq: index + 1,
op: batchOp,
source: batch.source,
})),
),
skippedCount: 0,
}));
const validate = jasmine.createSpyObj('ValidateStateService', [
'validateAndRepairCurrentState',
]);
validate.validateAndRepairCurrentState.and.resolveTo(true);
const effects = jasmine.createSpyObj('OperationLogEffects', [
'processDeferredActions',
]);
effects.processDeferredActions.and.resolveTo();
TestBed.configureTestingModule({
providers: [
ConflictResolutionService,
{ provide: Store, useValue: store },
{ provide: OperationApplierService, useValue: applier },
{ provide: OperationLogStoreService, useValue: opLogStore },
{
provide: SnackService,
useValue: jasmine.createSpyObj('SnackService', ['open']),
},
{ provide: ValidateStateService, useValue: validate },
{ provide: OperationLogEffects, useValue: effects },
{
provide: CLIENT_ID_PROVIDER,
useValue: { loadClientId: () => Promise.resolve(clientId) },
},
{ provide: ENTITY_REGISTRY, useValue: buildEntityRegistry() },
],
});
const svc = TestBed.inject(ConflictResolutionService);
await svc.autoResolveConflictsLWW([conflict]);
const appended = opLogStore.appendMixedSourceBatchSkipDuplicates.calls
.allArgs()
.flatMap(([batches]) => batches)
.filter((batch) => batch.source === 'local')
.flatMap((batch) => [...batch.ops])
.filter((o: Operation) => o.entityId === 'task-1' && o.opType === OpType.Update);
const applied = applier.applyOperations.calls
.allArgs()
.flatMap(([ops]) => ops as Operation[])
.filter((o) => o.entityId === 'task-1');
return { appended, applied };
};
// Content model: reconstruct the entity from the op's carried fields,
// mirroring the consumer paths — adapter `{ task: { id, changes } }` -> merge
// changes; nested `{ actionPayload }` (#8980/#8990) or flat LWW payload ->
// shallow-merge fields minus id (updateOne). This asserts WHICH fields the op
// transports for the no-receiver-only-field case, where every field is present
// on both sides so nothing is cleared. The receiver-only CLEARING that the
// 'replace'/setOne path performs is exercised through the real production
// reducer in the sibling test, which is where it actually matters (#8933).
const applyOp = (
state: Record<string, unknown>,
o: Operation,
): Record<string, unknown> => {
const p = o.payload as Record<string, unknown>;
const task = p['task'] as Record<string, unknown> | undefined;
if (task && typeof task['changes'] === 'object') {
return { ...state, ...(task['changes'] as Record<string, unknown>) };
}
const actionPayload = p['actionPayload'] as Record<string, unknown> | undefined;
if (actionPayload && typeof actionPayload === 'object') {
const changed = { ...actionPayload };
delete changed['id'];
return { ...state, ...changed };
}
const flat = { ...p };
delete flat['id'];
return { ...state, ...flat };
};
const pick = (s: Record<string, unknown>): Record<string, unknown> => ({
title: s['title'],
notes: s['notes'],
});
it('the full-snapshot local-win op reconciles fields present on both sides (no receiver-only field in play)', async () => {
// Round 1: A (title) and B (notes) conflict -> disjoint merge on A.
const opA = op({
id: 'op-A',
clientId: 'clientA',
vectorClock: { clientA: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'A-title' } } },
});
const opB = op({
id: 'op-B',
clientId: 'clientB',
vectorClock: { clientB: 1 },
timestamp: 3000,
payload: { task: { id: 'task-1', changes: { notes: 'B-notes' } } },
});
const r1 = await resolveCapturing(
'clientA',
{ id: 'task-1', title: 'A-title', notes: 'base' },
conflictOf([opA], [opB]),
);
const mergedOp = r1.appended[0];
expect(mergedOp).toBeDefined();
// A's state after applying the merged delta.
let stateA = applyOp({ id: 'task-1', title: 'A-title', notes: 'base' }, mergedOp);
expect(pick(stateA)).toEqual({ title: 'A-title', notes: 'B-notes' });
// Concurrent third client C: overlapping title edit, NEWER timestamp,
// clock concurrent with everything (C never saw opA/opB/merged).
const opC = op({
id: 'op-C',
clientId: 'clientC',
vectorClock: { clientC: 1 },
timestamp: 4000,
payload: { task: { id: 'task-1', changes: { title: 'C-title' } } },
});
let stateC: Record<string, unknown> = {
id: 'task-1',
title: 'C-title',
notes: 'base',
// A field touched by NEITHER opC nor the merged delta. A partial
// (changed-fields-only) local-win op would drop it, so it makes the
// "full snapshot" assertion below test more than the two edited fields.
tagIds: ['keep-me'],
};
// Round 2 on A: local merged op vs remote opC. Fields overlap on title
// and the merged op is flat/opaque -> no re-merge -> whole-op LWW ->
// opC (newer) wins -> A applies opC AS-IS (partial).
const r2a = await resolveCapturing(
'clientA',
stateA,
conflictOf([mergedOp], [opC]),
);
expect(r2a.appended.length).toBe(0); // remote win: no new op from A
for (const o of r2a.applied) {
stateA = applyOp(stateA, o);
}
// TRANSIENT LIMB (documented, not asserted as final): A now holds
// { title: C, notes: B-notes } while C holds { title: C, notes: base }.
expect(pick(stateA)).toEqual({ title: 'C-title', notes: 'B-notes' });
// Round 2 on C: local opC vs remote merged op -> LOCAL win -> C must
// emit a reconciling local-win op carrying its FULL entity snapshot.
const r2c = await resolveCapturing(
'clientC',
stateC,
conflictOf([opC], [mergedOp]),
);
for (const o of r2c.applied) {
stateC = applyOp(stateC, o);
}
const localWinOp = r2c.appended[0];
expect(localWinOp).toBeDefined();
// Full snapshot, not a partial changed-fields-only delta: applied to a
// BARE base the op alone must reconstruct C's COMPLETE entity — every
// field, including notes (=base, untouched by opC) AND tagIds (touched by
// neither opC nor the merged delta). This is the closure property; a
// partial op would drop these and never reconcile A's B-notes with C.
expect(applyOp({ id: 'task-1' }, localWinOp)).toEqual({
id: 'task-1',
title: 'C-title',
notes: 'base',
tagIds: ['keep-me'],
});
// The local-win op's clock DOMINATES both inputs (opC and the merged op),
// so in production it reaches A as a plain non-conflicting remote op that
// applies directly — which is what the modelled propagation below assumes.
expect(compareVectorClocks(localWinOp.vectorClock, opC.vectorClock)).toBe(
VectorClockComparison.GREATER_THAN,
);
expect(compareVectorClocks(localWinOp.vectorClock, mergedOp.vectorClock)).toBe(
VectorClockComparison.GREATER_THAN,
);
// Modelled propagation (justified by the dominating clock above): the
// local-win op reaches A as a plain remote op and applies directly.
stateA = applyOp(stateA, localWinOp);
// SHARED-FIELD RECONCILIATION (deliberately NOT whole-state convergence):
// the fields both sides carry line up — A's `notes` (absorbed from the
// merged delta) is overwritten by C's snapshot value, and `title` already
// agreed. We do NOT assert byte-identical whole-state equality: the real
// lwwUpdateMetaReducer also stamps `modified` and repairs relationships
// (project.taskIds, tag/TODAY membership) that this field-level `applyOp`
// model omits. Whole-state, cross-client convergence is not established at
// this layer — in particular the inverse upload ordering (opC accepted
// first, the merged op rejected, so C never downloads it and emits no
// reconciling op) can leave the clients apart even on shared fields. That
// is a real-transport property for a dedicated multi-client harness, not
// this service-level composition test.
expect(pick(stateA)).toEqual(pick(stateC));
expect(pick(stateA)).toEqual({ title: 'C-title', notes: 'base' });
});
// Pins the receiver-only CLEARING behavior. B edits an OPTIONAL field
// (`dueDay`) that C never has: A absorbs it via the merged delta, then C's
// later full local-win snapshot omits it. Because that snapshot rides an
// `lwwUpdateMode: 'replace'` op, the production meta-reducer applies it via
// setOne (a full entity swap), so A's absorbed `dueDay` — and B's `notes` —
// are CLEARED and the clients CONVERGE. This is the behavior the
// replace/setOne work established; the earlier shallow-`updateOne` path (and a
// hand-built action that keeps `lwwUpdateMode` out of `meta`) would instead
// strand the absorbed field on A. This guards against regressing to that
// non-clearing path. Ops travel the real wire shape (JSON round-trip) and are
// applied through the production `convertOpToAction` + meta-reducer seam.
it('clears a receiver-only field the loser absorbed via the replace-mode local-win op — A converges onto C for this ordering (production reducer + JSON round-trip)', async () => {
// Round 1: A (title) vs B (notes + an OPTIONAL dueDay that C never has).
const opA = op({
id: 'op-A',
clientId: 'clientA',
vectorClock: { clientA: 1 },
timestamp: 2000,
payload: { task: { id: 'task-1', changes: { title: 'A-title' } } },
});
const opB = op({
id: 'op-B',
clientId: 'clientB',
vectorClock: { clientB: 1 },
timestamp: 3000,
payload: {
task: { id: 'task-1', changes: { notes: 'B-notes', dueDay: '2026-07-15' } },
},
});
const r1 = await resolveCapturing(
'clientA',
{ id: 'task-1', title: 'A-title', notes: 'base' },
conflictOf([opA], [opB]),
);
const mergedOp = r1.appended[0];
expect(mergedOp).toBeDefined();
// A absorbs the merged delta, INCLUDING the receiver-only dueDay.
let stateA = applyOp({ id: 'task-1', title: 'A-title', notes: 'base' }, mergedOp);
expect(stateA['dueDay']).toBe('2026-07-15');
// Third client C: overlapping newer title edit; its entity never had dueDay.
const opC = op({
id: 'op-C',
clientId: 'clientC',
vectorClock: { clientC: 1 },
timestamp: 4000,
payload: { task: { id: 'task-1', changes: { title: 'C-title' } } },
});
let stateC: Record<string, unknown> = {
id: 'task-1',
title: 'C-title',
notes: 'base',
};
expect('dueDay' in stateC).toBe(false);
// Round 2 on A: merged op vs newer opC -> opC wins -> A applies it (partial).
const r2a = await resolveCapturing(
'clientA',
stateA,
conflictOf([mergedOp], [opC]),
);
for (const o of r2a.applied) {
stateA = applyOp(stateA, o);
}
// Round 2 on C: local opC vs remote merged op -> LOCAL win -> full snapshot.
const r2c = await resolveCapturing(
'clientC',
stateC,
conflictOf([opC], [mergedOp]),
);
for (const o of r2c.applied) {
stateC = applyOp(stateC, o);
}
const localWinOp = r2c.appended[0];
expect(localWinOp).toBeDefined();
// JSON round-trip — SuperSync serializes ops with JSON.stringify, so C's flat
// snapshot (which never had dueDay) reaches A with dueDay simply ABSENT from
// the snapshot; the wire cannot encode "clear this field" explicitly.
const wirePayload = JSON.parse(JSON.stringify(localWinOp.payload)) as Record<
string,
unknown
>;
expect('dueDay' in (wirePayload['actionPayload'] as Record<string, unknown>)).toBe(
false,
);
// Apply the round-tripped local-win op to A through the PRODUCTION seam.
// convertOpToAction routes the op's `lwwUpdateMode: 'replace'` into
// action.meta, so lwwUpdateMetaReducer applies it via setOne — a full
// snapshot swap. (A hand-built action spreading lwwUpdateMode at the top
// level instead of meta silently takes the updateOne shallow-merge branch
// and hides this.) A's task carries the modelled post-round-2 state (title
// reconciled to C, notes still B's, dueDay absorbed) plus the structural
// fields the reducer's relationship-repair reads.
const mockBase = jasmine.createSpy('base').and.callFake((s: unknown) => s);
const prodReducer = lwwUpdateMetaReducer(mockBase);
const aRootState = buildRootStateWithTask({
...stateA,
dueWithTime: null,
projectId: null,
tagIds: [],
parentId: null,
subTaskIds: [],
modified: 1000,
});
const action = convertOpToAction({
...localWinOp,
payload: wirePayload,
} as Operation) as unknown as Action;
prodReducer(aRootState, action);
const aTask = (
mockBase.calls.mostRecent().args[0] as Record<
string,
{ entities: Record<string, Record<string, unknown>> }
>
)[TASK_FEATURE_NAME].entities['task-1'];
// ACTUAL CURRENT BEHAVIOR: A's task entity converges onto C's snapshot. C's
// later local-win op is `lwwUpdateMode: 'replace'`, so the production reducer
// applies it via setOne — replacing A's whole task with C's snapshot (a
// COMPLETE current-state snapshot in production; createLWWUpdateOp warns that
// a partial one would lose data). Every field that snapshot omits is CLEARED.
// This is the replace/setOne behavior; a shallow updateOne (the
// pre-#8990 path, or a mis-built action that keeps lwwUpdateMode out of meta)
// would instead strand dueDay on A and leave the clients divergent.
//
// The DISCRIMINATING proof is `notes` and `dueDay` below: `notes` only flips
// from the absorbed 'B-notes' to 'base', and `dueDay` only disappears, if the
// reducer actually engaged setOne (updateOne would keep dueDay). `title` was
// already 'C-title' on A before this step (absorbed in round 2a), so it
// corroborates but does not by itself prove the op was applied. Convergence
// here is at the task-entity level for THIS upload ordering (see the round-2
// scoping note above); it is not a whole-normalized-state / all-orderings claim.
expect(aTask['title']).toBe('C-title'); // already C's pre-reducer; unchanged by the replace
expect(aTask['notes']).toBe('base'); // 'B-notes' → 'base': setOne took C's snapshot
expect(aTask['dueDay']).toBeUndefined(); // absorbed field CLEARED by setOne (updateOne would keep it)
expect('dueDay' in stateC).toBe(false); // C never had it — A now matches C
});
});
});