Skip to content

feat(contrail): add /xrpc/net.openmeet.event + rsvp endpoints (additive)#580

Open
tompscanlan wants to merge 1 commit into
mainfrom
feat/contrail-xrpc-phase1
Open

feat(contrail): add /xrpc/net.openmeet.event + rsvp endpoints (additive)#580
tompscanlan wants to merge 1 commit into
mainfrom
feat/contrail-xrpc-phase1

Conversation

@tompscanlan
Copy link
Copy Markdown
Contributor

Summary

Adds a new XRPC surface backed by Contrail's index of public ATProto calendar events and RSVPs. The endpoints (listRecords, getRecord) are served by a ContrailProvider that wraps @atmo-dev/contrail@0.6.0 against a dedicated Postgres schema.

  • Endpoints live at /xrpc/net.openmeet.event.* and /xrpc/net.openmeet.rsvp.*.
  • Populated by a one-shot sync (npm run contrail:sync); a live-ingest deployment can be added later.
  • Single-tenant by design — the new surface bypasses TenantGuard and the /api prefix by mounting as raw Express middleware.

What this PR does not change: event-query.service.ts, the events table, the publishing pipeline, bsky-firehose-consumer, bsky-event-processor. None of OM's existing read or write paths are touched.

Three implementation notes worth attention

  1. Advisory lock around contrail.init() (contrail.provider.ts, contrail-init-lock.ts). Without it, concurrent boots across replicas race on ALTER TABLE ADD COLUMN inside Contrail's init and emit "column already exists" errors. The unit test in contrail-init-lock.spec.ts exercises the serialization invariant against a real Postgres.

  2. Mount at /xrpc with internal NSID filter, not /xrpc/net.openmeet (main.ts). Express's prefix matcher needs a path boundary (/ or end) after the mount path; the dot in net.openmeet.event.listRecords is not one. So we mount at /xrpc and gate inside the handler with req.url.startsWith('/net.openmeet.').

  3. Function-constructor wrapper for the dynamic ESM import (contrail-loader.ts). @atmo-dev/contrail is ESM-only. OM API compiles to CommonJS, where TypeScript rewrites await import(...) to require(...) — broken for ESM. The Function-constructor indirection keeps the import opaque to the TS compiler so Node executes the real ESM import at runtime. A local contrail.d.ts shim declares the types so we don't need a project-wide moduleResolution change.

Verification

  • npm test -- --testPathPattern=contrail-init-lock passes (2 tests) when run with CONTRAIL_TEST_DATABASE_URL pointing at a Postgres.
  • nest build clean.
  • Curl-level proof against a local Contrail Postgres (smoke server in src/contrail/smoke-server.ts): listRecords (sorted by rsvpsCount), listRecords?search=meetup, getRecord?...hydrateRsvps=3, and rsvp.listRecords?subjectUri=... all return well-formed responses with real records.

Companion changes

  • openmeet-infrastructure main already updated: k8s/components/api/base/job-contrail-sync.yaml is a suspended CronJob that runs npm run contrail:sync inside the API image. Triggered manually: kubectl create job --from=cronjob/contrail-sync contrail-sync-$(date +%s).

Known follow-ups (out of scope for this PR)

  • The project's tsc --noEmit baseline has 461 pre-existing errors in *.spec.ts files (unrelated to this PR; surfaced because the local hook runs project-wide). nest build passes because tsconfig.build.json excludes specs. Cleanup is its own ticket.
  • Phase 2: live ingest as a separate Deployment (replicas: 1) for continuous indexing; deferred cutover decisions on read paths and retiring bsky-firehose-consumer.

Test plan

  • npm test -- --testPathPattern=contrail-init-lock passes
  • nest build clean
  • Curl /xrpc/net.openmeet.event.listRecords?limit=3 against a Postgres populated via npm run contrail:sync returns records
  • Curl /xrpc/net.openmeet.event.getRecord?uri=...&hydrateRsvps=3 returns event with RSVPs grouped by status
  • OM API boots without CONTRAIL_DATABASE_URL set (Provider logs a warning, endpoints return 503)

Adds a new XRPC surface backed by Contrail's index of public ATProto
calendar events and RSVPs. The endpoints (listRecords, getRecord) are
served by a ContrailProvider that wraps @atmo-dev/contrail@0.6.0 against
a dedicated Postgres schema. Populated via a one-shot sync command
(npm run contrail:sync); a separate live-ingest deployment can be added
later.

What this does not change: existing event-query.service.ts, the events
table, the publishing pipeline, the bsky-firehose-consumer +
bsky-event-processor. Nothing on OM's existing read or write paths is
touched. The new endpoints sit alongside the legacy REST surface.

The new surface is single-tenant — Contrail indexes the universal
public record set, so /xrpc/net.openmeet.* bypasses TenantGuard and the
global /api prefix by mounting as raw Express middleware.

Three implementation notes worth flagging for review:

- ContrailProvider takes a Postgres advisory lock before calling
  contrail.init(). Without it, concurrent boots across replicas race on
  ALTER TABLE ADD COLUMN and emit spurious "column already exists"
  errors.

- The Express mount is at /xrpc with an internal req.url filter for
  /net.openmeet.* methods. Mounting at /xrpc/net.openmeet doesn't work
  because Express's prefix matcher requires a path boundary after the
  prefix, and the dot in net.openmeet.event.listRecords is not one.

- @atmo-dev/contrail is ESM-only. OM API compiles to CommonJS, where
  TypeScript rewrites `await import()` to `require()` (which can't load
  ESM). A small Function-constructor wrapper in contrail-loader.ts
  keeps the dynamic import opaque to the TS compiler so Node executes
  the real ESM import at runtime.

A local d.ts shim declares the contrail surface we use, avoiding a
project-wide moduleResolution change.
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