fix(web): the round-8 enumeration's in-class findings, including two this unit introduced

ONE enumeration-prompted codex round on the frozen tip, per the lead's
disposition: not "is this right?" but "enumerate every remaining way a write
can leave the client and the server disagreeing". It returned 36 items across
four categories and said no category was empty — which is CONVE-24 working:
an enumeration is the only round that can return "none left" as a finding,
and this one did not.

Fixed here: the members of classes this unit had already closed, and the two
defects the unit's OWN round-8 fix introduced.

MINE, both in the open-children path, both from gating too widely:

1. The DECLINED branch took a claim. Declining writes nothing — it re-props
   the value the client already had — but claiming advanced the applied mark,
   so a write that had genuinely COMMITTED and was still in flight lost its
   claim on return. Server changed, pane showing the old value, nothing left
   to correct it. A branch that asserts no new server truth must not take the
   mark for one.
2. A successful FORCED write was discarded on supersession. If the newer
   write then failed without writing, the server held the confirmed value and
   the pane held the old one. `claim` asks the question that matters — has
   anything newer actually WRITTEN — and supersession does not.

IN-CLASS, four more:

3. The confirm callback SENDS, and asked about supersession only after it
   returned. The dialog can sit open indefinitely; checking afterwards
   suppresses the display of a write that already reached the server.
4. `submitOrderedOCC` asked only about supersession after its refetch, so
   switching items during that await still produced another PATCH at the item
   the pane had left — invisible, because the caller discards the response,
   and landing on the row all the same. Both questions now, both times.
5. The hold's release: prop AGREEMENT is a coincidence test, not an answer.
   Remove C from [A,B,C] and add it straight back, and the second write holds
   a list equal to the prop nobody has changed — the effect read that as
   confirmation and released mid-write, the first write's answer became the
   base, and C was lost by the mechanism built to stop that. Settlement now
   owns the release where it exists; agreement keeps it where it does not.
   A synchronously-throwing consumer no longer strands the hold, and the
   agreement path checks the identity stamp it had been ignoring.
6. The lane menu's bulk "Move all to" is the FIFTH affordance that writes the
   group value; round 7 enumerated four. It offers `statusField.options` as
   destinations, so a `status` retyped to `multi_relation` with its options
   retained offers lanes that do not exist and sends a scalar into a list.

A SOURCE FILE WAS BINARY, and that is worth its own paragraph because it made
this unit's own verification lie. `fieldWriteTarget` was written with a real
NUL BYTE where the source should carry the two-character escape for one —
identical at runtime, but `file` reports "data" and grep then returns NOTHING
AND EXIT 1 for every pattern, which is indistinguishable from "not present".
A restore-and-verify grep over that file came back empty for a symbol four
lines below it. It shipped in a9f58ccb; git still shows that path as `Bin` in
this diff.

INSTRUMENTS — 9 new legs, every one shown red against the unfixed code:
the agreement release (its first fixture gave the same answer either way and
was rebuilt so it discriminates), the retarget stamp, the synchronous throw,
the post-refetch item switch, the bulk move (its first version passed against
a menu that never opened — it now proves the menu opened, with an entry that
is not gated on the refusal), and four source-guard legs pinning the two
dispositions above, the claim-as-GATE spelling codex showed was passable with
the `if` removed, and the callback's guard-before-send order. Two error
assertions now compare IDENTITY rather than class, which they were shown to
need.

NOT FIXED, filed as the lead ruled — new classes go into the package as
follow-up items, not another round: BUG-3036 (whole-item responses unordered
against each other), BUG-3037 (OCC cannot separate two writes inside one
second — the client guard never even runs), BUG-3038 (a whole-list retry
replays instead of re-deriving), BUG-3039 (a debounced edit cancelled by the
previous keystroke's response), BUG-3040 (a refused write leaves the rejected
value in scalar inputs), BUG-3041 (a conventional key typed multi_relation
crashes card rendering), BUG-3042, BUG-3043, BUG-3044, BUG-3045 (`npm test`
can exit 0 having run no .svelte.test.ts at all), BUG-3046, BUG-3047,
BUG-3049 (full-fields writes without OCC undo a field patch ON THE SERVER),
TASK-3048.

