Calendar event updates do not appear after save (3 e2e tests), plus a kind-flip that breaks replacement #62

Closed
opened 2026-07-30 11:44:26 +00:00 by laoc · 3 comments
Owner

Three e2e tests fail on the calendar update path, and they fail on content rather than on locators. Split out of #39, which was scoped to stale selectors.

calendar-editing:180  can update title and save          -> waiting for h1 'Updated Event …'
calendar-editing:226  can update description and save    -> waiting for 'Updated description …'
calendar-editing:295  updated event shows new data …     -> waiting for h1 'Reload Test … - Edited'

Each test creates an event (works), opens the edit modal (works — the prefill test passes), changes one field, saves, waits 5 s, hard-navigates back to the same naddr URL, and waits 10 s more. The new value never arrives. Three independent tests, three different fields, one shape.

Found by TestOER while verifying #39 at 2d94d16b; the other 16 tests in those two files pass once the create-hub selector is disambiguated.

What is not the cause — read from source, not reproduced

calendar-actions.svelte.js:updateEvent() looks correct on the two things that would obviously break replacement:

  • The d-tag is preserved. It is read off the existing event (:138) and threaded into buildCalendarEventTags, and the function throws rather than inventing one if it is missing. Same (kind, pubkey, d-tag) coordinate, so the naddr in the URL stays valid.
  • The publish is awaited, with a comment explaining exactly why updates await and creates do not (:193-198).

So this is not "the update silently never published" and not "editing forks the event to a new address", which were the first two candidates.

Candidates that remain

Neither of these can be settled by reading, and neither TestOER nor I have distinguished them:

  1. The e2e relay may not be honouring replacement for addressable kind 31923, so the reload legitimately gets the old event back.
  2. The client may be serving the reload from cache — the event store is populated before the publish resolves, and a hard goto re-hydrates from whatever the loader finds first.

The distinguishing test is cheap: after a save, query the e2e strfry directly for kinds:[31923] at that author + d-tag and count. Two events means the relay kept both; one event with the new title means the relay is fine and the client is showing something stale.

A separate, real bug found while reading this

updateEvent() builds the replacement with:

kind: eventData.kind || existingEvent.kind

and convertFormDataToEvent() (calendar.js:346,357) sets kind to 31922 for date-based and 31923 for time-based events from the form state. So toggling all-day on an existing event changes the kind while keeping the d-tag.

Under NIP-01 a replaceable event is addressed by (kind, pubkey, d-tag) — a different kind is a different coordinate. The original event is therefore never replaced: you end up with two live events, and the naddr already in the URL (which encodes the old kind) keeps resolving to the stale one.

Not reproduced — found by reading, and the three failing tests above do not touch the all-day toggle, so this is not their cause. Worth confirming and fixing on its own. The plausible fix is to delete the old coordinate when the kind flips, or to refuse the toggle on an existing event; which one is a product call.

Scope

Not a regression from #39 — those tests were already failing before it, behind the stale selectors, so nobody had seen this far down the flow. #39 fixed the selectors and made these three visible.

