simocracy-indexer · drop-hyperindex-migration

Drop Hyperindex — fold all reads into simocracy-indexer

@daviddao

simocracy.org used to read from two indexers: simocracy-indexer for org.simocracy.* and Hyperindex (api.hi.gainforest.app) for org.hypercerts.* / org.impactindexer.* / app.certified.*. The split caused the May-10 browser-harness smoketest to fail when a hypercerts proposal written from a bsky.network PDS never reached /proposals. Six PRs across two repos collapse the split. End-to-end smoke test on the exact same bsky.network DID now passes, with every test record cleaned up before the report was written.

Date: 2026-05-10 · pass · Reviewer: David Dao → sim Mr Meow · DID under test: did:plc:qc42fmqqlsmdq7jiypiiigww · PR window: 25 minutes from first PR open to last merge


Headline result

5/5
Phases shipped
6/6
PRs merged
0
Records stranded
1
Indexer (was 2)

Headline. Five planned phases (vendor 5 lexicons, compat URI/DID/rkey filter, recordEvents WS subscription + path alias, v2 URL flips, v2 typed-callsite rewrite) all shipped, plus one follow-up bugfix for a dual-WSS upgrade race that the production WS probe surfaced minutes after the merge. The end-to-end smoke test posted a proposal and a comment as Mr Meow on the exact bsky.network DID the May-10 report flagged broken (did:plc:qc42fmqqlsmdq7jiypiiigww), watched both render on simocracy.org via the new single-indexer path including sim attribution on /stats Recent Activity, then deleted every record + sidecar (5 total) and verified cleanup across PDS, indexer GraphQL, and rendered UI. The May-10 gap is closed.

Coverage matrix

PhaseRepoPRWire shapeProduction probe
1 — vendor 5 lexiconssimocracy-indexer#2schema 32 → 42 fields, lexicons 18 → 24pass
3a — compat URI/DID/rkey filtersimocracy-indexer#1records(uri:…) server-side filterpass
4a — recordEvents + WS path aliassimocracy-indexer#3 + follow-up 1d7befdWS connects on both /graphql and /graphql/wspass
2 — URL flips + NSID renamesimocracy-v2#10compat shape, single base URL, canonical NSIDspass
3b — typed→compat rewritesimocracy-v2#115 callsites in lib/indexer.ts portedpass

Walkthrough

Mr Meow's proposal listed on simocracy.org/proposals
01Proposal visible on /proposals. “Open the indexer; close the silos.” by MR MEOW [SIM] · @DAVIDDAO.ORG · 1M AGO in The Senate. Card renders with title, short description, byline, gathering, and tags — the full path the May-10 report said was broken now closed. Other recent @daviddao.org proposals (Sovereign AI Salon, Filecoin Onchain Cloud) are also visible above and below it.
Standalone proposal page rendering description, budget, and tags
02Standalone proposal page. The full markdown description renders cleanly — including the 5-phase summary and the “If you’re reading this on simocracy.org/proposals: the bsky.network → relay → simocracy-indexer’s bsky tap → indexer DuckDB → v2’s compat fetch → /proposals SSR loop is closed” payload paragraph. Budget item $1 Migration proof shows in the requested-budget block. descriptionToText() handled the union-shape parsing without the typed-Hyperindex flattening v2 used to depend on.
Mr Meow's comment on Endowment Fund
03Comment rendered with sim attribution. On Endowment Fund: “Mr Meow [SIM] · SPOKEN BY @DAVIDDAO.ORG · 7M” — proves the org.impactindexer.review.comment + org.simocracy.history sidecar pair flow through the new compat-shape read in lib/notifications/index.ts and that v2 still joins sim attribution from the history sidecar at render time.
Recent Activity feed showing Mr Meow's writes
04/stats Recent Activity. Top two entries: “David Dao proposed as Mr Meow on ‘Open the indexer; close the silos.’ · 8M AGO” and “David Dao commented as Mr Meow on ‘Endowment Fund’ · 8M AGO”. Both attribution rows come from joining the history sidecar to the parent record — the same pipeline the May-10 report observed working for one collection but not the other.
Endowment Fund proposal page after cleanup — 'No comments yet.'
05Cleanup verified on the rendered UI. Same Endowment Fund page as 03, after deleteRecord on all 5 smoke URIs (proposal + proposalContext sidecar + 2 history sidecars + comment) using the user’s OAuth session. DISCUSSION → No comments yet. ISR cache lag on the /proposals listing required ?nocache=1 to bust; the proposal-detail SSR was authoritative. PDS listRecords + indexer records(uri:…) point-fetch both confirmed zero remaining records before the screenshot.

