Skip to content

fix(sync): a cascade-deleted child says which drive it left - #1315

Merged
joepio merged 2 commits into
developfrom
fix/cascade-delete-fanout
Aug 28, 2026
Merged

fix(sync): a cascade-deleted child says which drive it left#1315
joepio merged 2 commits into
developfrom
fix/cascade-delete-fanout

Conversation

@joepio

@joepio joepio commented Aug 28, 2026

Copy link
Copy Markdown
Member

Destroying a folder removes its contents server-side, and the event that announces each child carried drive: None. The fanout routes on exactly that:

let Some(owner) = msg.drive.as_ref() else { return; };

So the removal reached only subscribers of the child's own subject — and in the v2 protocol there are none. Store.subscribeWebSocket is a documented no-op; clients subscribe once, per drive. Every cascade-deleted child was announced to nobody.

What that costs a user: delete a folder, and another open tab keeps rendering its contents. The tab that issued the destroy keeps them in its local database, where a reload brings them back. The parent vanishes and its children do not, which reads as a half-finished delete.

The fix

The drive has to be read where the resource still exists — a listener reacting to the event cannot look it up, because by then it is gone. So DbEvent::Destroyed carries it, and recursive_remove passes its own drive down as the fallback for a child created before the server stamped one.

Why the e2e never caught it

getCurrentSubject was read straight after clicking "New Folder", but QuickCreateRow fires createNewResource without awaiting, so the click returns before the navigation. The subject read there is the parent's — confirmed directly with a probe: nested and parent were the same string.

The test then destroyed that parent and asserted it was gone, which it was, by the commit it had just posted. It never once exercised the cascade it exists to cover. Whether the navigation had landed depended on load, which is what made it look flaky rather than wrong.

It now waits for the URL to change (as sidebar subresource already does) and asserts the two subjects differ — a test that silently checks the wrong resource should fail, not pass.

Verification

  • New lib test asserts the invariant that matters: the removal names a drive. Built on a genesis commit with rights validation on, because that is the path that stamps drive — a plain save skips the stamp and the test would pass for a reason the app never meets.
  • Fails without the fix (left: None), passes with it.
  • 429/429 lib tests pass on this branch.
  • Server tracing confirmed the whole chain: cascade fires with the drive, fanout receives it, within=true subs=1. A client-side probe confirmed the other end: the child now appears in removeResource calls and leaves the store.
  • delete resource e2e passes; reverting just the server fix fails it in 15s. e2e.spec.ts + tables.spec.ts together — the pairing that used to reproduce the failure locally — 23 passed.

The e2e runs were done on feat/plugin-model, where this was originally found; the commits cherry-picked onto develop with no conflicts and the lib suite is green here.

joepio added 2 commits August 28, 2026 12:24
Destroying a folder removes its contents server-side, and the event that
announces each child carried `drive: None`. The fanout uses that to route:

    let Some(owner) = msg.drive.as_ref() else { return; };

so the removal reached only subscribers of the child's own subject — and
in the v2 protocol there are none. `Store.subscribeWebSocket` is a
documented no-op; clients subscribe once, per drive. Every cascade-deleted
child was therefore announced to nobody.

What that costs: another tab keeps rendering resources that no longer
exist, and the tab that issued the destroy keeps them in its local
database, where a reload brings them back. The parent vanished and its
contents did not, which reads as a half-finished delete.

The drive has to be read where the resource still exists — a listener
reacting to the event cannot look it up, because by then it is gone. So
`DbEvent::Destroyed` carries it, and `recursive_remove` passes its own
drive down as the fallback for a child created before the server stamped
one.

The test asserts the invariant that actually matters: the removal names a
drive. It is built on a genesis commit with rights validation on, because
that is the path that stamps `drive` — a plain `save` skips the stamp, and
the test would then pass for a reason the app never meets.
`getCurrentSubject` was read straight after clicking "New Folder", but
QuickCreateRow fires `createNewResource` without awaiting it, so the click
returns before the navigation. The subject read there is the PARENT's.

The test then destroyed that parent and asserted it was gone — which it
was, directly, by the commit it had just posted. It never once exercised
the cascade it exists to cover, and whether the navigation had landed
depended on load, which is what made it look flaky rather than wrong.

So wait for the URL to change, as `sidebar subresource` already does, and
assert the two subjects differ — a test that silently checks the wrong
resource should fail, not pass.

With the child's real subject the barrier before the reload becomes
meaningful: it waits for the cascade's removal to arrive over the drive
subscription. Reverting the server fix now fails this test in 15s.
@joepio
joepio merged commit e42b6c5 into develop Aug 28, 2026
4 checks passed
@joepio
joepio deleted the fix/cascade-delete-fanout branch August 28, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant