Skip to content

Architecture

Guide for contributors working on HappyView itself. For an overview of the product, see the Introduction.

graph LR
  accTitle: System overview
  App

  App -->|"GET /xrpc/{method}"| Query
  App -->|"POST /xrpc/{method}"| Procedure

  subgraph HappyView
    Query["Query handler<br/><small>Script (optional)</small>"]
    Procedure["Procedure handler<br/><small>Script (optional)</small>"]
    Ingest["Ingest pipeline<br/><small>Record scripts</small>"]
    Backfill["Backfill worker"]
    Spaces["Spaces<br/><small>Permissioned data</small>"]
  end

  Procedure --> DB
  Query --> DB
  Spaces --> DB
  Ingest --> DB

  Procedure -->|proxy write| PDS["User PDS"]

  DB[("SQLite / PostgreSQL<br/><small>records · lexicons · spaces</small>")]

  Jetstream["Jetstream<br/><small>WebSocket</small>"] -->|record events| Ingest
  Jetstream -->|archive replay| Backfill
  Relay["Relay<br/><small>listReposByCollection</small>"] -->|account discovery| Backfill
  Backfill -->|listRecords| PDS
  Backfill --> Ingest

  Labeler["Labeler<br/><small>WebSocket (out-of-band)</small>"] -->|label events| DB

Queries go through the query handler to the database (SQLite by default, or Postgres). Writes go through the procedure handler to the user’s PDS, then HappyView indexes the record locally. Real-time record events stream in via Jetstream (its v2 subscribeEvents stream when the host offers it, otherwise v1), applied in order for each repo, with the stream position saved only once every earlier event has been applied; historical records are backfilled in-process, either by replaying the Jetstream archive or by listing accounts through the relay’s listReposByCollection and fetching records directly from each PDS. Every source hands its events to one ingest pipeline (src/ingest.rs), which runs the record script and writes the record. Labelers are external services that emit content labels over a direct WebSocket connection, outside the relay and repository system. Spaces provide permissioned data containers with membership-gated access, per-user repo state tracking (LtHash + signed commits), and cross-service credential-based authentication.

sequenceDiagram
    accTitle: Reads (queries)
    participant C as App
    participant X as xrpc_get()
    participant R as LexiconCatalog
    participant L as Script
    participant D as Database

    C->>X: GET /xrpc/{method}?params
    X->>R: Look up<br/>(must be a query)
    R-->>X: Lexicon
    X->>X: Rate limit, validate parameters
    alt Script bound to the query
        X->>L: Run in its interpreter
        L->>D: happyview.db / happyview.sql
        D-->>L: Results
        L-->>X: Response
    else No script
        X->>D: List target_collection,<br/>or get by uri
        D-->>X: Results
    end
    X-->>C: JSON response

A procedure is authenticated, routed and validated first, and only then reaches the code that writes.

sequenceDiagram
    accTitle: Writes, from the request to the procedure handler
    participant C as App
    participant A as XrpcClaims
    participant X as xrpc_post()
    participant R as LexiconCatalog
    participant H as handle_procedure()

    C->>A: POST /xrpc/{method}<br/>DPoP auth, X-Client-Key
    A->>X: Validated claims
    X->>X: 401 if there are none
    X->>R: Look up<br/>(must be a procedure)
    R-->>X: Lexicon
    X->>X: Rate limit, validate<br/>parameters and input
    X->>H: Claims, input, lexicon
    H->>H: Resolve the<br/>script, if any

A write then goes to the caller’s PDS the same way with a script or without one, and handle_procedure() answers the app with the script’s return value or the PDS’s response.

sequenceDiagram
    accTitle: Writes, from the procedure handler to the PDS
    participant H as handle_procedure()
    participant L as Script
    participant S as PDS session
    participant P as User PDS
    participant D as Record store

    alt Script bound to the procedure
        H->>L: Run in its interpreter
        L->>S: happyview.record<br/>create, put or delete
    else No script
        H->>S: The lexicon's action
    end
    S->>P: createRecord, putRecord<br/>or deleteRecord
    P-->>S: PDS response
    S->>D: Write or delete<br/>the local copy

Without a script, the lexicon’s action picks the write. The default, upsert, puts when the input has a uri field and creates otherwise. The PDS session is DPoP for an app’s user and OAuth for the dashboard.

