Skip to content

feat(cache): persistent event cache for snappier repeat visits#18

Open
sroertgen wants to merge 63 commits intodevfrom
feature/persistent-event-cache
Open

feat(cache): persistent event cache for snappier repeat visits#18
sroertgen wants to merge 63 commits intodevfrom
feature/persistent-event-cache

Conversation

@sroertgen
Copy link
Copy Markdown
Contributor

Summary

Adds a persistent event cache backed by IndexedDB (via nostr-idb) so repeat visits feel snappy. Loaders read from cache first, network second; events flow back into the cache automatically.

  • New singleton event-cache.svelte.js wrapping NostrIDB with a kind allowlist (0, 3, 5, 10002, 10222, 30000, 30002, 30023, 30142, 31922, 31923, 31924)
  • Wired cacheRequest into addressLoader, eventLoader, unifiedLoader
  • New createCachedTimelineLoader helper used by calendar, educational, community, kanban, vocab, highlights, RSVP loaders
  • warmIdentity hydrates own kind-3 / 10002 / 30002 / 30000 events + followed users' profiles on boot
  • Settings → "Lokaler Zwischenspeicher" panel with event count + clear
  • E2E warm-reload test (e2e/cache-warm-boot.test.js) confirms cards render after WS is blocked
  • SSR-safe: NostrIDB constructed only when indexedDB exists

Spec: docs/superpowers/specs/2026-04-24-persistent-event-cache-design.md

Test plan

  • pnpm run test — 1950 pass (3 pre-existing failures in GlobalFAB/NoteCard, unrelated)
  • pnpm run check — 0 errors, 2 warnings (both unrelated to this branch)
  • pnpm run build — clean
  • pnpm run test:e2e cache-warm-boot — pass
  • Manual /calendar verification (Playwright in worktree dev server):
    • Cold visit renders 585 events; IDB populates to 1179 events (37 kind-0, 1 kind-3, 6 kind-10002, 13 kind-10222, 5 kind-30000, 1 kind-30002, 47 kind-30023, 108 kind-30142, 28 kind-31922, 916 kind-31923, 17 kind-31924)
    • 0 non-allowed kinds in IDB — write filter holds
    • Reload still renders 424 events even with relays returning 429/NXDOMAIN — cache-served
    • 0 [event-cache] console warnings at any point
  • Settings panel: shows "1180 Ereignisse", clear-cache flow opens dialog with distinct "Bestätigen" button, confirm clears IDB to 0 and UI updates; reload then repopulates cache from network.

Deferred follow-ups

  • 3 cacheable-kind loaders still call raw createTimelineLoader due to factory-pattern signature: relay-list-loader.js, contact-list-loader.js, userDeletionLoader in base.js. Writes still flow through persistEventsToCache, so functionality is intact — just missing read-side cache-hit benefit. Worth a follow-up PR.
  • createDateRangeCalendarLoader / createPaginatedCalendarLoader use timedPool directly. Render fine via network; cache-hit benefit deferred.

Replaces the single-screen AMB form with a 7-step guided wizard that lowers
the metadata bar for non-library users. Highlights:

- Step 1 "Bildungsbereich": picking Schule/Hochschule/Extra preselects the
  subject vocabulary and educational levels for later steps.
- Step 2 URL/naddr: server-side metadata extraction (OpenGraph + AMB
  JSON-LD) via /api/reader prefills later steps; inspection runs
  automatically on type (debounced) and on paste — no manual button.
  Pasting an owned naddr redirects into edit mode.
- Step 3 "Basic": title/description/language plus a content-fit image
  preview surfaced at the top of the step as a visual anchor.
- Step 4 "Klassifikation": SKOS-backed pickers for resource type,
  educational level, and subject; vocab-slug disambiguation only shows
  human labels when multiple parallel pickers are active (extra BB).
- Steps 5-7 unchanged (content & creators, rights, community share).

Plus a terminology shift: header reads "Bildungsressource teilen" /
"Share Educational Resource" — the act is making an existing resource
findable, not creating it.

Also adds:
- Unit tests for helpers (ambJsonLdToFormData, bildungsbereich,
  keywordInput, metadataExtraction, resolveMetadataInput, vocabResolver)
  and for MetadataFetchStep.
- E2E coverage for the new wizard flow.
Replace DaisyUI hover tooltips with persistent label pills next to each
FAB sub-button so users (especially on touch) can see what every action
does without having to hover each icon in turn. Label sits to the right
of the icon, anchored to the same edge as the main "+" button.

