diff --git a/apps/web/package.json b/apps/web/package.json index 3c1d77c..d565bfc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -63,7 +63,7 @@ "dependencies": { "@atcute/bluesky-richtext-parser": "^2.1.1", "@atcute/jetstream": "^1.1.2", - "@atmo-dev/contrail": "^0.9.0", + "@atmo-dev/contrail": "^0.11.0", "@atmo-dev/events-ui": "workspace:*", "@ethercorps/sveltekit-og": "^4.2.1", "@foxui/colors": "^0.8.5", diff --git a/apps/web/src/lib/contrail.config.ts b/apps/web/src/lib/contrail.config.ts index 1854567..ae8b932 100644 --- a/apps/web/src/lib/contrail.config.ts +++ b/apps/web/src/lib/contrail.config.ts @@ -7,6 +7,13 @@ export const config: ContrailConfig = { // writing records to the PDS so contrail re-fetches and indexes them // immediately instead of waiting for the jetstream. notify: true, + // Refresh the SQLite query planner's statistics on a schedule so + // multi-predicate reads (e.g. rsvp.listRecords filtered by subject.uri + + // status) pick the selective index instead of the planner's default + // heuristic — measured ~50x fewer rows read. Runs in the ingest cycle, gated + // to once/24h via a persisted timestamp, bounded by PRAGMA analysis_limit so + // it can't blow D1's per-query CPU budget. Defaults are fine. + maintenance: { optimize: true }, // `spaces` is declared statically so `pnpm generate` emits the `rsvp.atmo.space.*` // lexicons. The real serviceDid is injected at runtime in `$lib/contrail/index.ts` // via `getSpacesConfig()` — generate doesn't serialize it. diff --git a/apps/web/src/lib/server/edge-cache.ts b/apps/web/src/lib/server/edge-cache.ts new file mode 100644 index 0000000..313108d --- /dev/null +++ b/apps/web/src/lib/server/edge-cache.ts @@ -0,0 +1,50 @@ +/** + * Read-through edge cache backed by the Cloudflare Cache API (`caches.default`). + * + * Use for reads whose result is identical for every visitor (global discovery + * lists, global activity feeds) so we serve them from the colo cache instead of + * re-querying D1 on every request. A hit skips both the DB round-trips AND any + * post-processing folded into `fn`. + * + * `caches.default` is a Cloudflare extension absent in dev (vite/node) — when + * it's missing we degrade to calling `fn` directly (no caching), so dev behaves + * like a permanent cache miss. + * + * Freshness is governed by the stored `Cache-Control: max-age`; once it expires + * the Workers Cache API treats the entry as a miss, so there's no manual TTL + * bookkeeping. Only non-null values are cached, so a failed/empty read isn't + * pinned as a negative result. + */ +function getEdgeCache(): Cache | null { + return typeof caches !== 'undefined' && 'default' in caches + ? (caches as unknown as { default: Cache }).default + : null; +} + +export async function cachedRead( + key: string, + ttlSeconds: number, + fn: () => Promise +): Promise { + const cache = getEdgeCache(); + // The host is irrelevant — it only namespaces keys within this colo's cache. + const cacheKey = new Request(`https://edge-cache.internal/${key}`); + + if (cache) { + const hit = await cache.match(cacheKey); + if (hit) return (await hit.json()) as T; + } + + const value = await fn(); + + if (cache && value != null) { + await cache.put( + cacheKey, + new Response(JSON.stringify(value), { + headers: { 'cache-control': `max-age=${ttlSeconds}` } + }) + ); + } + + return value; +} diff --git a/apps/web/src/routes/(app)/+page.server.ts b/apps/web/src/routes/(app)/+page.server.ts index fc74538..35d770f 100644 --- a/apps/web/src/routes/(app)/+page.server.ts +++ b/apps/web/src/routes/(app)/+page.server.ts @@ -12,11 +12,20 @@ import { } from '$lib/contrail'; import { getSpacesClient } from '$lib/spaces/server/client'; import { spacesAvailable } from '$lib/spaces/config'; +import { cachedRead } from '$lib/server/edge-cache'; import type { PageServerLoad } from './$types'; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; -const ACTIVITY_FETCH_LIMIT = 200; +/** Raw records pulled for the activity feed before JS-side filtering/clustering + * down to ACTIVITY_DISPLAY_LIMIT. Recent RSVPs concentrate on popular events, + * so a modest window still yields a full set of clusters — fetching more just + * hydrates events we throw away. */ +const ACTIVITY_FETCH_LIMIT = 75; const ACTIVITY_DISPLAY_LIMIT = 10; +/** TTL for the global (non-personalized) home-page surfaces. They're identical + * for every visitor, so we serve them from the colo edge cache and only hit D1 + * on a miss. Short enough that new events/RSVPs surface within ~a minute. */ +const GLOBAL_CACHE_TTL_S = 60; /** Activity feed includes RSVPs to events that ended within this window so * recently-finished events linger briefly (their RSVPs are still meaningful * social signal). */ @@ -85,15 +94,20 @@ export const load: PageServerLoad = async ({ locals, platform }) => { return { upcoming, past }; })(); - const globalPromise = withD1Retry(() => - listDiscoverableEventsFromContrail(publicClient, { - startsAtMin: nowIso, - rsvpsCountMin: 2, - hydrateRsvps: 5, - sort: 'startsAt', - order: 'asc', - limit: 20 - }) + // Global discovery list — same for every visitor, so cache it at the edge. + // The key is static (no per-request `nowIso`), so a hit just means the + // `startsAtMin` filter is up to one TTL stale — harmless for "upcoming". + const globalPromise = cachedRead('home:discoverable', GLOBAL_CACHE_TTL_S, () => + withD1Retry(() => + listDiscoverableEventsFromContrail(publicClient, { + startsAtMin: nowIso, + rsvpsCountMin: 2, + hydrateRsvps: 5, + sort: 'startsAt', + order: 'asc', + limit: 20 + }) + ) ); // listRecords and getFeed return structurally identical rsvp records, but TS @@ -185,26 +199,30 @@ export const load: PageServerLoad = async ({ locals, platform }) => { .slice(0, ACTIVITY_DISPLAY_LIMIT); } - async function fetchGlobalActivity(): Promise { - const response = await withD1Retry(() => - publicClient.get('rsvp.atmo.rsvp.listRecords', { - params: { - hydrateEvent: true, - profiles: true, - sort: 'createdAt', - order: 'desc', - limit: ACTIVITY_FETCH_LIMIT - } - }) - ); - if (!response.ok) return []; - const clusters = new Map(); - addRsvpsToClusters( - (response.data.records ?? []) as ActivityRsvp[], - (response.data.profiles ?? []) as ActivityProfile[], - clusters - ); - return finalizeClusters(clusters); + // Global activity is identical for every visitor; cache the fully-clustered + // result so a hit skips the record hydrate AND the clustering work. + function fetchGlobalActivity(): Promise { + return cachedRead('home:global-activity', GLOBAL_CACHE_TTL_S, async () => { + const response = await withD1Retry(() => + publicClient.get('rsvp.atmo.rsvp.listRecords', { + params: { + hydrateEvent: true, + profiles: true, + sort: 'createdAt', + order: 'desc', + limit: ACTIVITY_FETCH_LIMIT + } + }) + ); + if (!response.ok) return []; + const clusters = new Map(); + addRsvpsToClusters( + (response.data.records ?? []) as ActivityRsvp[], + (response.data.profiles ?? []) as ActivityProfile[], + clusters + ); + return finalizeClusters(clusters); + }); } const recentActivityPromise = (async (): Promise<{ diff --git a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts index d4ab9e6..542660a 100644 --- a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts @@ -1,6 +1,5 @@ import { error } from '@sveltejs/kit'; -import type { ActorIdentifier, Did } from '@atcute/lexicons'; -import { actorToDid } from '$lib/atproto/methods'; +import type { ActorIdentifier } from '@atcute/lexicons'; import { flattenEventRecord, flattenEventRecords, @@ -30,7 +29,7 @@ type EventRecord = Awaited>; */ async function loadEventRecordResilient( client: Client, - did: string, + actor: string, rkey: string ): Promise { // `caches.default` is a Cloudflare extension not in the DOM `CacheStorage` @@ -39,12 +38,15 @@ async function loadEventRecordResilient( typeof caches !== 'undefined' && 'default' in caches ? (caches as unknown as { default: Cache }).default : null; - const cacheKey = new Request(`https://event-cache.internal/${did}/${rkey}`); + const cacheKey = new Request(`https://event-cache.internal/${actor}/${rkey}`); try { + // `actor` may be a handle or DID — contrail's getRecord resolves a handle + // authority in the URI (from its local identities table), so we skip the + // app's own network handle resolution entirely. const record = await withD1Retry(() => getEventRecordFromContrail(client, { - did, + did: actor, rkey, hydrateRsvps: RSVP_HYDRATE_LIMIT, profiles: true @@ -74,22 +76,13 @@ export async function load({ params, locals, url, platform }) { throw error(404, 'Event not found'); } - // Resolve the actor (handle→DID). A failure here is almost always transient - // (DNS / network), not a missing event — surface it as retryable rather than - // a misleading "not found" the user can't recover from. - let did: Did; - try { - did = await actorToDid(params.actor); - } catch { - throw error(503, 'Could not resolve this profile right now — please try again.'); - } - - // Fetch the event (retry + cache fallback). Distinguish "genuinely not - // indexed" (404) from a transient index/D1 error (503), so a hiccup doesn't - // masquerade as "not found". + // Fetch the event by actor (handle or DID) — contrail resolves a handle in + // the URI authority server-side, so there's no separate app-side resolution + // step. Retry + cache fallback distinguishes a transient index/D1 error (503) + // from a genuinely-missing event (404). let eventRecord: EventRecord; try { - eventRecord = await loadEventRecordResilient(client, did, rkey); + eventRecord = await loadEventRecordResilient(client, params.actor, rkey); } catch { throw error(503, 'Temporarily unavailable — please try again.'); } @@ -102,6 +95,9 @@ export async function load({ params, locals, url, platform }) { const fullEventRecord = eventRecord!; const eventUri = fullEventRecord.uri; + // Canonical DID from the resolved record — use this (not params.actor, which + // may be a handle) for all downstream lookups that need a DID. + const did = eventData.did; // A conference is just an event with type=conference; its talks are events // pointing back at it via additionalData.parentEvent. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a4e24e..97e38dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^1.1.2 version: 1.1.2 '@atmo-dev/contrail': - specifier: ^0.9.0 - version: 0.9.0(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1)) + specifier: ^0.11.0 + version: 0.11.0(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1)) '@atmo-dev/events-ui': specifier: workspace:* version: link:../../packages/ui @@ -388,14 +388,14 @@ packages: '@atcute/xrpc-server@0.1.12': resolution: {integrity: sha512-70KIerQlljp5+s6t0u6YNN9klEboQUZa2hhoi/hmXIO1cIKEORettTMctnyjfcCJaSfAuj42dxPu51GTZBlm8w==} - '@atmo-dev/contrail-appview@0.9.0': - resolution: {integrity: sha512-nb/l820ex4h41Eh1AMKxu2vLuBZ9kAlhr7RiGi1Q9ynA2I9R4Gjy4guEzGXADKs4mLfiHvGbVINn/IGixNtFtQ==} + '@atmo-dev/contrail-appview@0.11.0': + resolution: {integrity: sha512-x+HNRDG41hQBWDR6TWwBsh25K3jXXnpsaGiCOyul+AOz04vIYy7d9OPh6pWiBKJgw5In/itSp4Oc/Rl7vfYojg==} - '@atmo-dev/contrail-authority@0.9.0': - resolution: {integrity: sha512-Vriw3ICqJCkALE3euc2U5lZ5mAIyRij3A7kyPNeQ2+WSuxSrIoyc97C3hol73nZQzAyC8SskfFcve2dN8CGR/A==} + '@atmo-dev/contrail-authority@0.11.0': + resolution: {integrity: sha512-7Fg9AQbI9yqeD1db8moaI0QjWWOn3RuzHC7lH4+jiLSTDOOyxfp+YFOrosRJ+mF41bEKqdZOHuAjk6VTORsOPw==} - '@atmo-dev/contrail-base@0.9.0': - resolution: {integrity: sha512-BTTRaOG4amCJvZwMbN2AbYcnfdUn5k0UGezBtKL5RUueDvIVVcNNk9cvS8Ol08y1FYqT8fn1KsyW2vb46e5Ctw==} + '@atmo-dev/contrail-base@0.11.0': + resolution: {integrity: sha512-whI53PRFAOVcvciSWNm8rtzvagJw/OaGuwsKiKqynlqiqP9L5QaFvyA7NMHOBbBC2gbX1TJq+MaJkiK/s1P0ww==} peerDependencies: pg: ^8.0.0 peerDependenciesMeta: @@ -406,11 +406,11 @@ packages: resolution: {integrity: sha512-rb22K1H0NYp9Y7Okui/ny42T3X4xfj795p+pPrtWeBsio2ZsK9uuFtn3E+XnB314sGSUQQSe0FtkgVCUjLT+uQ==} hasBin: true - '@atmo-dev/contrail-record-host@0.9.0': - resolution: {integrity: sha512-32Re59u8BJFWHhfqRo7kBC2Iv5eLFFFjowVvriz1xqwAJIZSD74Ch7rJHj8542GgNPr45LGIOYeRw7ACzNKYbA==} + '@atmo-dev/contrail-record-host@0.11.0': + resolution: {integrity: sha512-u4jeCtLOOhx6ycDwfwB8cHl5W3w2TkQrAlgCsxJS+2xdTWkV1dx8vapRM84IuicbBbmjPm3gCUZ7x9b5+Npf7g==} - '@atmo-dev/contrail@0.5.0': - resolution: {integrity: sha512-PeeA3Q6NDwaho2dgaW4QANPRvhjKBml17MMxjcq6DwQG7p0MTUqoa2RM1G7DpA8CAIPrRXTVcosb6ybqshlWtQ==} + '@atmo-dev/contrail@0.11.0': + resolution: {integrity: sha512-oPN50OqG9KiEAZVSc9va2mGu5CzhTGX/QPHkCV1bfQ92Z+I9NkMRxvLzXEGjFYZLDtXgKDkpKW8a0U9QLCpioA==} hasBin: true peerDependencies: pg: ^8.0.0 @@ -421,8 +421,8 @@ packages: wrangler: optional: true - '@atmo-dev/contrail@0.9.0': - resolution: {integrity: sha512-6L6+cNWyCel79Hjwk/G3P/vq9m2ENjMCf0J6RGf2nk+rJl3uZ/Ai7JvzU/HDNAMRf36fPjNME05zqL6AzVelhg==} + '@atmo-dev/contrail@0.5.0': + resolution: {integrity: sha512-PeeA3Q6NDwaho2dgaW4QANPRvhjKBml17MMxjcq6DwQG7p0MTUqoa2RM1G7DpA8CAIPrRXTVcosb6ybqshlWtQ==} hasBin: true peerDependencies: pg: ^8.0.0 @@ -3691,7 +3691,7 @@ snapshots: '@badrap/valita': 0.4.6 nanoid: 5.1.7 - '@atmo-dev/contrail-appview@0.9.0': + '@atmo-dev/contrail-appview@0.11.0': dependencies: '@atcute/atproto': 3.1.10 '@atcute/cbor': 2.3.2 @@ -3702,24 +3702,24 @@ snapshots: '@atcute/jetstream': 1.1.2 '@atcute/lexicons': 1.2.9 '@atcute/xrpc-server': 0.1.12 - '@atmo-dev/contrail-authority': 0.9.0 - '@atmo-dev/contrail-base': 0.9.0 - '@atmo-dev/contrail-record-host': 0.9.0 + '@atmo-dev/contrail-authority': 0.11.0 + '@atmo-dev/contrail-base': 0.11.0 + '@atmo-dev/contrail-record-host': 0.11.0 hono: 4.12.14 transitivePeerDependencies: - pg - react - '@atmo-dev/contrail-authority@0.9.0': + '@atmo-dev/contrail-authority@0.11.0': dependencies: '@atcute/cid': 2.4.1 '@atcute/lexicons': 1.2.9 - '@atmo-dev/contrail-base': 0.9.0 + '@atmo-dev/contrail-base': 0.11.0 hono: 4.12.14 transitivePeerDependencies: - pg - '@atmo-dev/contrail-base@0.9.0': + '@atmo-dev/contrail-base@0.11.0': dependencies: '@atcute/atproto': 3.1.10 '@atcute/cid': 2.4.1 @@ -3739,15 +3739,15 @@ snapshots: - react - wrangler - '@atmo-dev/contrail-record-host@0.9.0': + '@atmo-dev/contrail-record-host@0.11.0': dependencies: '@atcute/cid': 2.4.1 - '@atmo-dev/contrail-base': 0.9.0 + '@atmo-dev/contrail-base': 0.11.0 hono: 4.12.14 transitivePeerDependencies: - pg - '@atmo-dev/contrail@0.5.0(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1))': + '@atmo-dev/contrail@0.11.0(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1))': dependencies: '@atcute/atproto': 3.1.10 '@atcute/cbor': 2.3.2 @@ -3758,6 +3758,10 @@ snapshots: '@atcute/jetstream': 1.1.2 '@atcute/lexicons': 1.2.9 '@atcute/xrpc-server': 0.1.12 + '@atmo-dev/contrail-appview': 0.11.0 + '@atmo-dev/contrail-authority': 0.11.0 + '@atmo-dev/contrail-base': 0.11.0 + '@atmo-dev/contrail-record-host': 0.11.0 cac: 7.0.0 hono: 4.12.14 jiti: 2.6.1 @@ -3766,7 +3770,7 @@ snapshots: transitivePeerDependencies: - react - '@atmo-dev/contrail@0.9.0(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1))': + '@atmo-dev/contrail@0.5.0(wrangler@4.77.0(@cloudflare/workers-types@4.20260317.1))': dependencies: '@atcute/atproto': 3.1.10 '@atcute/cbor': 2.3.2 @@ -3777,10 +3781,6 @@ snapshots: '@atcute/jetstream': 1.1.2 '@atcute/lexicons': 1.2.9 '@atcute/xrpc-server': 0.1.12 - '@atmo-dev/contrail-appview': 0.9.0 - '@atmo-dev/contrail-authority': 0.9.0 - '@atmo-dev/contrail-base': 0.9.0 - '@atmo-dev/contrail-record-host': 0.9.0 cac: 7.0.0 hono: 4.12.14 jiti: 2.6.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c5e2825..d208d87 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,3 +12,8 @@ minimumReleaseAgeExclude: - '@atmo-dev/contrail-base@0.9.0' - '@atmo-dev/contrail-record-host@0.9.0' - '@atmo-dev/contrail@0.9.0' + - '@atmo-dev/contrail-appview@0.11.0' + - '@atmo-dev/contrail-authority@0.11.0' + - '@atmo-dev/contrail-base@0.11.0' + - '@atmo-dev/contrail-record-host@0.11.0' + - '@atmo-dev/contrail@0.11.0'