sequenceDiagram
    accTitle: Admin endpoints
    participant C as Caller
    participant A as UserAuth
    participant U as Users table
    participant H as Admin handler
    participant D as Database

    C->>A: Request with a cookie,<br/>API key or DPoP session
    A->>A: Validate the credential
    A->>U: DID lookup
    alt Users table empty, no SUPERADMIN_DID
        U-->>A: Auto-bootstrap with superadmin
    else User found
        U-->>A: Load permissions
    end
    A->>A: Permission check (403 if missing)
    A->>H: Authorized request
    H->>D: Database operation
    D-->>H: Result
    H-->>C: JSON response

A DPoP session also needs the dev.happyview.adminFull grant, and an API key skips the bootstrap. A service-auth JWT is refused with 403.

sequenceDiagram
    accTitle: Real-time indexing
    participant J as Jetstream WebSocket
    participant H as HappyView
    participant D as Database
    participant R as LexiconCatalog

    H->>J: Connect (collection filters from indexed lexicons)
    loop Stream events
        J->>H: Record commit event
        H->>H: Run the record script, if any
        alt create / update
            H->>D: UPSERT into records table
        else delete
            H->>D: DELETE from records table
        end
    end
    J->>H: Lexicon schema event (com.atproto.lexicon.schema)
    H->>D: Update tracked network lexicons
    H->>R: Update the in-memory catalog
    Note over H,D: Applied watermark persisted to instance_settings for resume on reconnect
    Note over H,J: Reconnects on collection filter changes (lexicon add/remove)

Ordering. Events are split across JETSTREAM_WORKERS workers by account DID, so each repo’s events apply in the order they arrived while different repos apply in parallel. Each worker runs one event at a time, record script included, so script latency bounds throughput: with slow scripts on Postgres, raise JETSTREAM_WORKERS. The saved cursor is the applied watermark: the position below which every event has been applied. After a restart or reconnect HappyView resumes from it, so delivery is at-least-once, and writes are idempotent (a replayed record carries the same rev and is ignored).

Accounts. An account event with active=false is handled by its status:

  • deleted, takendown and suspended purge the account’s records and leave an account tombstone (jetstream.account_deleted, jetstream.account_takendown). A relay can still be relaying a PDS that a deleted account has left. So before purging a deleted account it holds records for, HappyView asks the account’s current PDS (com.atproto.sync.getRepoStatus, 5 second timeout). If that PDS says the account is active, nothing is purged or tombstoned and jetstream.account_status_mismatch is logged. If the PDS cannot be asked, the purge goes ahead. takendown and suspended are purged without asking, because a relay may impose them and the account’s PDS cannot see that.
  • deactivated, desynchronized and throttled keep the records (jetstream.account_inactive).
  • active=true lifts the account’s tombstone. Records that were purged come back only through a new backfill.
  • A #sync removes the repo’s records in registered collections, and the current ones that follow replace them. identity events change nothing, because HappyView caches no identities.

Failures.

  • A database outage is retried indefinitely with growing delays, at most every 16 seconds by default, so ingest pauses rather than skipping events. While paused, the consumer cannot notice a collection change or a shutdown until the queue drains.
  • Any other failure is retried for about 31 seconds, then logged as jetstream.apply_failed and skipped.
  • Invalid input (no record body, an unknown operation, data the database rejects) is skipped at once with one jetstream.record_invalid warning.

Recovering a cursor.

  • A cursor older than Jetstream keeps (CursorTooOld) is replayed from the Jetstream archive when it has one and BACKFILL_SOURCE is auto or replay: see Gap replay under Backfill. Otherwise it resumes from the stored witnessed time. Jetstream clamps that to its oldest event and announces the gap with #info OutdatedCursor, which is logged as jetstream.gap with reason outdated_cursor. Only when there is no stored time does HappyView resume from the live tip, logged as cursor_too_old. A backfill recovers what was missed.
  • If the server renumbers its seqs (FutureCursor), HappyView resumes by witnessed time too.
  • An unexplained 400 first drops compression, then the seq cursor (only when a seq was sent and a witnessed time is saved; otherwise it goes straight to the hourly retry and keeps the cursor), then retries hourly and logs jetstream.invalid_request.
  • Under auto, a 404 falls back to v1 until the process restarts.

Edge cases of revs and tombstones.

  • A record script can run for a write that a tombstone then refuses.
  • An account tombstone’s rev is a TID of the time Jetstream witnessed the event. A PDS whose clock differs from Jetstream’s can therefore let an older replay through, or block a newer write until the tombstone is swept (RECORD_TOMBSTONE_RETENTION_HOURS, 72 by default).
  • A stale record tombstone left after a newer recreate blocks writes without a rev (crawl writes) to that record until the sweep.
  • A crawl row fetched before a live update but written after it can overwrite the newer body while keeping the stored rev. A later replay at that rev heals it.
  • A delete with a rev removes a crawled row that has none.
  • A delete for a record HappyView does not hold leaves a tombstone only when the account holds records in that collection, or a running or pausing backfill covers it. Those are the cases where a create could still arrive. The network sends many deletes for records an instance never held, and tombstoning each would fill the table.