Also rename fab_create_resource to "Share Learning Content" /
"Lerninhalt teilen" to match the feature rename already applied to the
resource creation page title on feature/guided-amb-wizard.
Flips the default presentation view mode for calendar pages from
"calendar" to "list". Updates the runtime default in CalendarView,
the URL-param parser, URL builder, active-filter detection, and the
initial-sync handler so the list view is the no-param default everywhere.
Adds a Relations wizard step to the AMB form with inline
hasPart/isPartOf pickers (AMBResourceSearchInput, NIP-50-backed
search + naddr-paste fallback). Relations are serialized as marker
`a`-tags via amb-nostr-converter and re-read in edit mode via
new getAMBHasPart / getAMBIsPartOf helpers.

Also fixes two bugs surfaced when publishing against real data:

- URL-d-tag coord truncation: the picker, the helpers reader, and
  the detail view all split the `kind:pubkey:dTag` coordinate on
  every `:`, truncating d-tags that are URLs (e.g. losing everything
  after `https`). Reads the d-tag directly from the source event
  where possible and uses a bounded split (indexOf/slice) everywhere
  else. Detail view now also filters to relation-marker `a`-tags
  only so bare `a`-tags don't render as related resources.

- educationalLevel auto-preselect: picking a Bildungsbereich in
  step 1 silently seeded step 4 with KIM level URIs the user never
  chose. Removed the effect; `educationalLevelDefaults` is renamed
  to `educationalLevelMapping` since it's only used now for
  reverse inference in edit-mode prefill, not as a preselect
  default.

Bumps amb-nostr-converter to 0.0.0-d216541 for the `relatedEvents`
option that emits the marker `a`-tags.
Brings in three landed commits:
- feat(amb): guided wizard for sharing educational resources
- feat(calendar): default to list view
- feat(amb): hasPart/isPartOf relations + URL-d-tag fixes

Subsumes feature/guided-amb-wizard and feat/default-calendar-list-view,
which both point at the guided-wizard commit.
Kind 30142 events now fall through to the generic /{naddr} route
instead of /discover?resource={naddr}, so clicks from the feed open
the dedicated detail view.
Rename AMBResourceForm → ResourceFormWizard and add a `variantId` prop so
the same wizard serves both `amb` and `ekw` metadata profiles. Variants
are gated by RESOURCE_FORM_VARIANTS env. Published events carry NIP-32
["L", "metadata-form"] + ["l", variantId, "metadata-form"] so edit mode
can reopen the correct form.

- variant registry (src/lib/config/resource-form-variants.js)
- resolveVariantIdFromEvent helper for edit mode
- shared eventTags helpers used by create/update actions
- ResourceVariantPickerModal (shown only when >1 variant enabled)
- /create/resource/[variant=resourceVariant] route + redirector
- FAB wiring: direct nav when single variant, picker when multi
- bildungsbereich keys per variant (amb: schule/hochschule/extra,
  ekw: schule/konfi)
- i18n, unit + E2E tests, COVERAGE.md entry
- svelte 5.49.2 → 5.55.4 (5 SSR XSS advisories)
- @sveltejs/kit 2.50.2 → 2.57.1 (kit + transitive devalue/cookie fixes)
- dompurify 3.3.1 → 3.4.1 (8 sanitization bypasses)
- applesauce-{accounts,common,core,relay,signers} 5.1.0 → 5.2.0
- applesauce-actions 5.1.0 → 5.1.1
- @sveltejs/adapter-node 5.5.2 → 5.5.4 (BODY_SIZE_LIMIT bypass)
- @tailwindcss/vite, tailwindcss 4.1.18 → 4.2.4
- daisyui 5.5.18 → 5.5.19
- vitest 4.0.18 → 4.1.5, svelte-check 4.3.6 → 4.4.6
- @playwright/test 1.58.1 → 1.59.1
- prettier 3.3.3 → 3.8.3, prettier-plugin-svelte 3.4.1 → 3.5.1
- eslint-plugin-svelte 3.14.0 → 3.17.1, lint-staged 16.2.7 → 16.4.0
- @inlang/cli 3.1.4 → 3.1.9
- nostr-tools 2.23.0 → 2.23.3
- livekit-client 2.18.1 → 2.18.5, ws 8.19.0 → 8.20.0
- svelte-maplibre 1.2.6 → 1.3.0, unpdf 1.4.0 → 1.6.0

pnpm audit: 62 → 38 vulnerabilities. Remaining advisories mostly
stem from the deprecated @inlang/paraglide-sveltekit wrapper (11
including the sole critical sha.js) and should be addressed by
migrating to @inlang/paraglide-js v2 directly.
The @inlang/paraglide-sveltekit adapter is deprecated; paraglide-js v2
now exports the Vite plugin and middleware directly. vite.config.js
already uses paraglideVitePlugin from @inlang/paraglide-js, and
hooks.server.js consumes the generated $lib/paraglide/server, so no
source changes are needed.
The route matcher called getEnabledVariants() which reads runtimeConfig.
SvelteKit runs matchers server-side during route resolution, before
/api/config is fetched client-side, so the pre-config default ['amb']
was rejecting otherwise-valid URLs like /create/resource/ekw with a 404.