Three e2e tests fail on the calendar **update** path, and they fail on content rather than on locators. Split out of #39, which was scoped to stale selectors. ``` calendar-editing:180 can update title and save -> waiting for h1 'Updated Event …' calendar-editing:226 can update description and save -> waiting for 'Updated description …' calendar-editing:295 updated event shows new data … -> waiting for h1 'Reload Test … - Edited' ``` Each test creates an event (works), opens the edit modal (works — the prefill test passes), changes one field, saves, waits 5 s, hard-navigates back to the same naddr URL, and waits 10 s more. The new value never arrives. Three independent tests, three different fields, one shape. Found by TestOER while verifying #39 at `2d94d16b`; the other 16 tests in those two files pass once the create-hub selector is disambiguated. ## What is *not* the cause — read from source, not reproduced `calendar-actions.svelte.js:updateEvent()` looks correct on the two things that would obviously break replacement: - **The d-tag is preserved.** It is read off the existing event (`:138`) and threaded into `buildCalendarEventTags`, and the function throws rather than inventing one if it is missing. Same `(kind, pubkey, d-tag)` coordinate, so the naddr in the URL stays valid. - **The publish is awaited**, with a comment explaining exactly why updates await and creates do not (`:193-198`). So this is not "the update silently never published" and not "editing forks the event to a new address", which were the first two candidates. ## Candidates that remain Neither of these can be settled by reading, and neither TestOER nor I have distinguished them: 1. **The e2e relay may not be honouring replacement** for addressable kind 31923, so the reload legitimately gets the old event back. 2. **The client may be serving the reload from cache** — the event store is populated before the publish resolves, and a hard `goto` re-hydrates from whatever the loader finds first. The distinguishing test is cheap: after a save, query the e2e strfry directly for `kinds:[31923]` at that author + d-tag and count. Two events means the relay kept both; one event with the new title means the relay is fine and the client is showing something stale. ## A separate, real bug found while reading this `updateEvent()` builds the replacement with: ```js kind: eventData.kind || existingEvent.kind ``` and `convertFormDataToEvent()` (`calendar.js:346,357`) sets `kind` to **31922 for date-based** and **31923 for time-based** events from the form state. So toggling all-day on an existing event changes the kind while keeping the d-tag. Under NIP-01 a replaceable event is addressed by `(kind, pubkey, d-tag)` — a different kind is a **different coordinate**. The original event is therefore never replaced: you end up with two live events, and the naddr already in the URL (which encodes the old kind) keeps resolving to the stale one. **Not reproduced** — found by reading, and the three failing tests above do not touch the all-day toggle, so this is not their cause. Worth confirming and fixing on its own. The plausible fix is to delete the old coordinate when the kind flips, or to refuse the toggle on an existing event; which one is a product call. ## Scope Not a regression from #39 — those tests were already failing before it, behind the stale selectors, so nobody had seen this far down the flow. #39 fixed the selectors and made these three visible.
Author
Owner

Root cause: updateEvent publishes without ever writing the new version into the local store, so the stale cached copy short-circuits the relay

Diagnosed by reading, on dev @ 3a4b9e8c. This resolves both halves of the question — the relay is fine (TestOER measured one event at the coordinate carrying the new title), and the client never asks it.

The asymmetry between create and update

helper writes to eventStore?
createEventcalendar-actions.svelte.js:105 publishEventOptimistic yespublish-service.js:244
updateEventcalendar-actions.svelte.js:198 publishEvent nopublish-service.js:133-235 contains zero eventStore references

calendar-actions.svelte.js does not import eventStore at all.

persistEventsToCache (stores/event-cache.svelte.js:70) is wired to eventStore.insert$. So:

  1. Create publishes and adds to the store → the pre-edit version is written to IndexedDB (31922/31923 are both in CACHEABLE_KINDS, event-cache.svelte.js:27-28).
  2. Update publishes to relays only → the store never sees the new version → IDB still holds the pre-edit one.
  3. On reload, fetchEventByIdaddressLoaderstep 1 is the cache (loaders/base.js:39-45 passes cacheRequest).
  4. addressPointerLoadingSequence (applesauce-loaders/dist/loaders/address-loader.js:57-63) removes a pointer from remaining as soon as any event at that address comes back, and returns when remaining is empty. A cache hit therefore ends the sequence — relay hints, extra relays and lookup relays are never tried.
  5. nostrUtils.js:243-246 takes firstValueFrom, so the stale event is the answer.

That accounts for every observation, including the ones that look like counter-evidence: creation passes (nothing cached yet), the prefill test passes (it asserts the original title, which is exactly what the stale read returns), deletion passes.

Correction: the cache cannot hold both versions

The open question in the previous comment was whether IDB holds both versions and emits the older first. It cannot:

  • nostr-idb keys addressable events by address, not id — getEventUID returns kind:pubkey:d (database/common.js:14-21).
  • addEvents re-reads the existing record and only writes if (!existingEvents[uid] || event.created_at > existingEvents[uid]) (database/insert.js:23).
  • Both read paths sort sortByDate = newest-first (utils.js:2, query-filter.js:281).

So at most one version per address is stored, newest-wins. It holds the old one because the new one was never offered. Not an ordering bug — a missing write.

The fix

Make updateEvent do what every other edit path already does. EditProfileModal.svelte:283 and EditCommunityModal.svelte:299 both call eventStore.add(signed) immediately after publishEvent; calendar is the outlier.

Blast radius — same shape, not yet verified

Any update to an already-cached replaceable/addressable kind published through publishEvent without an accompanying eventStore.add will read back stale after a reload. Candidates worth checking, in descending order of likelihood:

  • calendar-actions.svelte.js:279 — kind 31924 calendar, cacheable, same file, no add.
  • services/relay-settings-service.js:66 — kind 10002, cacheable, no eventStore reference in the file.
  • services/pin-list-service.js:102,135.