Operators can watch the consumer with GET /admin/jetstream/status and the About page’s Jetstream card. See Jetstream status.

sequenceDiagram
    accTitle: Backfill
    participant A as Operator
    participant H as HappyView
    participant D as Database
    participant Relay as Relay
    participant PLC as PLC Directory
    participant PDS as User PDS

    A->>H: POST /admin/backfill
    H->>D: Create backfill_jobs record (status = running)

    rect rgb(240, 255, 240)
    note over H,PDS: Discovery, resolution and fetching run concurrently
    par Discovery (network backfills)
        loop Each collection, while the queue has room
            H->>Relay: listReposByCollection (one page)
            Relay-->>H: DIDs
            H->>D: Queue (did, collection) units, save the relay cursor
        end
    and Resolver task
        loop Unresolved units
            H->>PLC: Resolve DID document
            PLC-->>H: PDS endpoint
            H->>D: Record the unit's PDS
        end
    and Fetcher task
        loop Resolved units (as they arrive)
            H->>PDS: listRecords for the unit's collection (paginated)
            PDS-->>H: Records
            H->>D: UPSERT each page
            H->>D: Delete the completed unit, bump counters
        end
    end
    end
    Note over H,D: At most BACKFILL_DISCOVERY_WINDOW units are queued per job

    H->>D: Mark job completed (or failed)

Two sources. A backfill job reads its records from one of two places, chosen by BACKFILL_SOURCE (default crawl) or the job’s source. A crawl runs the pipeline above, and every account backfill crawls. A replay asks the Jetstream archive for a plan (planSnapshot), downloads the segments and blocks that hold the job’s collections, and writes the records through the same path as live events. A second sweep then applies the account and sync events for the accounts that hold records. Markers are applied as of their own time, so the two sweeps give the same result in either order and never remove records written after them. auto replays when the Jetstream has an archive and crawls when it doesn’t. A replay returns only what the archive holds; some accounts’ history in Bluesky’s archive starts after their first record, which a crawl would find.

Gap replay. When the live consumer’s cursor is older than Jetstream keeps, HappyView creates a replay job for the gap between its last applied seq and the archive’s sealed tip, and resumes the live stream at that tip. The consumer waits at most 30 seconds for the archive’s plan; past that it resumes without a replay. Live events and the replay overlap safely, because the rev guard and tombstones make every write idempotent. Instances on BACKFILL_SOURCE=crawl (the default), or on a Jetstream without an archive, skip the gap.

Tables are named happyview_<name>, and the headings below drop the prefix. These are the tables a contributor meets first, not all of them. Types are indicative: SQLite stores most of these as TEXT or INTEGER, and migrations/ holds each backend’s definitions.

happyview_records is a view, and it is what reads and raw SQL see. The rows live in happyview_records_v2, with DIDs and collection NSIDs interned in happyview_dids and happyview_collections. A record’s body is its JSON text exactly as received: record on Postgres, and body on SQLite, which a background task compresses with a per-collection zstd dictionary.