One existing test now describes a KNOWN LOSS and says so: the
ordinary-conflict leg asserts that a third party's addition disappears, which
is what the code does and not what it should do (BUG-3038). Invert it when
that lands.

Gates on the tip, each run unpiped: `make lint` exit 0 / 0 issues · `make
test` exit 0, 0 FAIL / 30 ok · gofmt 0 files · `svelte-check` exit 0 / 0
errors · web vitest 2625 passed · `vite build` exit 0 with web/build
regenerated. `make test-pg` not run and not owed — no SQL in any commit.

TASK-2999 / PLAN-2857 U4

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
This commit is contained in:
xarmian
2026-09-13 19:15:55 +00:00
parent 89d52ce498
commit ab1bc203f1
8 changed files with 396 additions and 14 deletions
@@ -775,6 +775,68 @@ describe('a REFUSED grouping must not write a group value (U4, codex round 1 P5)
expect(plus).toHaveLength(0);
});
it('offers no bulk "Move all to" either — the FIFTH group-writing affordance', async () => {
// Round 8 enumeration. Round 7 enumerated four affordances that write the
// group value and this was not among them: the lane menu reads
// `statusField.options` and offers them as destinations, so a `status`
// field retyped to `multi_relation` with its options retained offers
// named lanes that no longer exist and calls `onMoveColumn` with a
// SCALAR — a write the server refuses for a list-shaped field.
//
// The item's status is ABSENT on purpose: a card carrying an ARRAY there
// trips scalar status formatting and the board throws before rendering,
// which is its own finding and not this one.
const coll = collection();
coll.schema = JSON.stringify({
fields: [
{
key: 'status',
label: 'Status',
type: 'multi_relation',
collection: 'colors',
options: ['old-a', 'old-b'],
},
],
});
const onMoveColumn = vi.fn();
const screen = render(BoardView, {
props: {
items: [{ ...item('car-1'), fields: JSON.stringify({}) } as Item],
collection: coll,
wsSlug: 'ws',
groupField: 'status',
onStatusChange: vi.fn(),
onMoveColumn,
// Supplied so the menu has an entry that is NOT gated on the
// refusal — the control that proves it opened.
onTagColumn: vi.fn(),
} as never,
});
// PRECONDITIONS: refused, one lane, one card, and the lane menu really is
// reachable — so "no Move all to" is not "no menu".
expect(screen.container.textContent).toContain('more than one group');
expect(screen.container.querySelectorAll('.kanban-column')).toHaveLength(1);
expect(screen.container.querySelectorAll('.item-card')).toHaveLength(1);
const menuButton = [...screen.container.querySelectorAll('button')].find(
(b) => (b.textContent ?? '').trim() === '\u22ef',
) as HTMLButtonElement | undefined;
expect(menuButton, 'no lane actions menu trigger — re-point this test').toBeTruthy();
menuButton!.click();
await tick();
await tick();
// PRECONDITION FOR THE ABSENCE: the menu really opened. Without this the
// leg passes against a menu that never rendered, which is how its first
// version stayed green with the gate deleted. "Tag all" is present for
// every lane and is not gated on the refusal.
const menuEntries = [...document.querySelectorAll('button')].map((b) => (b.textContent ?? '').trim());
expect(menuEntries.some((t) => t.includes('Tag all'))).toBe(true);
expect(menuEntries.filter((t) => t.includes('Move all to'))).toHaveLength(0);
expect(onMoveColumn).not.toHaveBeenCalled();
});
it('CONTROL: an ordinary board still offers the lane create control', () => {
// Withholding it everywhere would remove a working affordance from every
// board on the instance.
@@ -781,7 +781,15 @@
? () => openDraft(colValue)
: undefined}
onArchive={onArchiveColumn ? () => onArchiveColumn?.(colItems) : undefined}
onMove={onMoveColumn ? (status) => onMoveColumn?.(colItems, status) : undefined}
onMove={/* The FIFTH affordance that writes the group value —
round 7 enumerated four and missed this one (round 8).
It offers `statusField.options` as destinations, so a
`status` field retyped to `multi_relation` with its
options retained offers lanes that do not exist and
sends a SCALAR into a list field. */
onMoveColumn && !isRelationGroup && !groupingRefusal
? (status) => onMoveColumn?.(colItems, status)
: undefined}
onTag={onTagColumn ? (tag) => onTagColumn?.(colItems, tag) : undefined}
onUntag={onUntagColumn ? (tag) => onUntagColumn?.(colItems, tag) : undefined}
onSetPriority={onSetPriorityColumn ? (p) => onSetPriorityColumn?.(colItems, p) : undefined}
@@ -661,3 +661,120 @@ describe('multi_relation — the hold ends when the write does, not only when it
expect(onchange.mock.calls[1][0]).toEqual([BLUE.id]);
});
});
describe('multi_relation — what the round-8 enumeration found in the round-8 fix', () => {
const editable = { field: multi, wsSlug: 'ws', username: 'dave' } as const;
async function settle() {
await tick();
await tick();
await tick();
}
const titles = () => [...document.querySelectorAll('.relation-title')].map((n) => n.textContent);
it('prop AGREEMENT does not release a write that reports its own settlement', async () => {
// Remove C, then add C straight back. The second write is holding
// [Green,Red,Blue] — which EQUALS the prop nobody has changed yet, so the
// agreement effect read it as "the server confirmed us" and released
// mid-write. The first write's answer then arrived as [Green,Red] and
// became the base, and the next removal sent a list with C missing.
//
// Agreement is a coincidence test; settlement is the answer. Whichever
// one is available, only one of them may own the release.
const gates: Array<() => void> = [];
// Each write answers when its gate is opened, and answering ALSO lands
// its own list on the prop — which is what the pane does, and what makes
// this leg discriminate. An earlier version left the prop alone; with it
// untouched, a released hold and a held one produce the same next write,
// so the guard's mutant survived.
const onchange = vi.fn(
(v: string[]) =>
new Promise<void>((resolve) => {
gates.push(() => {
rerender({ ...editable, value: v, onchange });
resolve();
});
}),
);
const { rerender } = render(FieldEditor, {
props: { ...editable, value: [GREEN.id, RED.id, BLUE.id], onchange },
});
await tick();
// Remove Blue → [Green,Red] sent.
await fireEvent.click(buttons(/^\s*Remove\s*$/)[2]);
await tick();
expect(onchange.mock.calls[0][0]).toEqual([GREEN.id, RED.id]);
// Add Blue back → [Green,Red,Blue] sent, which equals the untouched prop.
await fireEvent.click(buttons(/\+\s*Add/)[0]);
await tick();
const blueRow = [...document.querySelectorAll('button')].find((b) =>
(b.textContent ?? '').includes('Blue'),
);
expect(blueRow, 'the picker offered no Blue row to choose').toBeTruthy();
await fireEvent.click(blueRow!);
await tick();
expect(onchange.mock.calls[1][0]).toEqual([GREEN.id, RED.id, BLUE.id]);
// PRECONDITION: both writes are outstanding and the screen shows the
// second one's list.
expect(titles()).toEqual(['Green', 'Red', 'Blue']);
// The FIRST write answers, putting ITS list — [Green,Red] — on the prop.
gates[0]();
await settle();
// The hold must still be the SECOND write's, so the screen keeps showing
// Blue and the next edit bases on the list the user is looking at.
expect(titles()).toEqual(['Green', 'Red', 'Blue']);
await fireEvent.click(buttons(/^\s*Remove\s*$/)[0]);
expect(onchange.mock.calls[2][0]).toEqual([RED.id, BLUE.id]);
});
it('a consumer that throws SYNCHRONOUSLY does not strand the hold', async () => {
// The settlement handler is installed AFTER `onchange` returns, so a
// consumer that throws on the way out armed a hold nothing could ever
// release — and every later edit took the abandoned list as its base.
const onchange = vi.fn(() => {
throw new Error('consumer blew up');
});
render(FieldEditor, { props: { ...editable, value: [GREEN.id, RED.id, BLUE.id], onchange } });
await tick();
// The component RETHROWS (the error is the consumer's to report, not ours
// to swallow); where it surfaces from a Svelte event handler is the
// harness's business and is deliberately not asserted here.
await fireEvent.click(buttons(/^\s*Remove\s*$/)[0]);
await settle();
// PRECONDITION: it really was called, so "no hold" is not "no write".
expect(onchange).toHaveBeenCalledTimes(1);
// The prop never changed, and with no hold the field shows it again.
expect(titles()).toEqual(['Green', 'Red', 'Blue']);
});
it('a RETARGETED editor does not have its hold cleared by the new value', async () => {
// The agreement effect compared CONTENT and not identity, so an editor
// reused for another item whose value happened to equal the held list
// cleared a hold that was never about this field. The stamp exists for
// exactly this question and the effect was not asking it.
const onchange = vi.fn();
const { rerender } = render(FieldEditor, {
props: { ...editable, value: [GREEN.id, RED.id, BLUE.id], onchange },
});
await tick();
await fireEvent.click(buttons(/^\s*Remove\s*$/)[0]);
expect(onchange.mock.calls[0][0]).toEqual([RED.id, BLUE.id]);
// Same instance, different workspace, and the new item's value is the
// list the OLD workspace's hold is carrying.
await rerender({ field: multi, wsSlug: 'other-ws', username: 'dave', value: [RED.id, BLUE.id], onchange });
await tick();
// Back again. The old hold must still be in force — it was never
// confirmed by anything.
await rerender({ ...editable, value: [GREEN.id, RED.id, BLUE.id], onchange });
await tick();
expect(titles()).toEqual(['Red', 'Blue']);
});
});
@@ -176,7 +176,22 @@ handlers — onchange is never called.
* `$state` rather than a plain `let` (CONVE-1688's split): `relationValues`
* reads it, so it belongs in the effect graph.
*/
let pendingRelation = $state<{ identity: string; list: string[] } | null>(null);
let pendingRelation = $state<{
identity: string;
list: string[];
/**
* True when the consumer gave a settlement signal, which then OWNS the
* release and prop agreement must keep out of it.
*
* Agreement is a coincidence test, not an answer: remove C from
* [A,B,C] and add it straight back, and the second write is holding
* [A,B,C] — equal to the prop nobody has changed yet. The effect below
* read that as "the server confirmed us", released mid-write, and the
* FIRST write's response then arrived as [A,B] and became the base. C
* was lost by the mechanism built to stop exactly that.
*/
tracked: boolean;
} | null>(null);
/**
* Tickets for the hold, so the SETTLEMENT of an older write cannot release a
@@ -209,8 +224,21 @@ handlers — onchange is never called.
function writeRelationList(next: string[]) {
const identity = relationIdentity;
const ticket = holdOrder.take(identity);
pendingRelation = { identity, list: next };
const outcome = onchange(next);
let outcome: void | Promise<void>;
try {
pendingRelation = { identity, list: next, tracked: false };
outcome = onchange(next);
} catch (err) {
// A consumer that throws SYNCHRONOUSLY never reaches the settlement
// path below, so the hold it just armed would stand forever and
// every later edit would base on the abandoned list. Release and
// rethrow: the throw is the consumer's to report, the stranded hold
// was ours.
if (pendingRelation?.identity === identity && !holdOrder.superseded(ticket)) {
pendingRelation = null;
}
throw err;
}
// A consumer that returns nothing has told us nothing, and the hold falls
// back to prop agreement (below). One that returns a promise is telling
// us when its write SETTLED, which is the question the hold is actually
@@ -219,6 +247,8 @@ handlers — onchange is never called.
// rejected list on screen forever and ignored every later server value.
const settlement = settlementOf(outcome);
if (!settlement) return;
// Settlement owns the release from here — see `tracked`.
pendingRelation = { identity, list: next, tracked: true };
void (async () => {
try {
await settlement;
@@ -255,6 +285,11 @@ handlers — onchange is never called.
$effect(() => {
const pending = pendingRelation;
if (!pending) return;
// Not for a write that reports its own settlement, and not for a hold
// belonging to a DIFFERENT identity: a retargeted editor whose new value
// happens to equal the old one's held list would otherwise clear a hold
// that was never about this field.
if (pending.tracked || pending.identity !== relationIdentity) return;
const incoming = Array.isArray(value)
? value.map((e) => (typeof e === 'string' ? e.trim() : '')).filter((e) => e !== '')
: [];
+23 -3
View File
@@ -2986,6 +2986,13 @@
try {
forced = await confirmOpenChildrenOrThrow(e, parentRef, () => {
if (!stillCurrent()) throw new Error('switched away');
// The dialog can sit open for as long as the user takes,
// and this callback SENDS. Checking supersession only
// after it returns suppresses the display of a write that
// has already gone to the server — the retry guard's own
// rule, at the one door that reaches the network without
// passing through it.
if (fieldWrites.superseded(ticket)) throw new Error('superseded');
return submitWithOCC(true);
});
} catch (retryErr) {
@@ -3006,8 +3013,14 @@
// Switched items while the confirm modal was up — complete
// silently rather than stamping A's result onto B.
if (!stillCurrent() || !item) return;
if (fieldWrites.superseded(ticket)) return;
if (forced) {
// Gated on CLAIM alone, never on supersession. A forced write
// that SUCCEEDED has changed the row, and the newer write may
// have failed without writing anything — discarding this
// response on the strength of a dispatch would then leave the
// server holding the confirmed value and the pane showing the
// old one, with nothing left to correct it. `claim` asks the
// question that matters: has anything newer actually written?
if (!fieldWrites.claim(ticket)) return;
item = withInflightTags(forced);
showSaved();
@@ -3019,8 +3032,15 @@
// the in-flight save indicator. Force `item` to a fresh
// reference so child components re-prop unambiguously
// even if they cache by identity.
if (!fieldWrites.claim(ticket)) return;
saveStatus = 'idle';
// NO claim here, and the reason is worth stating because the
// first version of this change had one. Declining writes NOTHING
// to the server: this re-props the client value it already had.
// Claiming advanced the applied mark all the same, so a write
// that had genuinely COMMITTED and was still in flight lost its
// claim on return — server changed, pane showing the old value,
// and nothing left to correct it. A branch that asserts no new
// server truth must not take the mark for one.
if (!fieldWrites.superseded(ticket)) saveStatus = 'idle';
item = { ...item };
toastStore.show('Status change cancelled', 'info');
return;
@@ -112,4 +112,84 @@ describe('ItemDetail field writes are ordered', () => {
expect(between, `await between claim and ${where}`).not.toMatch(/\bawait\b/);
}
});
it('every claim is a GATE — the answer is acted on, never discarded', () => {
// Round 8 enumeration. The leg above matched `fieldWrites.claim(ticket)`
// anywhere, so replacing `if (!fieldWrites.claim(ticket)) return;` with a
// bare `fieldWrites.claim(ticket);` kept the proximity and no-await
// assertions green while every response was assigned regardless of the
// answer. A call whose result nothing reads is not a guard.
const calls = [...UPDATE_FIELD.matchAll(/fieldWrites\.claim\(ticket\)/g)];
expect(calls.length).toBeGreaterThan(0);
for (const m of calls) {
const line = UPDATE_FIELD.slice(
UPDATE_FIELD.lastIndexOf('\n', m.index ?? 0) + 1,
UPDATE_FIELD.indexOf('\n', m.index ?? 0),
).trim();
expect(line, `claim result discarded: ${line}`).toBe('if (!fieldWrites.claim(ticket)) return;');
}
});
it('the forced-write success is gated on CLAIM and never on supersession', () => {
// Round 8 enumeration, and a defect this unit's own first version
// introduced. A forced write that SUCCEEDED has changed the row; if the
// newer write then failed without writing, returning on supersession
// leaves the server holding the confirmed value and the pane showing the
// old one, with nothing left to correct it. `claim` asks whether anything
// newer actually WROTE, which is the question that matters here.
// Bounded at the branch's own `showSaved()`, not at the next landmark:
// the DECLINED branch below it legitimately mentions supersession (for
// the save indicator), and a slice running into it reports that as a
// violation here.
// The window starts where the dialog RETURNS, not at `if (forced) {`:
// a supersession check placed just above that line returns before the
// forced branch is ever reached, which is the defect itself. A slice
// anchored on the branch would miss it — measured, it survived.
const windowAt = UPDATE_FIELD.indexOf('if (!stillCurrent() || !item) return;');
const forcedAt = UPDATE_FIELD.indexOf('if (forced) {', windowAt);
const savedAt = UPDATE_FIELD.indexOf('showSaved();', forcedAt);
expect(windowAt).toBeGreaterThan(-1);
expect(forcedAt).toBeGreaterThan(windowAt);
expect(savedAt).toBeGreaterThan(forcedAt);
const forcedBranch = UPDATE_FIELD.slice(windowAt, savedAt);
expect(forcedBranch).toContain('if (!fieldWrites.claim(ticket)) return;');
expect(forcedBranch).not.toMatch(/fieldWrites\.superseded\(ticket\)/);
});
it('the DECLINED branch takes no claim — it writes no server truth', () => {
// The other half of the same defect. Declining writes nothing: it re-props
// the client value it already had. Claiming advanced the applied mark all
// the same, so a write that had genuinely COMMITTED and was still in
// flight lost its claim on return. A branch that asserts no new server
// truth must not take the mark for one.
const declined = UPDATE_FIELD.slice(
UPDATE_FIELD.indexOf('if (forced) {'),
UPDATE_FIELD.indexOf('BUG-2273: OCC retries were exhausted') > -1
? UPDATE_FIELD.indexOf('if (isUpdateConflictError(e)) {')
: UPDATE_FIELD.length,
);
const afterForced = declined.slice(declined.indexOf('toastStore.show(\'Status change cancelled\''));
expect(declined).toContain("toastStore.show('Status change cancelled'");
// Between the end of the forced branch and the cancel toast there is no
// claim — only the saveStatus guard.
const cancelAt = declined.indexOf("toastStore.show('Status change cancelled'");
const declinedBody = declined.slice(declined.indexOf('}', declined.indexOf('showSaved();')), cancelAt);
expect(declinedBody).not.toContain('fieldWrites.claim(ticket)');
expect(afterForced.length).toBeGreaterThan(0);
});
it('the confirm callback asks about supersession BEFORE it sends', () => {
// The dialog can sit open for as long as the user takes, and this
// callback SENDS. Checking only after it returns suppresses the DISPLAY
// of a write that has already reached the server (round 8 enumeration).
const callback = UPDATE_FIELD.slice(
UPDATE_FIELD.indexOf('confirmOpenChildrenOrThrow('),
UPDATE_FIELD.indexOf('} catch (retryErr) {'),
);
const guardAt = callback.indexOf('fieldWrites.superseded(ticket)');
const sendAt = callback.indexOf('submitWithOCC(true)');
expect(guardAt).toBeGreaterThan(-1);
expect(sendAt).toBeGreaterThan(-1);
expect(guardAt).toBeLessThan(sendAt);
});
});
+67 -7
View File
@@ -31,8 +31,13 @@ class FakeRow {
value: string[] = ['A', 'B', 'C'];
updated_at = 't0';
private n = 0;
/** The last conflict thrown, so a test can assert the SAME object came back. */
lastConflict: Conflict | null = null;
patch(expected: string, next: string[]): Row {
if (expected !== this.updated_at) throw new Conflict('update_conflict');
if (expected !== this.updated_at) {
this.lastConflict = new Conflict('update_conflict');
throw this.lastConflict;
}
this.value = [...next];
this.updated_at = `t${++this.n}`;
return this.read();
@@ -155,10 +160,13 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () =>
expect(row.value).toEqual(['C']);
expect(outcome.ok).toBe(false);
// It fails with the CONFLICT it was given, not a synthesised error: the
// caller tells a superseded write apart by asking the same question this
// did, and dressing it up as something else would make that impossible.
expect(outcome.ok === false && outcome.e).toBeInstanceOf(Conflict);
// It fails with the CONFLICT it was given — the same object, not merely
// something of the same class. The caller tells a superseded write apart
// by asking the question this asked, and a synthesised error of the
// right shape would satisfy an `instanceof` while losing whatever the
// server said (round 8 enumeration: these assertions checked class and
// message, so swapping in a different Conflict stayed green).
expect(outcome.ok === false && outcome.e).toBe(row.lastConflict);
});
it('NEGATIVE CONTROL: with no supersession question at all, A comes back', async () => {
@@ -201,6 +209,14 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () =>
// The other half of the control pair: the guard must not have turned the
// OCC retry off. Somebody ELSE moves the row, and this write re-applies
// its own value against the fresh one, which is BUG-2273 working.
//
// AND IT DESCRIBES A KNOWN LOSS, deliberately. The third party added D;
// the retry re-sends the list it computed BEFORE that, so D disappears
// although the local gesture only removed A. This leg asserts what the
// code does, not what it should do — closing that needs the retry to
// re-apply the GESTURE to the fresh list rather than replay its result,
// which is a write-contract change and is filed as BUG-3038. Invert this
// assertion when that lands; it is not an endorsement.
const row = new FakeRow();
const order = new WriteOrder();
const ticket = order.take(TARGET);
@@ -277,6 +293,47 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () =>
expect(sends).toBe(3); // the first send plus two retries
});
it('does not RE-SEND once the view moves on DURING the refetch', async () => {
// Round 8 enumeration, reproduced. The post-refetch check asked only
// about supersession, so switching items while the GET was in flight
// still produced another PATCH at the item the pane had just left —
// invisible, because the caller then discards the response, and landing
// on the row all the same.
//
// This is the transition the leg below cannot see: there `stillCurrent`
// is false from the start, so the retry never begins.
const row = new FakeRow();
const order = new WriteOrder();
const ticket = order.take(TARGET);
row.patch('t0', ['A', 'B', 'C', 'D']); // a third party moved it first
let current = true;
let sends = 0;
await expect(
submitOrderedOCC<Row>({
order,
ticket,
maxRetries: 2,
initialExpected: 't0',
send: (expected) => {
sends++;
return Promise.resolve(row.patch(expected, ['B', 'C']));
},
refetch: () => {
current = false; // the user opens another item, right here
return Promise.resolve(row.read());
},
isConflict,
stillCurrent: () => current
})
).rejects.toBeInstanceOf(Conflict);
// PRECONDITION: the first send really happened, so "one send" is a
// refusal to RETRY rather than a refusal to start.
expect(sends).toBe(1);
expect(row.value).toEqual(['A', 'B', 'C', 'D']);
});
it('does not retry once the view has moved on', async () => {
const row = new FakeRow();
const order = new WriteOrder();
@@ -303,6 +360,9 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () =>
const order = new WriteOrder();
const ticket = order.take(TARGET);
const refetch = vi.fn(() => Promise.resolve({ value: [], updated_at: 't9' }));
// By IDENTITY: `rejects.toThrow('network')` passes against any error
// carrying that word, including one this function made up.
const failure = new Error('network');
await expect(
submitOrderedOCC<Row>({
@@ -310,12 +370,12 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () =>
ticket,
maxRetries: 2,
initialExpected: 't0',
send: () => Promise.reject(new Error('network')),
send: () => Promise.reject(failure),
refetch,
isConflict,
stillCurrent: () => true
})
).rejects.toThrow('network');
).rejects.toBe(failure);
expect(refetch).not.toHaveBeenCalled();
});
Binary file not shown.