The two profile/community modals are not affected — they add.

nostr-idb's NostrIDB.add() only populates its in-memory eventMap when the uid is absent (nostrdb/nostrdb.js:105), and loadEventsByUID consults eventMap before IDB (query-filter.js:264-268). So within a single page session, a second version at the same address never replaces the first in that map. Masked here by the full page.goto() reload, but it means "clear the cache" and "reload the page" are not interchangeable when testing this.

Also correcting the issue text

These events are kind 31922, not 31923 — the tests fill #startDate only, so convertFormDataToEvent takes the date-based branch (calendar.js:346). A kinds:[31923] query returns 0 for them and reads exactly like "never published".

The all-day kind-flip bug already in this issue is unrelated to the three failures and still stands on its own.

## Root cause: `updateEvent` publishes without ever writing the new version into the local store, so the stale cached copy short-circuits the relay Diagnosed by reading, on `dev` @ `3a4b9e8c`. This resolves both halves of the question — the relay is fine (TestOER measured one event at the coordinate carrying the new title), and the client never asks it. ### The asymmetry between create and update | | helper | writes to `eventStore`? | |---|---|---| | `createEvent` — `calendar-actions.svelte.js:105` | `publishEventOptimistic` | **yes** — `publish-service.js:244` | | `updateEvent` — `calendar-actions.svelte.js:198` | `publishEvent` | **no** — `publish-service.js:133-235` contains zero `eventStore` references | `calendar-actions.svelte.js` does not import `eventStore` at all. `persistEventsToCache` (`stores/event-cache.svelte.js:70`) is wired to `eventStore.insert$`. So: 1. **Create** publishes *and* adds to the store → the pre-edit version is written to IndexedDB (31922/31923 are both in `CACHEABLE_KINDS`, `event-cache.svelte.js:27-28`). 2. **Update** publishes to relays only → the store never sees the new version → IDB still holds the pre-edit one. 3. On reload, `fetchEventById` → `addressLoader` → **step 1 is the cache** (`loaders/base.js:39-45` passes `cacheRequest`). 4. `addressPointerLoadingSequence` (`applesauce-loaders/dist/loaders/address-loader.js:57-63`) removes a pointer from `remaining` as soon as *any* event at that address comes back, and returns when `remaining` is empty. A cache hit therefore **ends the sequence — relay hints, extra relays and lookup relays are never tried.** 5. `nostrUtils.js:243-246` takes `firstValueFrom`, so the stale event is the answer. That accounts for every observation, including the ones that look like counter-evidence: creation passes (nothing cached yet), the prefill test passes (it asserts the *original* title, which is exactly what the stale read returns), deletion passes. ### Correction: the cache cannot hold both versions The open question in the previous comment was whether IDB holds both versions and emits the older first. It cannot: - `nostr-idb` keys addressable events by **address**, not id — `getEventUID` returns `kind:pubkey:d` (`database/common.js:14-21`). - `addEvents` re-reads the existing record and only writes `if (!existingEvents[uid] || event.created_at > existingEvents[uid])` (`database/insert.js:23`). - Both read paths sort `sortByDate` = newest-first (`utils.js:2`, `query-filter.js:281`). So at most one version per address is stored, newest-wins. It holds the old one because **the new one was never offered**. Not an ordering bug — a missing write. ### The fix Make `updateEvent` do what every other edit path already does. `EditProfileModal.svelte:283` and `EditCommunityModal.svelte:299` both call `eventStore.add(signed)` immediately after `publishEvent`; calendar is the outlier. ### Blast radius — same shape, not yet verified Any **update** to an already-cached replaceable/addressable kind published through `publishEvent` without an accompanying `eventStore.add` will read back stale after a reload. Candidates worth checking, in descending order of likelihood: - `calendar-actions.svelte.js:279` — kind 31924 calendar, cacheable, same file, no add. - `services/relay-settings-service.js:66` — kind 10002, cacheable, no `eventStore` reference in the file. - `services/pin-list-service.js:102,135`. The two profile/community modals are **not** affected — they add. ### Related, and a separate defect `nostr-idb`'s `NostrIDB.add()` only populates its in-memory `eventMap` when the uid is absent (`nostrdb/nostrdb.js:105`), and `loadEventsByUID` consults `eventMap` **before** IDB (`query-filter.js:264-268`). So within a single page session, a second version at the same address never replaces the first in that map. Masked here by the full `page.goto()` reload, but it means "clear the cache" and "reload the page" are not interchangeable when testing this. ### Also correcting the issue text These events are **kind 31922**, not 31923 — the tests fill `#startDate` only, so `convertFormDataToEvent` takes the date-based branch (`calendar.js:346`). A `kinds:[31923]` query returns 0 for them and reads exactly like "never published". The all-day kind-flip bug already in this issue is unrelated to the three failures and still stands on its own.
laoc closed this issue 2026-07-30 13:20:54 +00:00
Author
Owner

