diff --git a/README.md b/README.md index 26805b6..dc94418 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ https://atmo.rsvp uses `community.lexicon.calendar.event` and `community.lexicon.calendar.rsvp`. features: + - event creation - rsvp to events -- add your events to any ical compatible calendar -(go to calendar/ when signed in and click "Add to your calendar") +- add your events to any ical compatible calendar + (go to calendar/ when signed in and click "Add to your calendar") - post your events/rsvps to bluesky or anywhere else with nice open-graph images - display comments - show what events your bsky follows are going to @@ -60,9 +61,8 @@ the read and write keys are kept separate on purpose so the browser-facing read **rollout order on an existing deployment.** the sink only indexes records applied _after_ it's enabled, so don't turn on the read path first or existing upcoming events vanish from search until they're next touched. instead: (1) set the write vars and let the sink arm, (2) populate the index (see below) and confirm the meili `events` index count looks right, then (3) set the read vars (`SEARCH_URL` / `SEARCH_API_KEY`). until step 3 the app keeps using the d1 fallback, so search stays working throughout. -**populating the index.** backfill and refresh now feed the sink, so `pnpm backfill` fills meili as it walks each user's pds. on an existing deployment the event records are usually already in d1, so `pnpm meili:reindex` is faster: it replays the stored `community.lexicon.calendar.event` rows straight from d1 into the index with no network walk and no d1 writes (add `:remote` to target the deployed d1). both paths apply the same discoverable filter as live ingest, so re-running either is idempotent. +**populating the index.** backfill and refresh now feed the sink, so `pnpm backfill` fills meili as it walks each user's pds. on an existing deployment the event records are usually already in d1, so `pnpm meili:reindex` is faster: it replays the stored `community.lexicon.calendar.event` rows straight from d1 into the index with no network walk and no d1 writes. both paths apply the same discoverable filter as live ingest, and the sink applies the index settings on its first write, so a fresh index gets the right filterable fields and re-running either is idempotent. `pnpm meili:reindex:remote` targets the deployed d1 and needs the same wrangler `env.production` that `pnpm backfill:remote` uses. ## contributing open for contributions by all :) - diff --git a/apps/web/src/lib/contrail.config.test.ts b/apps/web/src/lib/contrail.config.test.ts index 326a4ba..4cecaa6 100644 --- a/apps/web/src/lib/contrail.config.test.ts +++ b/apps/web/src/lib/contrail.config.test.ts @@ -6,7 +6,11 @@ import { config } from './contrail.config'; const listDiscoverableByUris = config.collections!.event.pipelineQueries!.listDiscoverableByUris; const run = async (search: string) => { - const source = await listDiscoverableByUris(undefined as never, new URLSearchParams(search), config); + const source = await listDiscoverableByUris( + undefined as never, + new URLSearchParams(search), + config + ); return { conditions: source.conditions ?? [], params: source.params }; }; @@ -22,9 +26,9 @@ describe('listDiscoverableByUris pipelineQuery', () => { const placeholders = source.conditions.join(' ').match(/\?/g) ?? []; expect(placeholders).toHaveLength(2); // The search surface must not leak events hidden from discovery. - expect( - source.conditions.some((c: string) => c.includes('preferences.showInDiscovery')) - ).toBe(true); + expect(source.conditions.some((c: string) => c.includes('preferences.showInDiscovery'))).toBe( + true + ); }); it('matches nothing when no uris are given', async () => { diff --git a/apps/web/src/lib/contrail.config.ts b/apps/web/src/lib/contrail.config.ts index 2afcfa5..c54943d 100644 --- a/apps/web/src/lib/contrail.config.ts +++ b/apps/web/src/lib/contrail.config.ts @@ -95,10 +95,7 @@ export const config: ContrailConfig = { .slice(0, MAX_HYDRATION_URIS); if (uris.length === 0) return { conditions: ['0 = 1'] }; return { - conditions: [ - `r.uri IN (${uris.map(() => '?').join(', ')})`, - DISCOVERABLE_CONDITION - ], + conditions: [`r.uri IN (${uris.map(() => '?').join(', ')})`, DISCOVERABLE_CONDITION], params: uris }; }, diff --git a/apps/web/src/lib/search/server/meili-sink.test.ts b/apps/web/src/lib/search/server/meili-sink.test.ts index 6a49ed3..aa19357 100644 --- a/apps/web/src/lib/search/server/meili-sink.test.ts +++ b/apps/web/src/lib/search/server/meili-sink.test.ts @@ -186,6 +186,30 @@ describe('createMeiliSink onRecords', () => { expect(calls).toHaveLength(0); }); + + it('applies index settings once, before the first write (fresh-index safety)', async () => { + const { fn, calls } = fakeFetch(); + const sink = createMeiliSink(() => BACKEND, fn); + + // Two batches on the same sink: a fresh-rollout `pnpm backfill` must not + // let PUT /documents auto-create a bare index whose _geo/startsAt searches + // 400. Settings are applied exactly once, and before the first write. + await sink.onRecords( + [created('at://did:plc:alice/community.lexicon.calendar.event/a', { name: 'A' })], + { phase: 'backfill' } + ); + await sink.onRecords( + [created('at://did:plc:alice/community.lexicon.calendar.event/b', { name: 'B' })], + { phase: 'backfill' } + ); + + const settings = calls.filter((c) => c.method === 'PATCH' && c.url.endsWith('/settings')); + expect(settings).toHaveLength(1); + const firstSettingsIdx = calls.findIndex((c) => c.url.endsWith('/settings')); + const firstPutIdx = calls.findIndex((c) => c.method === 'PUT'); + expect(firstSettingsIdx).toBeGreaterThanOrEqual(0); + expect(firstSettingsIdx).toBeLessThan(firstPutIdx); + }); }); describe('fetch is invoked detached (workerd Illegal invocation guard)', () => { diff --git a/apps/web/src/lib/search/server/meili-sink.ts b/apps/web/src/lib/search/server/meili-sink.ts index bf4df7c..18c1f4a 100644 --- a/apps/web/src/lib/search/server/meili-sink.ts +++ b/apps/web/src/lib/search/server/meili-sink.ts @@ -92,7 +92,15 @@ export class MeiliEventIndex { async applySettings(): Promise { await this.request('PATCH', `/indexes/${this.indexUid}/settings`, { searchableAttributes: ['name', 'description'], - filterableAttributes: ['_geo', 'startsAt', 'endsAt', 'status', 'mode', 'did', 'locationTypes'], + filterableAttributes: [ + '_geo', + 'startsAt', + 'endsAt', + 'status', + 'mode', + 'did', + 'locationTypes' + ], sortableAttributes: ['_geo', 'startsAt', 'endsAt'] }); } @@ -148,6 +156,12 @@ export function createMeiliSink( getBackend: () => MeiliSinkBackend | null, fetchFn?: typeof fetch ): Sink { + // Apply the read-path's filterable/sortable settings once, before the first + // write. Otherwise a fresh-rollout `pnpm backfill` (or reindex) lets PUT + // /documents auto-create a bare index whose _geo/startsAt filtered searches + // 400. Only flip the flag on success, so a transient Meili outage retries + // next batch instead of permanently disabling the sink in the Worker. + let settingsApplied = false; return { async onRecords(events: RecordEvent[]): Promise { const backend = getBackend(); @@ -180,6 +194,10 @@ export function createMeiliSink( if (docs.length === 0 && deletes.length === 0) return; const index = new MeiliEventIndex(backend, fetchFn); + if (!settingsApplied) { + await index.applySettings(); + settingsApplied = true; + } // Order doesn't matter across upsert/remove within a batch: a given uri // is deduplicated to a single RecordEvent, so it's either a create or a // delete, never both. diff --git a/apps/web/src/lib/search/server/reindex-cli.ts b/apps/web/src/lib/search/server/reindex-cli.ts index fcec4df..fb3b46a 100644 --- a/apps/web/src/lib/search/server/reindex-cli.ts +++ b/apps/web/src/lib/search/server/reindex-cli.ts @@ -1,6 +1,8 @@ -// CLI entry for the D1 -> Meili event reindex. Mirrors `contrail backfill`: -// pnpm meili:reindex # local D1 binding -// pnpm meili:reindex:remote # --remote -> getPlatformProxy production binding +// CLI entry for the D1 -> Meili event reindex. Resolves the D1 binding exactly +// like `contrail backfill`, so `:remote` needs the same wrangler `env.production` +// that `backfill:remote` already relies on: +// pnpm meili:reindex # default env -> local D1 binding +// pnpm meili:reindex:remote # --remote -> getPlatformProxy production env // // The Meili backend is resolved from the same env the sink uses, injected by the // operator (so no secret is committed): @@ -32,6 +34,11 @@ if (!backend) { await applyMeiliSettings(backend); const sink = createMeiliSink(() => backend); +// Say which D1 we're about to touch, so an operator can't mistake a default-env +// (local) run for a deployed one, or vice-versa. +console.log( + `reindex target: ${remote ? 'production env (deployed D1)' : 'default env (local D1)'}` +); const { env, dispose } = await getPlatformProxy({ environment: remote ? 'production' : undefined });