Raven ScottandCursor 23c10599d7 Phase 433: DM and contacts error logging for diagnostics.
Log dm.open, dm.group, and contacts request/accept/decline failures
so the dm-errors dev-log filter surfaces actionable inbox issues.

Co-authored-by: Cursor <[email protected]>
2026-05-23 17:53:35 -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.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%