fix(cache): offer published events to the EventStore, and stop caching phantoms (#64) #69

Merged
laoc merged 2 commits from fix/issue-64-eventstore-add-omissions into dev 2026-07-30 21:09:53 +00:00
Owner

Closes #64.

Four sibling sites of #62, a shared helper, and — after TestOER's browser measurement — a restore of the version a failed publish replaced.

Why any of this matters

The IndexedDB cache is the first step of applesauce's addressPointerLoadingSequence, and a cache hit ends it. So for a cacheable kind, whatever is in IDB is what the app shows, and a relay cannot correct it. The cache is fed only from eventStore.insert$, and publishEvent never touches the EventStore.

Sites fixed

site kind symptom
relay-settings-service.js 10002 zero eventStore references. The relay list itself, so a stale read mis-routes every subsequent query
calendar-actions createCalendar 31924 no add; missing from cache until a relay round-trip
publishEventOptimistic failure path any cacheable removed the event from the EventStore but could not reach IDB
calendarActions.deleteEvent 5 dead code publishing a kind 5 with neither eventStore.add nor cacheDeletion. Removed

The failed-publish path, as measured rather than as I first described it

TestOER ran the discriminating experiment in a real browser — unmocked pipeline, IndexedDB read directly, bogus-address control, four dead-relay modes. Two of my original claims did not survive it, and both are corrected here:

1. uncacheEvent alone deletes, it does not recover. The phantom overwrites the good version at its address before anything knows the publish failed, so deleting it leaves the address EMPTY — 9/9 trials. User-visibly that is a 404 on a resource that exists. So publishEventOptimistic now captures the replaced version before the optimistic add — the only moment it is still reachable — and re-adds it after the un-cache. Ordering is load-bearing: nostr-idb only writes a replaceable event when it is newer than the entry at its address, so restoring first is silently rejected.

2. The hasEvent flush-time guard does not fire on this path. The fastest possible failure reports at 1006-1015ms (5/5) because getPublishRelays does a relay-list lookup before publishing starts, while the batch flushes at 1000ms. Deleting the guard left outcomes identical 5/5. It is not a co-equal second guard for #64; it is kept for the invariant it does cover — superseded replaceable versions, NIP-09 deletions — and the comment and test name now say so.

uncacheEvent is the whole of the #64 fix: disabled, the phantom leaks 4/4.

Shared helper

nextCreatedAt() + cachePublishedEvent() in helpers/replaceableUpdates.js. Adopting nextCreatedAt surfaced three further update paths carrying the #62 bug with no guard — updateWiki (30818), updateArticle (30023), updateResource (30142). On a same-second tie nostr-idb keeps the OLD version deterministically. TestOER confirmed updateResource in a browser: created_at 1785441994 -> 1785441995, d-tag preserved.

Verification

5216 / 5222 tests pass      lint clean      pnpm check 0 errors

The 6 failures are five DM/inbox/gift-wrap files that fail identically on unmodified dev under the same load. I ran dev's full suite twice: once fully clean, once with the same five red. Load-dependent 5s/30s timeouts, same family as the known GlobalFAB flake — pre-existing, not this branch. Worth its own issue.

Every new assertion confirmed load-bearing by reverting the code it covers:

no-op uncacheEvent                     -> 3 fail
uncacheEvent without id-equality       -> 1 fail (deletes a newer version)
no restore                             -> 2 fail
capture moved after the optimistic add -> 1 fail

Known limits

  • nextCreatedAt in relay-settings-service resolves the previous list from the EventStore; if it was never loaded there the guard falls back to wall-clock time. No worse than the Date.now() it replaces.
  • The restore is unit-tested at both the publish-service and IDB levels but has not been re-run in a browser — TestOER's 9/9 EMPTY measurements were against 13ac018d, before it existed.
Closes #64. Four sibling sites of #62, a shared helper, and — after TestOER's browser measurement — a restore of the version a failed publish replaced. ## Why any of this matters The IndexedDB cache is the **first** step of applesauce's `addressPointerLoadingSequence`, and a cache hit **ends** it. So for a cacheable kind, whatever is in IDB is what the app shows, and a relay cannot correct it. The cache is fed only from `eventStore.insert$`, and `publishEvent` never touches the EventStore. ## Sites fixed | site | kind | symptom | |---|---|---| | `relay-settings-service.js` | 10002 | zero `eventStore` references. The **relay list itself**, so a stale read mis-routes every subsequent query | | `calendar-actions` `createCalendar` | 31924 | no add; missing from cache until a relay round-trip | | `publishEventOptimistic` failure path | any cacheable | removed the event from the EventStore but could not reach IDB | | `calendarActions.deleteEvent` | 5 | dead code publishing a kind 5 with neither `eventStore.add` nor `cacheDeletion`. Removed | ## The failed-publish path, as measured rather than as I first described it TestOER ran the discriminating experiment in a real browser — unmocked pipeline, IndexedDB read directly, bogus-address control, four dead-relay modes. Two of my original claims did not survive it, and both are corrected here: **1. `uncacheEvent` alone deletes, it does not recover.** The phantom overwrites the good version at its address before anything knows the publish failed, so deleting it leaves the address **EMPTY** — 9/9 trials. User-visibly that is a 404 on a resource that exists. So `publishEventOptimistic` now captures the replaced version **before** the optimistic add — the only moment it is still reachable — and re-adds it after the un-cache. Ordering is load-bearing: nostr-idb only writes a replaceable event when it is newer than the entry at its address, so restoring first is silently rejected. **2. The `hasEvent` flush-time guard does not fire on this path.** The fastest possible failure reports at **1006-1015ms** (5/5) because `getPublishRelays` does a relay-list lookup before publishing starts, while the batch flushes at 1000ms. Deleting the guard left outcomes identical 5/5. It is **not** a co-equal second guard for #64; it is kept for the invariant it does cover — superseded replaceable versions, NIP-09 deletions — and the comment and test name now say so. `uncacheEvent` is the whole of the #64 fix: disabled, the phantom leaks 4/4. ## Shared helper `nextCreatedAt()` + `cachePublishedEvent()` in `helpers/replaceableUpdates.js`. Adopting `nextCreatedAt` surfaced three further update paths carrying the #62 bug with no guard — **`updateWiki` (30818), `updateArticle` (30023), `updateResource` (30142)**. On a same-second tie nostr-idb keeps the OLD version deterministically. TestOER confirmed `updateResource` in a browser: `created_at` 1785441994 -> 1785441995, d-tag preserved. ## Verification ``` 5216 / 5222 tests pass lint clean pnpm check 0 errors ``` The 6 failures are five DM/inbox/gift-wrap files that fail **identically on unmodified `dev`** under the same load. I ran `dev`'s full suite twice: once fully clean, once with the same five red. Load-dependent 5s/30s timeouts, same family as the known `GlobalFAB` flake — **pre-existing, not this branch.** Worth its own issue. Every new assertion confirmed load-bearing by reverting the code it covers: ``` no-op uncacheEvent -> 3 fail uncacheEvent without id-equality -> 1 fail (deletes a newer version) no restore -> 2 fail capture moved after the optimistic add -> 1 fail ``` ## Known limits - `nextCreatedAt` in `relay-settings-service` resolves the previous list from the EventStore; if it was never loaded there the guard falls back to wall-clock time. No worse than the `Date.now()` it replaces. - The restore is unit-tested at both the publish-service and IDB levels but has **not** been re-run in a browser — TestOER's 9/9 EMPTY measurements were against `13ac018d`, before it existed.
Siblings of #62: publish sites that never reach the IndexedDB cache, plus
the failure path that reaches it and should not.

Why any of this matters: the cache is the FIRST step of applesauce's
addressPointerLoadingSequence and a cache hit ENDS the sequence — relay
hints, additional relays and lookup relays are never tried. So for a
cacheable kind, whatever is in IDB is what the app shows, and a relay
cannot correct it. The cache is fed only from `eventStore.insert$`
(event-cache.svelte.js), and `publishEvent` never touches the EventStore.

Sites fixed

- relay-settings-service.js (kind 10002) had zero eventStore references.
  This is the relay list itself, so a stale read there mis-routes every
  subsequent query — the blast radius is not one screen.
- calendar-actions createCalendar (kind 31924) had no add either. Milder:
  a create has no stale prior version to be served, only "missing until a
  relay round-trip".
- publishEventOptimistic removed a failed event from the EventStore but
  could not reach IDB, because the cache pipeline was insert-only. For an
  addressable kind that is worse than a leak: nostr-idb keys by
  `kind:pubkey:d`, so the phantom OVERWRITES the last good version at that
  address and the cache-hit short-circuit means no relay is ever asked to
  correct it. A failed edit would render forever.
- calendarActions.deleteEvent was dead code (no callers; the UI goes
  through helpers/eventDeletion.js) that published a kind 5 with neither
  an eventStore.add nor a cacheDeletion — exactly the failure the
  cacheDeletion doc comment warns about. Removed so it is not copied.

The phantom fix is two guards, because the write is buffered and the two
orderings fail differently:

- uncacheEvent() deletes the entry from IDB when the publish has already
  flushed. It keys by getEventUID, the key nostr-idb actually writes
  under — passing event.id would silently match nothing for exactly the
  replaceable kinds that matter — and deletes only when the stored entry
  IS that event, so a newer version that legitimately took the address is
  never dropped.
- the write callback re-checks eventStore.hasEvent at FLUSH time, for the
  case where the failure lands inside the batch window. Also stops
  superseded versions and deleted events being persisted.

Shared helper

nextCreatedAt() and cachePublishedEvent() move the two #62 guards out of
calendar-actions into helpers/replaceableUpdates.js, with the reasoning
attached, so a new publish site inherits them instead of re-deriving them.

nextCreatedAt is then adopted at three further update paths that had the
#62 bug and no guard: updateWiki (30818), updateArticle (30023) and
updateResource (30142). All three publish optimistically, so a same-second
edit ties — and on a tie nostr-idb keeps the OLD version deterministically
(strict `>` in database/insert.js), not half the time. Ties are not exotic
because applesauce's unixNow() rounds rather than floors.

Not measured: the discriminating experiment for the phantom path is
dead-relay -> save -> reload in a browser, which is TestOER's to run.
updateResource has no direct unit test — educational-actions.svelte.js
cannot be imported in the node test env (established in
educational-actions-tags.test.js), so that one adoption rests on the
helper's own tests and on `pnpm check`.

Verification: 473 files / 5213 tests pass (+20), lint clean, `pnpm check`
0 errors. Each new event-cache test was confirmed load-bearing by
reverting the guard it covers and watching it fail. The non-zero exit from
`pnpm test` is the known GlobalFAB EnvironmentTeardownError flake, not a
test failure.

Refs #64
Follow-up to 13ac018d, from TestOER's browser measurement on PR #69.

Deleting the phantom was not enough. Adding a replacement evicts its
predecessor from the EventStore (keepOldVersions is off) and, once the
cache batch flushes, overwrites it in IDB too — nostr-idb keys
replaceable events by `kind:pubkey:d`. So `uncacheEvent` left the address
EMPTY, not restored. Measured in a browser across four dead-relay modes,
9/9 trials EMPTY, and user-visibly that is a 404 on a resource that
exists.

publishEventOptimistic now captures the version being replaced BEFORE the
optimistic add — the only moment it is still reachable — and re-adds it
after the phantom is un-cached. Ordering is load-bearing: nostr-idb only
writes a replaceable event when it is newer than the entry at its
address, so restoring first is silently rejected.

Two claims corrected in the comments, both measured by TestOER with
negative controls:

- The `hasEvent` flush-time guard does NOT fire on the failed-publish
  path. The fastest possible failure reports at 1006-1015ms (5/5) because
  getPublishRelays does a relay-list lookup before publishing starts,
  while the batch flushes at 1000ms. Deleting the guard left outcomes
  identical 5/5. It is kept for the invariant it does cover — superseded
  replaceable versions and NIP-09 deletions — and the comment and the
  test name now say which.
- uncacheEvent is the whole of the #64 fix: disabling it leaks the
  phantom 4/4.

Verification: 5216/5222 pass. The 6 failures are five DM/inbox/gift-wrap
files that fail IDENTICALLY on unmodified dev under the same load —
confirmed by running dev's full suite twice, once clean and once with the
same five red. Load-dependent 5s/30s timeouts, same family as the known
GlobalFAB flake, not this branch. lint clean, `pnpm check` 0 errors.

Each new assertion confirmed load-bearing by reverting the code it
covers: no restore -> 2 fail; capture moved after the add -> 1 fail.

Refs #64
laoc merged commit 59b2de154e into dev 2026-07-30 21:09:53 +00:00
Sign in to join this conversation.
No reviewers
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!69
No description provided.