Closed by #63, merged to dev at b82bfe9c.

One part of this issue was not fixed and is now #65 — the all-day toggle flipping 31922 <-> 31923 while keeping the d-tag. It needs a product decision, so it is split out rather than left inside a closed issue.

Verification: TestOER ran the full calendar-creation + calendar-editing set at 961c3a82 twice, 20 passed / 0 failed, first-attempt passes with 0 retries and 0 flaky, --workers=1 on separate ports, HEAD confirmed before and after each run.

The progression, for anyone reading this later:

commit update tests
3a4b9e8c baseline 0 of 6 attempts all three red 2/2
9d2c309e eventStore.add 1 of 6, retry only a different test each run — noise, not signal
485cab0d + created_at bump :187 and :305 pass first attempt remaining red was the assertion, not the cache
961c3a82 test scoping 20/20, twice

The step worth remembering is the middle one: the eventStore.add fix alone looked like it had helped, and it had not. What separated them was that the last failure changed shapeelement(s) not found became strict mode violation — which is what proved the read had gone fresh while the assertion was still wrong.

Closed by #63, merged to `dev` at `b82bfe9c`. **One part of this issue was not fixed and is now #65** — the all-day toggle flipping 31922 <-> 31923 while keeping the d-tag. It needs a product decision, so it is split out rather than left inside a closed issue. Verification: TestOER ran the full `calendar-creation` + `calendar-editing` set at `961c3a82` twice, **20 passed / 0 failed**, first-attempt passes with 0 retries and 0 flaky, `--workers=1` on separate ports, HEAD confirmed before and after each run. The progression, for anyone reading this later: | commit | update tests | | |---|---|---| | `3a4b9e8c` baseline | 0 of 6 attempts | all three red 2/2 | | `9d2c309e` `eventStore.add` | 1 of 6, retry only | a *different* test each run — noise, not signal | | `485cab0d` + `created_at` bump | `:187` and `:305` pass first attempt | remaining red was the assertion, not the cache | | `961c3a82` test scoping | **20/20, twice** | | The step worth remembering is the middle one: the `eventStore.add` fix alone looked like it had helped, and it had not. What separated them was that the last failure *changed shape* — `element(s) not found` became `strict mode violation` — which is what proved the read had gone fresh while the assertion was still wrong.
Author
Owner

If you are retesting this issue by hand, do not test it with the all-day toggle — you will see the old symptom and it is a different bug (#65).

TestOER reproduced #65 in a browser at b82bfe9c (twice): toggling all-day on an existing event changes the NIP-52 kind (31922 <-> 31923) while keeping the d-tag, so the app publishes to a different coordinate. The original event survives byte-identical, the naddr in the URL still resolves to it, and the page renders the pre-edit title with no error shown.

That is the same user-visible symptom this issue describes — "my edit did nothing" — but no cache fix can touch it, because the edit genuinely went somewhere else.

To verify #62 is fixed, edit a title, a description, or a date without touching the all-day toggle. Those are the three paths the e2e tests cover, and they are green 20/0.

#65 is fixed separately in PR #66.

**If you are retesting this issue by hand, do not test it with the all-day toggle** — you will see the old symptom and it is a *different* bug (#65). TestOER reproduced #65 in a browser at `b82bfe9c` (twice): toggling all-day on an existing event changes the NIP-52 kind (31922 <-> 31923) while keeping the d-tag, so the app publishes to a **different coordinate**. The original event survives byte-identical, the naddr in the URL still resolves to it, and the page renders the pre-edit title with no error shown. That is the same user-visible symptom this issue describes — "my edit did nothing" — but no cache fix can touch it, because the edit genuinely went somewhere else. **To verify #62 is fixed**, edit a title, a description, or a date *without* touching the all-day toggle. Those are the three paths the e2e tests cover, and they are green 20/0. #65 is fixed separately in PR #66.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
edufeed/edufeed-app#62
No description provided.