Validate against the static ALL_VARIANTS registry in the matcher, and
move enabled-list filtering into the page component: await configReady,
then redirect registered-but-disabled variants to the default (preserving
?community= and ?edit=).
When configReady is already true at onMount, svelte's writable calls
the subscribe callback synchronously. The callback references `unsub`
before the const assignment completes, throwing "Cannot access 'unsub'
before initialization". Short-circuit with get(configReady) so the
subscription path only runs when we actually need to wait.

Same pattern applied to both the /create/resource redirector and the
/create/resource/[variant] page.
Replaces the subtle ghost button under the URL input with a full-width
option card (mirrors the step-1 Bildungsbereich style), separated by an
"or" divider. Clicking the card auto-advances to step 3 after 200ms,
matching the step-1 radio pattern.

Copy changes:
- Label: "No external link?" / "Kein externer Link?" (was the
  jargon-heavy "Index a new Nostr-only resource")
- Description: "Create the resource directly on Nostr — e.g. for files
  you upload." (new key, explains the two use cases)
- State card (shown on back-nav and edit mode): "This resource will be
  created directly on Nostr …" (was "Resource without an external URL")
- Cancel: "Enter a URL after all" (was "Cancel and enter a URL
  instead")

E2E test updated: the state-card assertion was removed from the happy
path (auto-advance skips past it), and the edit round-trip now asserts
against the new state-card wording.
prettier 3.7+ removed the legacy getVisitorKeys export that
prettier-plugin-svelte 3.5.1 still calls from printEmbeddedLanguages,
which makes every .svelte file crash with

    TypeError: getVisitorKeys is not a function or its return value is
    not iterable

— breaking the pre-commit hook for the whole repo. Pinning prettier to
~3.3.3 (the last version before the crash) restores the hook while
keeping plugin-svelte 3.5.1. Bump back to the current prettier stable
once prettier-plugin-svelte ships a fix.
Three calendar fixes bundled together with shared test coverage.

- Pin persistence: CalendarMapView used to overwrite its pin list on
  every geocode batch. Switched to a $state.raw coordCache Map that
  merges new geocode results into existing entries, with derived pins
  recomputed from the cache. Multiple concurrent IIFEs from $effect
  re-runs now merge safely (last-write-wins on overlapping keys).

- Grid button: CalendarNavigation used to strip the view param from
  the URL when clicking the grid button, relying on a "default is
  list" branch in the loader — which meant the loader then rendered
  the list view, not the grid. Now always writes the explicit view
  value via updateQueryParams.

- Initial map zoom / style-load race: replaced prop-driven bounds
  with a programmatic map.fitBounds(bounds, { padding: 50, maxZoom:
  12, animate: false }) via bind:map + bind:loaded and a one-shot
  guard flag — prevents the svelte-maplibre bidirectional state loop
  where moveend handlers write bounds back to the prop.

Refactor: drop dead mapCenter/mapZoom state and collapse the
three-branch calculateMapBounds into two (empty vs. non-empty). The
single-event path now uses a degenerate bbox [[lng,lat],[lng,lat]],
which fitBounds with maxZoom: 12 centers correctly.

Tests: new E2E covering all three view-toggle buttons (locks in the
grid fix). Map-canvas rendering isn't asserted from E2E because the
mock-relay test fixtures have no location data; COVERAGE.md records
this as a known gap.
…ck derived_inert

Two related fixes surfaced while chasing an intermittent empty community
"Startseite" feed.

- Routing: +page.svelte derived `selectedContentType` fell back to the
  raw `$page.url.searchParams.get('view')` when +page.js returned
  `data.contentView = undefined`. That bypassed +page.js's
  validContentTypes validation and let foreign `view` values through
  (e.g. `view=list` preserved by buildCommunityPath when navigating from
  /calendar after toggling grid/list). MainContentArea has no branch
  for `list`, so the {#key} block rendered no view — blank content
  area that the user perceived as an empty home feed. Now the derived
  relies on the validated `data.contentView` only, defaulting to
  `home` when undefined.

- ImageWithFallback: handleError() is wired to the <img> onerror DOM
  handler, so it can fire after the component's $effect has been
  destroyed (e.g. view switch while the image is still loading).
  Reading the `robohashSrc` $derived at that point produced the
  `[svelte] derived_inert` console warning. Since the value only
  depends on the `src` prop — which remains accessible from the
  closure — inline the template string and drop the $derived.
Adds console.debug instrumentation to confirm the feed renders for the
user-reported reproduction path (/calendar → grid → list → click
community) now that the routing root cause is fixed.

- HomeView: logs emission index + length + dt-since-effect for both
  activitySub (CommunityActivityModel) and bookmarkSub
  (CommunitySocialBookmarkModel). Lets us see whether an empty first
  emit is followed by populated emits, or stays empty — useful if a
  network-failure-induced empty feed shows up separately from the
  already-fixed routing bug.

- community-content-loader: counting subs for the `direct`, `reposts`,
  and `targetedPublications` timeline streams. Each logs its event
  count on complete so we can tell when the network layer returns
  nothing for a community.

Safe to revert once we've confirmed no lingering empty-feed cases.
…p branch

Renames CalendarFilterSidebar.svelte → CalendarFilterDrawer.svelte and
rewrites it as a mobile-only overlay drawer. Removes the desktop
collapsible sidebar branch entirely. Adds Authors section (FeaturedAuthors)
as a fifth filter section. Props now received from parent instead of reading
calendarFilters store internally. Updates CalendarView.svelte to point at the
new file with a temporary stub for Task 9 to fully wire up.
…ne filters

Wire CalendarFilterBar, CalendarFilterDrawer, and FeaturedAuthors into
CalendarView, replacing the old flex-sidebar + rounded-card layout with a
stacked container layout; add featuredAuthors default to config and Step 3
featured-author filtering to displayedEvents.
…thors rail

Five Playwright tests covering the /calendar redesign:
- Footer is visible at bottom (page no longer island-wrapped)
- Page uses min-h-screen wrapper (old card flex wrapper gone)
- Desktop inline filter bar renders its triggers
- Mobile Filter button opens drawer; Escape closes
- No critical JS errors on drawer flow
- Featured Authors rail present when CALENDAR_FEATURED_AUTHORS is set (skips otherwise)

Reset-link behavior is covered by component tests in CalendarFilterBar.test.js.
- TagSelector: free-text input + Add button (mirrors RelaySelector custom-relay UX)
  with normalization (#FooBar → foobar), duplicate detection, and inline error
- SearchInput: refactor to a generic controlled input (value/onChange/onSubmit/
  debounceMs) so it can be reused outside calendarFilters. Migrate
  CalendarFilterBar and CalendarFilterDrawer to wire the store themselves
- PeopleFilter (new): SearchInput-driven name filter over the avatar grid +
  Enter-to-add by npub/hex, auto-opens details when a query is active
- normalizePubkey: extract from ContactSearchInput into \$lib/helpers/pubkey.js
  (jsdom-safe, no app-settings transitive deps)
- ActiveFilterChips, AdvancedFiltersDropdown: new components for the redesigned
  filter chrome; calendar-filters store gains onlyFollowsMode + effective-author
  helpers; calendar loaders gain date-range / search variants
- i18n keys for tag selector and people filter (en/de)
- Tests: pubkey (9), SearchInput (7), PeopleFilter (7), TagSelector (6),
  ActiveFilterChips, calendar-search, calendar-filters effective-authors and
  follow-lists, calendar-date-range-loader, calendar-relay-filter, plus
  E2E coverage for the redesigned chrome
Calendar UI redesign:
- Inline filter bar + drawer chrome (replaces sidebar + card)
- ActiveFilterChips, AdvancedFiltersDropdown, PeopleFilter components
- Generic SearchInput (reusable beyond calendarFilters)
- TagSelector custom-tag input
- normalizePubkey helper extracted to \$lib/helpers
- E2E coverage for the redesigned chrome
Skipped calendar-search.js — uses pool.request() directly for NIP-50 search.
Skipped amb-search.js (uses pool.request() directly for NIP-50 search).
Updated test mocks for base.js to expose createCachedTimelineLoader as a
passthrough that calls the (mocked) createTimelineLoader, so existing
assertions on createTimelineLoader call args still match.
Skipped profile.js, contact-list-loader.js, relay-list-loader.js,
blossom-server-loader.js, badge-loaders.js — they take pool/eventStore
as parameters instead of using the singleton, so the helper signature
doesn't fit. They will keep using createTimelineLoader directly with
the caller-supplied pool.
…svp loaders

Skipped blossom-server-loader.js and badge-loaders.js — both take pool
and eventStore as parameters instead of using the singleton.
Updated vocab-loader test mock to expose createCachedTimelineLoader.
The NostrIDB constructor accesses indexedDB at instantiation time, which
crashes the SvelteKit prerender step where indexedDB is undefined. Guard
the singleton with `typeof indexedDB !== 'undefined'` and have the public
API (cacheRequest/count/clear/warmIdentity, dbReady) early-return their
graceful fallbacks when the singleton is unavailable. Aligns with the
spec's "additive cache, never block the app" requirement.
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