Raven ScottandCursor da8541dd0c Phase 434: home-mode mention ingest and notification error logging (v0.8.397).
Persist guild inbound handler as ingestInboundGuildMessage for home view, resolve mentions from DB members when guild mesh is detached, and add structured error logs for mark-all, open notification, and mark-channel-read.

Co-authored-by: Cursor <[email protected]>
2026-05-23 18:09:41 -04:00

pearcord-platform

Application facade: one PearcordPlatform class that wires identity, database, guild mesh, DMs, invites, voice, discovery, bots, and dozens of feature modules for the Pearcord desktop sidecar and companion.

Mission

Expose a single EventEmitter API the UI drives over IPC (apps/pearcord/index.js), while owning session lifecycle (onboarding, guild/DM mode, active channel), permission checks, gossip bridging, and lazy initialization of feature registries (emoji, stickers, automation, search, …).

When to use / not

Use when:

  • Building the Pearcord app, companion read-only client, or headless smoke that needs full session semantics.
  • You want one import instead of composing pearcord-identity + pearcord-guild + pearcord-message yourself.

Do not use when:

  • Writing a minimal library that only needs constants (pearcord-shared) or raw DB access (pearcord-db).
  • Implementing a single feature in isolation — prefer the focused module (pearcord-voice, pearcord-discovery, …) and optional thin adapter.

Public API

Export Role
PearcordPlatform Main facade (~9k lines)
USER_STATUS Re-exported from pearcord-shared for UI

Session & identity

Method / property Role
ready() identity.ready(), channel descriptions, invite/attachments bootstrap if onboarded
register({ username, displayName }) _bootstrapAfterUser
importIdentityBundle(bundle) Device pair restore
onboarded, identity, storagePath, db, dbPath Session state
listGuilds(), loadGuild(guildId), createGuild({ name, publicListing? }), joinInvite(code) Guild session

Messaging & channels

Method Role
selectChannel(channelId) Sets messages.setChannel, read state, voice context
sendMessage, editMessage, deleteMessage Delegates to PearcordMessage + guild/DM gossip
signalTyping, toggleReaction Gossip + local state
createCategory, createForumPost, thread helpers PearcordGuild + permissions

Invites & discovery

Method Role
createInvite() PearcordInvite#create with channel snapshot
joinInvite(code) Resolve + guild.joinByInvite + mesh; pearcord://explore?…navigateDeepLink (v0.8.108)
markOnboardingExploreShown() Clear first-guild Explore prompt; onboardingExploreShown pref (v0.8.108)
setThreadsPanelFilter(filter) Filter chips; persists threadsPanelFilter in USER_PREFS (v0.8.108)
formatDiscoveryExploreBaselineDeepLink() pearcord://explore?baseline&at share URL (v0.8.107)

Voice & media

Method Role
joinVoice, leaveVoice, setVoiceMute pearcord-voice + guild gossip
setVoiceRosterChannelExpanded(channelId, expanded) USER_PREFS voiceRosterExpandedChannelIds (v0.8.110)
setDiscoveryFilter({ query, sort, minMembers, tag }) Persists discoveryExploreFilter in USER_PREFS (v0.8.111)
Voice/s screen share ingest VoiceMediaHub, ScreenShareHub wired in _openGuild

Views & companion

Property Role
mode 'home', 'guild', or DM mode
getViewState() / snapshot builders IPC state payloads for UI
_readOnly, _companionMode Companion restrictions

Hundreds of additional methods cover moderation, bots, search, audit export, forums, stage, automod, boosts, settings mesh, contacts, notifications — each delegating to the matching pearcord-* package.

P2P surface

Platform does not implement wire protocols; it orchestrates:

Subsystem Module Topic / protocol
Guild gossip pearcord-guild pearcord-gossip-v1 on guild.topic
DM mesh pearcord-dm dmTopic / group DM topics
Contacts pearcord-contacts contactsTopic
Settings / prefs pearcord-settings userPrefsTopic
Discovery pearcord-discovery Public listings mesh
Device pair pearcord-device-sync deviceSyncTopic + DEVICE_PAIR_* RPC