Findings

Phase 1 (PR #2): five lexicons vendored, indexer schema grew from 32 → 42 query fields

Vendored canonical lexicons org.hypercerts.collection, org.hypercerts.context.evaluation, org.hypercerts.context.measurement, app.certified.badge.definition, app.certified.badge.award from hypercerts-org/hypercerts-lexicon@develop, plus app/certified/defs.json as a peer ref. TAP_COLLECTION_FILTERS alphabetically expanded on both tap (bsky.network) and tap-epds (certified.one) services. After Railway redeploy, the schema reported 42 typed query fields (was 32) and the periodic-reconciliation tick filled the new collections within an hour: orgHypercertsContextEvaluationAggregate(count) = 12, appCertifiedBadgeDefinitionAggregate(count) = 10, appCertifiedBadgeAwardAggregate(count) = 8. Side-effect: the canonical NSID for evaluations is org.hypercerts.context.evaluation, not the legacy .claim.evaluation v2 was reading; v2’s notification fan-out got migrated to the canonical NSID in Phase 2.

Phase 3a (PR #1): compat records() takes uri / did / rkey server-side

Added optional filters to the legacy records(collection:…) GraphQL handler. The DuckDB schema already had did and rkey as indexed columns and uri as the primary key, so the filter is a composed WHERE, not a LIKE scan. Production probe: records(collection:"org.hypercerts.claim.activity", uri:"at://…/3mlif5yg5m32e") returned exactly one edge with hasNextPage:false. v2’s Phase 3b uses this — fetchActivitiesByUris went from N round-trips per page to N round-trips per request, each with O(1) lookup instead of O(collection-size).

Phase 4a (PR #3 + follow-up 1d7befd): recordEvents compat subscription + WS path alias

Hand-rolled extension of adapter.schema with a recordEvents(collection: String): RecordEvent field that fans the existing per-collection EventEmitter into a single stream — the field v2’s useIndexerSubscription.ts calls. Mounted the WebSocket server at both /graphql and /graphql/ws so v2’s toWsUrl() output keeps working. Both v2 subscription stack bugs the audit had previously documented (wrong path + wrong field name) are now fixed entirely server-side, no v2 changes required.

Phase 2 (PR #10): v2 URL flips + canonical evaluation NSID

Three trivial flips at callsites already on the legacy records(collection:…) compat shape: lib/notifications/index.ts likes / evaluations / badge-awards fan-out, scripts/backfill-proposal-context.ts (rewritten from the typed sortBy form to compat + client-side createdAt sort), and app/skill.md/route.ts. HYPERINDEXER_URL kept as a @deprecated alias of DEFAULT_INDEXER_URL so lib/indexer.ts stayed functional through Phase 3b. Build + lint clean, zero api.hi.gainforest.app matches in lib/, app/, scripts/, hooks/.

Phase 3b (PR #11): five typed Hyperindex callsites in lib/indexer.ts rewritten to compat shape

orgHypercertsClaimActivity (paginated), orgHypercertsClaimActivityByUri (used by 4 callers), and orgHypercertsCollectionByUri all gone. New fetchRecordByUri() helper tries the Phase-3a uri filter first, falls back to a paginated collection scan if the indexer ignores it (degrades gracefully if the two phases ship out of order). resolveActivityImages simplified — the second network round-trip is gone since the compat path inlines the raw blob ref. parseItemUri learned to accept the raw {uri,cid} StrongRef object in addition to the Go-serialized string Hyperindex used to flatten to. HYPERINDEXER_URL import dropped from lib/indexer.ts; only the deprecated alias in indexer-utils.ts remains, scheduled for removal in Phase 5.

End-to-end smoke test on Mr Meow / did:plc:qc42fmqqlsmdq7jiypiiigww

The exact DID the May-10 report identified as broken. Five records written via simocracy_post_proposal + simocracy_post_comment MCP tools (proposal + proposalContext sidecar + history sidecar; comment + history sidecar). All five reached the indexer within ~8s of the writes — verified via records(uri:…) point-fetch returning count=1 for each. /proposals SSR rendered the proposal card with full byline. The standalone proposal page rendered description, budget item, and tags. The Endowment Fund proposal page rendered the comment with Mr Meow [SIM] · SPOKEN BY @DAVIDDAO.ORG attribution. /stats Recent Activity showed both writes as “David Dao proposed/commented as Mr Meow”.

Per skill simocracy-smoke-test §0 (mandatory cleanup) all five records were then deleted via com.atproto.repo.deleteRecord using the user’s existing OAuth session, then verified absent on three layers: PDS listRecords (zero hits across all four collections), indexer records(uri:…) point-fetch (count=0 each), rendered UI (?nocache=1 on the proposal page returned to No comments yet.). Zero records stranded.

Phase 5 — deprecated alias and quirk prose deleted (2026-05-17)

One week after the migration shipped, the scheduled Phase 5 cleanup ran. Telemetry pulled before any code change: recordCount grew 1116 → 1335 (+219 records), knownDids grew 57 → 86 (+29 new DIDs), both taps stayed connected, insertStats.batched climbed to 170k with zero individual-fallback errors. All five Phase-1-vendored collections were populated and growing — most tellingly org.hypercerts.collection (0 → 6) and org.hypercerts.context.measurement (0 → 16), which were empty at merge — proof the firehose has been delivering steadily across the new tap filter for a full week. /proposals populated, /skill.md never mentioned api.hi.gainforest.app.

Green light, ran the cleanup: simocracy-v2 #12 deletes the @deprecated HYPERINDEXER_URL re-export from lib/indexer-utils.ts, drops the now-vestigial indexerUrl?: string parameter from the three helpers the plan named (fetchActivitiesByUris, fetchProposalsByFloorTag, fetchCollectionProposals) + their three external call sites, and rewrites every Hyperindex-quirk comment across proposals-feed.tsx, both s-process modals, budget-items.ts, lexicon-types.ts, proposal-context.ts, useIndexerSubscription.ts, and app/api/proposals/for-gathering/route.ts to describe what the indexer actually does today. The normalization helpers (descriptionToText, workScopeToString, resolveActivityImages) stay — they handle union-shape variation intrinsic to the lexicon, not Hyperindex-specific drift. npm run build passes; rg -i hyperindex returns only the 34 intentional historical references in plans/. Merged + Vercel-redeployed at 2026-05-17T11:01Z. The migration is now end-to-end done.

Skills + agent docs reconciled with the new path

Two agent-facing skill surfaces called out the old “two indexers” framing and were updated post-migration:

Caveats & follow-ups

Reproducing

Plansimocracy-v2/plans/PLAN_drop_hyperindex.md PRs (indexer)#2 lexicons · #1 compat URI filter · #3 recordEvents + WS · follow-up 1d7befd PRs (v2)#10 URL flips · #11 typed→compat DID under testdid:plc:qc42fmqqlsmdq7jiypiiigww (PDS blewit.us-west.host.bsky.network) SimMr Meow at at://did:plc:qc42fmqqlsmdq7jiypiiigww/org.simocracy.sim/3mfo6vwfaka24 GatheringThe Senate at at://did:plc:awtx57rxuisy4sf4kzzr3uhf/org.simocracy.gathering/3mijiollfqs2i Indexersimocracy-indexer-production.up.railway.app Cross-linkflue-simocracy / browser-harness-smoketest — the May-10 finding this run closes

Smoke commands

Schema field count (after Phase 1)curl -s 'https://simocracy-indexer-production.up.railway.app/graphql' -H 'Content-Type: application/json' --data '{"query":"{__schema{queryType{fields{name}}}}"}' | jq '.data.__schema.queryType.fields | length' → 42 (was 32) URI filter (Phase 3a)curl -s … --data '{"query":"query Q($collection:String!,$uri:String){records(collection:$collection,uri:$uri,first:5){edges{node{uri}} pageInfo{hasNextPage}}}","variables":{"collection":"org.hypercerts.claim.activity","uri":"…/3mlif5yg5m32e"}}' → 1 edge, hasNextPage:false WS subscription (Phase 4a)graphql-ws client connects on both wss://…/graphql and wss://…/graphql/ws, subscribes to recordEvents(collection:"org.simocracy.sim"), accepts the schema Posts as Mr Meowsimocracy_post_proposal + simocracy_post_comment MCP tools (records appear at the indexer in ≤8s) Cleanupnode --experimental-strip-types pi-simocracy/scripts/cleanup-smoke-test.mjs — uses the user’s OAuth session to call com.atproto.repo.deleteRecord on each of the 5 written URIs Cleanup verificationlistRecords on the PDS (blewit.us-west.host.bsky.network) returns zero smoke rkeys; records(uri:…) point-fetch returns count=0; /proposals/<rkey>?nocache=1 shows “No comments yet.”