simocracy-indexer · drop-hyperindex-migration
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.
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.
| Phase | Repo | PR | Wire shape | Production probe |
|---|---|---|---|---|
| 1 — vendor 5 lexicons | simocracy-indexer | #2 | schema 32 → 42 fields, lexicons 18 → 24 | pass |
| 3a — compat URI/DID/rkey filter | simocracy-indexer | #1 | records(uri:…) server-side filter | pass |
4a — recordEvents + WS path alias | simocracy-indexer | #3 + follow-up 1d7befd | WS connects on both /graphql and /graphql/ws | pass |
| 2 — URL flips + NSID rename | simocracy-v2 | #10 | compat shape, single base URL, canonical NSIDs | pass |
| 3b — typed→compat rewrite | simocracy-v2 | #11 | 5 callsites in lib/indexer.ts ported | pass |
/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.
$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.
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.
/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.
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.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.
records() takes uri / did / rkey server-sideAdded 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).
1d7befd): recordEvents compat subscription + WS path aliasHand-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.
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/.
lib/indexer.ts rewritten to compat shapeorgHypercertsClaimActivity (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.
did:plc:qc42fmqqlsmdq7jiypiiigwwThe 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.
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.
Two agent-facing skill surfaces called out the old “two indexers” framing and were updated post-migration:
https://www.simocracy.org/skill.md (auto-generated by app/skill.md/route.ts) was already fixed by Phase 2 PR #10 — the route now emits a single-indexer cheat-sheet with no api.hi.gainforest.app mentions, surfacing automatically on Vercel redeploy.simocracy-org-agent-guide (the published org.simocracy.skill at 3mlde5d42qp2n) was a static record on Mr Meow’s PDS, so the description and body had drifted. Patched in place via com.atproto.repo.putRecord using the user’s OAuth session: description rewritten from “the two indexers” to “the single Simocracy indexer”, lexicon table extended with the 5 newly-indexed collections (collection, context.evaluation, context.measurement, badge.definition, badge.award), Hyperindexer-tagged callouts dropped, comment-thread recipe rerouted to simocracy-indexer-production.up.railway.app. updatedAt stamped to 2026-05-10T11:01:12Z. The auto-indexed catalog on /skill.md picked up the new short description on the next refresh.WebSocketServer({server}) upgrade race. Phase 4a’s initial implementation attached two WebSocketServer({server,path}) instances to the same http.Server. ws registers an upgrade listener per server, so every WS upgrade was handled twice — one of them through the wrong WSS, corrupting frames (“Invalid WebSocket frame: RSV1 must be clear” on /graphql, HTTP 400 on /graphql/ws). Surfaced by the production probe immediately after merge; fixed in follow-up 1d7befd with noServer:true + a single upgrade event router. Lesson: this is exactly the kind of bug the deferred local boot test (skipped because duckdb’s prebuilt binary doesn’t ship for Node 25) would have caught.did:plc:qc42fmqqlsmdq7jiypiiigww fail typed description reads because lex-gql’s resolver only checks value.$type and many records lack the canonical wrapper. We chose to ship hypercerts reads on the compat records() shape (raw value JSON, parsed client-side via the existing descriptionToText helper) rather than try to harden lex-gql. Worth filing upstream eventually./proposals listing. The proposal-card listing kept showing “1 COMMENT” on Endowment Fund for ~30s after the comment was deleted. The proposal-detail SSR was authoritative immediately. Not a regression from the migration — Vercel ISR has always behaved this way — but worth knowing for future smoke tests.simocracy-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
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.”