_wireGuild(guild) registers setMessageHandler and forwards guild events to platform.emit (message, member, guild-sync, voice, …).

Storage

Path Owner
{storagePath}/db/ LocalDatabase — all @pearcord/* collections
{storagePath}/identity/ pearcord-identity keypairs
{storagePath}/dm-meta/ JsonStore for DM metadata
Feature dirs attachments, discovery JSON, moderation, voice state, etc.

storagePath from resolveStoragePath() (PEARCORD_STORAGE or ~/.config/pearcord).

Platform integration (core batch A wiring)

// Constructor (simplified)
this.db = new LocalDatabase(this.dbPath)
this.identity = new PearcordIdentity({ db, storagePath })
// ready() → invite, attachments, discovery when snap.user

// Guild open (_openGuild)
this.guild = new PearcordGuild({ ownerId, userId, db })
this._wireGuild(this.guild)
await this.guild.joinMesh()
this.messages = new PearcordMessage({ authorId: user.id, db })
Dependency Role in platform
pearcord-db Shared this.db
pearcord-identity this.identity, storage paths
pearcord-guild Active server + mesh
pearcord-message this.messages per channel
pearcord-invite this.invite
pearcord-shared Permissions, COLLECTIONS, deep links, id, now

pearcord-channel and pearcord-sync are not dependencies.

UI / IPC

Primary consumer: apps/pearcord/index.js sidecar reading pear-pipe JSON.

Direction Examples
UI → platform register, create-guild, join-invite, select-guild, select-channel, send-message, join-voice

v0.8.115: loadGuild / _openGuild clears stale activeChannelId, then _selectGuildChannelWithUnread picks mention-weighted unread channels before falling back to the first text channel. setSearch records searchRecentByGuild in user prefs. View: searchRecentQueries.

v0.8.397: ingestInboundGuildMessage(msg) processes guild mesh gossip while on home (mention alerts + inbox via DB member lookup). markAllNotificationsRead / openNotificationTarget / markChannelRead log structured errors. Smoke helper: simulateGuildMessage(platform, msg) in smoke-runner.cjs.

v0.8.252: Guild search mesh skipped when guildOpenNoViewableChannels; emoji batch prefetch skips negative miss TTL; sticker gossip prefetch gated on attachments; session.startup logs INFO when initial guild mesh flush was bounded. Smoke: test:guild-search-no-viewable-skip, test:sticker-prefetch-attachments-ready.

v0.8.248: Guild open filters unread/last picks with _canViewChannel; sets guildOpenNoViewableChannels when none accessible; defers _ensureGuildModules via setTimeout(0) after channel pick; emoji mesh prefetch clears miss cache on success. Smokes: test:guild-viewable-channel-pick, test:guild-open-no-viewable, test:guild-plain-beats-dual-combo.

v0.8.158: Between two mention+plain parent buckets, the category with the higher combined score wins on guild open. Smoke: test:guild-dual-combo-buckets-highest.

v0.8.157: A heavy plain-only parent bucket whose total exceeds three lower mention+plain combo categories wins on guild open. Smoke: test:guild-plain-beats-triple-combo.

v0.8.156: Among three mention+plain parent buckets, the category with the highest combined score wins on guild open. Smoke: test:guild-triple-combo-buckets-highest.

v0.8.155: Among three plain-only parent buckets, the category with the highest combined unread total wins on guild open. Smoke: test:guild-triple-plain-bucket-pick.

v0.8.154: A mention+plain parent bucket whose combined score exceeds a higher plain-only bucket wins on guild open. Smoke: test:guild-combo-beats-plain-bucket.

v0.8.153: When plain-only and mention+plain parent buckets tie on total score, the bucket with the lower minimum channel position wins. Smoke: test:guild-plain-combo-minpos-tie.

v0.8.152: Between two plain-only parent buckets, the category with the higher plain-unread total wins on guild open. Smoke: test:guild-dual-plain-bucket-pick.

v0.8.151: A plain-only parent bucket whose unread total exceeds a lighter mention+plain combo bucket wins on guild open (e.g. 1004 plain vs 1003 combo). Smoke: test:guild-plain-beats-combo-bucket.

v0.8.150: Among three mention+plain parent buckets, the category with the highest combined score wins on guild open. Smoke: test:guild-triple-combo-bucket-pick.

v0.8.149: Equal mention+plain parent-bucket totals tie-break to the bucket whose unread channels have the lower minimum position. Smoke: test:guild-combo-bucket-minpos-tie.

v0.8.148: A mention+plain category bucket beats a plain-only bucket when both have the same plain-unread count (mention adds 1000 to bucket total). Smoke: test:guild-combo-beats-plain-equal.

v0.8.147: Between two categories each with mention+plain unread, the parent bucket with the higher combined score wins. Smoke: test:guild-dual-combo-bucket-pick.

v0.8.146: A category bucket with one mention plus plain unread (score 1001 + n) beats a higher plain-only bucket in another category. Smoke: test:guild-mention-plain-bucket-pick.

v0.8.145: A root (uncategorized) bucket whose plain-unread total exceeds 1000 beats two mention-weighted category buckets. Smoke: test:guild-root-plain-triple-mention-pick.

v0.8.144: Among three parent buckets, a mention-weighted bucket (score 1001+) beats two higher plain-unread buckets. Smoke: test:guild-mention-triple-bucket-pick.

v0.8.143: Three parent buckets with equal unread totals pick the bucket with the lowest minPos. Smoke: test:guild-bucket-minpos-triple-pick.

v0.8.142: When two parent buckets share equal unread totals and equal minPos, the first bucket encountered in channel scan order wins. Smoke: test:guild-bucket-minpos-order-pick.

v0.8.141: Equal parent-bucket unread totals across root and categories tie-break to the bucket with the lower minimum channel position. Smoke: test:guild-bucket-minpos-root-pick.

v0.8.140: removeSearchRecent(query) drops a guild-scoped recent search from searchRecentByGuild. A mention in the root bucket beats higher plain unread in a category. Smokes: test:search-recent-remove, test:guild-root-mention-bucket-pick.

v0.8.139: A mention-weighted category bucket beats higher plain unread in the root (uncategorized) bucket. Smoke: test:guild-mention-root-bucket-pick.

v0.8.138: With unread in both root and category buckets, parent-bucket totals decide the pool (highest score wins). Smoke: test:guild-mixed-bucket-pick.

v0.8.137: When unread exists in only one category among several, selection stays inside that category pool. Smoke: test:guild-dual-category-isolation-pick.

v0.8.136: In a single-category unread pool, mention-weighted score (mentions × 1000 + unread) beats a lower-position channel with equal plain unread. Smoke: test:guild-mention-single-category-pick.

v0.8.135: When all unread channels share one parentId, _selectGuildChannelWithUnread limits the pool to that category before position/score tie-breaks. Smoke: test:guild-single-category-pool.

v0.8.134: Uncategorized (parentId empty) and category buckets compete by total unread score; uncategorized totals can beat a lower-total category. Smoke: test:guild-uncategorized-bucket-pick.

v0.8.133: Parent-bucket selection prefers the highest total unread score over a bucket with a lower minPos but fewer unreads. Smoke: test:guild-bucket-total-over-minpos.

v0.8.132: Within one category, a channel with a mention alert beats another with the same plain unread count (mentions × 1000 + unread). Smoke: test:guild-mention-in-bucket-pick.

v0.8.131: Within a single parent bucket, equal unread/mention scores pick the channel with the lower sibling position. Smoke: test:guild-in-bucket-position-tie.

v0.8.130: Equal parent-bucket unread totals break ties by lower minimum channel position among unread rows in that bucket (sibling index within the parent). Smoke: test:guild-bucket-minpos-tie.

v0.8.126: Parent bucket totals use mention-weighted scores (mentions × 1000 + unread), so a mention category beats higher plain unread elsewhere. Smoke: test:guild-mention-bucket-pick.

v0.8.125: _selectGuildChannelWithUnread picks the parent bucket (category or uncategorized) with the highest total unread score, then best channel within it. Smokes: test:guild-category-bucket-pick, test:guild-category-unread-pick.

v0.8.124: _unreadForChannel filters rows by channelId after HyperDB find. When all unread are in one category, _selectGuildChannelWithUnread picks only within that parentId. Smoke: test:guild-category-unread-pick.

v0.8.123: Equal unread/mention scores tie-break to lower channel.position. Smoke: test:guild-unread-position-tie.

v0.8.120: _selectGuildChannelWithUnread scores mentions × 1000 + unread; mention channel beats higher plain unread. Smoke: test:guild-mention-channel-pick.

v0.8.119: _selectGuildChannelWithUnread order: highest unread/mention score → lastChannelByGuild → first text. Smokes: test:guild-unread-over-last-channel, test:guild-unread-channel-jump.

v0.8.116: selectChannel persists savable channel types via _persistLastGuildChannel. Smoke: npm run test:guild-last-channel-prefs.

v0.8.237: View exposes sessionReady, guildLoading, guildLoadingGuildId, sessionError. Emits user-prefs and guild-loading for UI pushState. openHome() cancels in-flight loadGuild.

| platform → UI | state, message, channel, notification, session-ready, user-prefs, guild-loading, user |

See IPC.md for the full message table. UI should not import guild/message modules directly in production builds.

Tests

Most apps/pearcord/scripts/smoke-*.cjs scripts construct PearcordPlatform with a temp storagePath. Runner: npm run test:smoke / smoke-runner.cjs.

Representative targets:

  • Guild/invite: test:guild-sync, test:invite, test:storage-path
  • Mesh RPC: smoke-voice-speaking-mesh.cjs, smoke-guild-sync.cjs
  • HyperDB: test:hyperdb (db only, but platform uses same path layout)

Code example

const path = require('bare-path')
const os = require('bare-os')
const { PearcordPlatform } = require('pearcord-platform')

const storagePath = path.join(os.tmpdir(), `pearcord-demo-${Date.now()}`)
const platform = new PearcordPlatform({ storagePath })

await platform.ready()
await platform.register({ username: 'demo', displayName: 'Demo' })

const { guild } = await platform.createGuild({ name: 'Test Server' })
await platform.sendMessage('Hello from the facade')

const view = platform.getViewState?.() ?? { mode: platform.mode, guildId: guild.id }
console.log(view)

await platform.close?.()

Minimal sidecar-style usage:

platform.on('message', (msg) => { /* push to UI */ })
platform.on('session-ready', () => { /* enable guild list */ })
platform.on('user-prefs', () => pushState()) // theme/density mesh sync (v0.8.237)
platform.on('guild-loading', () => pushState()) // guild switch overlay

View fields sessionReady, guildLoading, guildLoadingGuildId, sessionError (v0.8.237). UI must not show the main shell until sessionReady is true.

v0.8.174: updateGuildSettings({ name, iconHash, nsfwGateEnabled }) persists server-wide age-restricted gate toggle on guild record. View exposes guild.nsfwGateEnabled.

Repository

Part of Pearcord.

  • Org: pearcord
  • Clone: git clone https://git.ssh.surf/pearcord/pearcord-platform.git
  • Install: npm install git+https://git.ssh.surf/pearcord/pearcord-platform.git#main

Depends on the full Pearcord module workspace (see package.json); install from monorepo modules/ for local development.

S
Description
Pearcord module: pearcord-platform
Readme
105 MiB
Languages
JavaScript 100%