ColumnTypeDescription
uritextAT URI (at://did/collection/rkey), derived
didtextAuthor DID
collectiontextLexicon NSID
rkeytextRecord key
recordtext (JSON)Record value
cidtextContent identifier
indexed_attimestampWhen HappyView indexed this version of the record. Redelivering an identical record (same CID and body) keeps the original value. A local write that Jetstream will echo back (an XRPC procedure, a happyview.record mirror, a linked-repo write) stores NULL, on insert and on update, until the echo stamps it. A plugin’s direct local-index write gets no echo, so an update through it keeps the existing value
created_attimestampWhen the row was first written
revtextTID of the repo commit that wrote this version, from Jetstream. NULL for local writes, listRecords backfill rows and rows written before revs were stored. A delivery with an older rev than the stored one is ignored

Recently deleted records (uri, rev, deleted_at) and deleted accounts (did, rev, seq, deleted_at). A create or update at or before a tombstone’s rev, or one without a rev, is not written, so a late or replayed copy cannot bring a deleted record back. Tombstones are kept for RECORD_TOMBSTONE_RETENTION_HOURS (72 by default).

ColumnTypeDescription
idtext (PK)Lexicon NSID
revisionintegerIncremented on upsert
lexicon_jsontext (JSON)Raw lexicon definition
backfillbooleanWhether to backfill on upload
target_collectiontextFor queries/procedures: which record collection
actiontextFor procedures: create, update, delete or upsert
sourcetextmanual or network
authority_didtextFor a network lexicon: its authority’s DID
last_fetched_attimestamptzFor a network lexicon: when it was last fetched
token_costintegerRate-limit cost, overriding the type’s default
indexedbooleanWhether HappyView ingests this collection
created_attimestamptz
updated_attimestamptz
ColumnTypeDescription
iduuid (PK)
didtext (unique)User’s atproto DID
is_superbooleanWhether this operator holds superadmin
created_attimestamptz
last_used_attimestamptzUpdated on each authenticated request
ColumnTypeDescription
user_iduuid (FK)References users.id
permissiontextPermission string (e.g. lexicons:create)
granted_attimestamptz
granted_byuuid (FK)References users.id
(PK)Composite primary key: (user_id, permission)
ColumnTypeDescription
iduuid (PK)
user_iduuid (FK)References users.id
nametextDescriptive label
key_hashtextSHA-256 hash of the full key
key_prefixtextFirst 11 characters for display
permissionstext (JSON)Permissions granted to this key
created_attimestamptz
last_used_attimestamptz
revoked_attimestamptzSet when revoked (soft delete)
ColumnTypeDescription
didtext (PK)User’s atproto DID
session_datatextSerialized OAuth session (managed by atrium)
signing_kidtextThe client key that established the session
created_attimestamptz
updated_attimestamptz
ColumnTypeDescription
state_keytext (PK)OAuth state parameter
state_datatextSerialized state (managed by atrium)
created_attimestamptz
ColumnTypeDescription
keytext (PK)Setting name (e.g. app_name)
valuetextSetting value
updated_attimestamptzLast modified
ColumnTypeDescription
iduuid (PK)
event_typetextCategory.action format (e.g. user.created)
severitytextinfo, warn, or error
actor_didtextDID of the user who triggered the event
subjecttextWhat was affected (DID, NSID, URI, etc.)
detailjsonbEvent-specific data
created_attimestamptz
ColumnTypeDescription
keytext (PK)Variable name
valuetextVariable value
created_attimestamptz
updated_attimestamptz
ColumnTypeDescription
idtext (PK)Internal space identifier
didtextDID in the space URI; the authority’s DID
authority_didtextThe space authority: the instance DID, or the creator’s DID when the instance has no service identity
creator_didtextDID of the user who created and administers the space
type_nsidtextSpace type as an NSID
skeytextSpace key (differentiates spaces of the same type)
display_nametextHuman-readable name (optional)
descriptiontextDescription (optional)
read_policytext (JSON)Policy gating credential issuance ($type-tagged)
write_policytext (JSON)Policy gating which writers the space tracks ($type-tagged)
app_accesstext (JSON){"$type":"com.atproto.simplespace.defs#open"} or {"$type":"com.atproto.simplespace.defs#allowList","allowed":[...]}
configtext (JSON)Space config (membership_public, records_public, extras)
revisiontextCurrent revision TID
created_attext
updated_attext
ColumnTypeDescription
idtext (PK)
space_idtext (FK)References spaces.id
member_didtextMember’s DID (or space URI for delegation)
can_readbooleanWhether the member can read the space
can_writebooleanWhether the member can write to the space
read_selfbooleanWhether reads are limited to the member’s own records
is_delegationbooleanWhether this member is a delegated space
granted_bytextDID of who granted membership
created_attext
ColumnTypeDescription
uritext (PK)at:// URI of the record
space_idtext (FK)References spaces.id
author_didtextDID of the record author
collectiontextLexicon NSID
rkeytextRecord key
recordjsonbRecord value
cidtextContent identifier
indexed_attext
ColumnTypeDescription
idtext (PK)
space_idtext (FK)References spaces.id
author_didtextDID of the repo author
lthash_statebytea2048-byte LtHash state
revtextCurrent revision
hashbyteaContent hash
ikmbyteaInput keying material for deniable signatures
sigbyteaSignature
macbyteaMessage authentication code
host_modetextWhere the repo lives: polyfill, migrating or native
sync_cursortextLast rev consumed from the PDS in native mode
updated_attext
ColumnTypeDescription
idtext (PK)
space_idtext (FK)References spaces.id
author_didtextDID of the operation author
revtextRevision this operation belongs to
idxintegerIndex within the revision
actiontextcreate, update, or delete
collectiontextLexicon NSID
rkeytextRecord key
cidtextContent identifier (for create/update)
prevtextPrevious CID (for update/delete)
created_attext
ColumnTypeDescription
idtext (PK)
space_idtext (FK)References spaces.id
servicetextSubscribing service; one registration per space
endpointtextResolved service endpoint, or webhook URL
deliverytextxrpc (notifyWrite calls) or webhook (legacy payload)
registered_bytextDID of who registered
expires_attextWhen the registration expires
created_attext
ColumnTypeDescription
space_idtext (FK)References spaces.id
repo_didtextDID of a repo the space tracks
revtextThe repo’s latest revision
hashblobThe repo’s latest LtHash digest
space_revtextSpace revision assigned to this update
updated_attext
ColumnTypeDescription
issuertextIssuer of an exchanged delegation token
jtitextThe token’s jti; a token is exchanged once
expires_attextWhen the token expires
ColumnTypeDescription
idtext (PK)
space_idtext (FK)References spaces.id
token_hashtextSHA-256 hash of the invite token
created_bytextDID of the user who created the invite
can_readbooleanWhether the invite grants read access
can_writebooleanWhether the invite grants write access
read_selfbooleanWhether reads are limited to the member’s own records
max_usesinteger?Maximum number of uses (null = unlimited)
usesintegerCurrent use count
expires_attext?Expiry timestamp (null = never)
revokedbooleanWhether the invite has been revoked
created_attext
ColumnTypeDescription
idtext (PK)
space_idtext (FK)References spaces.id
issued_totextDID the credential was issued to
token_hashtextHash of the credential token
jtitextThe credential’s jti claim
expires_attextWhen the credential expires
revoked_attext?When the credential was revoked
created_attext
ColumnTypeDescription
idtext (PK)
didtextThe space’s DID
space_idtext (FK)References spaces.id
signing_key_enctextEncrypted signing key (AES-256-GCM)
rotation_key_enctextEncrypted rotation key (AES-256-GCM)
created_bytextDID of who provisioned the key
created_attext
ColumnTypeDescription
idinteger (PK)Always 1 (singleton)
modetextdid_web, did_plc, attach_account or not_exposed
didtextThe service’s DID
signing_key_enctextEncrypted signing key
rotation_key_enctext?Encrypted rotation key (did:plc only)
attached_account_didtext?The attached account’s DID (attach_account mode)
setup_completebooleanWhether setup has been finalized
created_attext
updated_attext
ColumnTypeDescription
idinteger (PK)
fragment_idtextDID document fragment identifier
service_typetextService type (e.g. AtprotoAppView)
access_modetextall, or specific for the XRPCs in service_entry_xrpcs
created_attext
updated_attext
ColumnTypeDescription
service_entry_idinteger (FK)References service_entries.id
lexicon_idtextLexicon NSID this entry handles
ColumnTypeDescription
idtext (PK)
fragment_idtextDID document fragment (e.g. #atproto_space)
key_typetextAlways Multikey
public_key_multibasetextPublic key in multibase encoding
private_key_enctextEncrypted private key (AES-256-GCM)
created_attext
ColumnTypeDescription
iduuid (PK)
collectiontextTarget collection (null = all)
didtextTarget DID (null = all)
statustextpending, running, pausing, paused, cancelling, cancelled, completed, failed
stagetextpending, discovering_repos, resolving_and_fetching, completed, failed, cancelled
total_reposintegerWork units discovered (a unit is one account under one collection; one per account for account-targeted jobs)
resolved_reposintegerUnits with PDS endpoint resolved
processed_reposintegerUnits with records fetched
total_recordsintegerTotal records indexed
errortextError message if failed
scopetextnetwork (accounts from the relay) or dids (accounts given at creation)
sourcetextcrawl or replay
notetextWhat the backfill executor said about the job
hook_statstext (JSON)What the job’s records cost in record scripts
started_attimestamptz
completed_attimestamptz
created_attimestamptz
Terminal window
# Unit tests (no database needed)
cargo test --lib
# Unit tests that run a script natively
cargo test --features lua-reference --lib
# All tests, against SQLite or Postgres
bash scripts/build-plugin-fixtures.sh
export TEST_DATABASE_URL='sqlite:///tmp/happyview-test.db?mode=rwc'
cargo test
# Or against Postgres
docker compose -f docker-compose.test.yml up -d
TEST_DATABASE_URL=postgres://happyview:happyview@localhost:5433/happyview_test cargo test
docker compose -f docker-compose.test.yml down

End-to-end tests use wiremock to mock external services (PLC directory, PDSes) and a real database for full integration coverage. A test that needs TEST_DATABASE_URL or a built plugin fixture fails without it, so a green run means it ran. Set HAPPYVIEW_ALLOW_SKIPS=1 to skip those tests instead.