feat(guild): owner deleteGuild with mesh gossip and local purge

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-01 15:22:51 -04:00
co-authored by Cursor
parent 3e36cee5c0
commit d3803b68f3
2 changed files with 252 additions and 1 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ Expose a single `EventEmitter` API the UI drives over IPC (`apps/pearcord/index.
| `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 |
| `listGuilds()`, `loadGuild(guildId)`, `createGuild({ name, publicListing? })`, `deleteGuild({ confirmName? })`, `joinInvite(code)` | Guild session; owner-only `deleteGuild` gossips `GUILD_DELETE`, revokes discovery, purges local data ([GUILD_DELETE.md](../../docs/GUILD_DELETE.md)) |
**v0.8.431 (Phase 468):** `dm.open` span.end includes `group`, `peerUserId`, `participantCount`; `discovery.list` span.end includes `filterTag`; `contacts.accept` span.end includes `peerUserId`; `dm.open`/`contacts.accept`/`discovery.list` error logs use `error:` key. Bundle: `npm run test:phase468-discovery-dm`. See [DISCOVERY.md](../../docs/DISCOVERY.md), [CONTACTS.md](../../docs/CONTACTS.md).
+251
View File
@@ -385,6 +385,7 @@ class PearcordPlatform extends EventEmitter {
this._guildLoading = false
this._guildLoadingGuildId = null
this._guildLoadGeneration = 0
this._guildDeleteInProgress = null
this._guildOpenNoViewableChannels = false
this._emojiAttachmentMissUntil = new Map()
this._stickerAttachmentMissUntil = new Map()
@@ -3500,6 +3501,14 @@ class PearcordPlatform extends EventEmitter {
this._fanoutBotEvent(BOT_EVENTS.GUILD_UPDATE, { guild: g })
this.emit('guild-update', g)
})
guildInstance.on('guild-delete', (payload) => {
this._ingestGuildDeleteFromNetwork(payload).catch((err) => {
this.log.warn('guild.delete ingest failed', {
guildId: payload?.guildId,
err: err?.message || String(err)
})
})
})
guildInstance.on('kick', (p) => this._onKickGossip(p))
guildInstance.on('timeout', (p) => {
this.moderation?.ingestGossip(p).catch(() => {})
@@ -5715,6 +5724,244 @@ class PearcordPlatform extends EventEmitter {
}
}
async _purgeGuildLocalData (guildId) {
const gid = String(guildId || '')
if (!gid) return
const channels = await this.db.find(COLLECTIONS.CHANNELS, { guildId: gid })
const channelIds = new Set(channels.map((c) => c.id))
const messages = await this.db.find(COLLECTIONS.MESSAGES, { guildId: gid })
for (const m of messages) {
await this.db.delete(COLLECTIONS.MESSAGES, { channelId: m.channelId, id: m.id })
}
for (const channelId of channelIds) {
const reactions = await this.db.find(COLLECTIONS.REACTIONS, { channelId })
for (const r of reactions) {
await this.db.delete(COLLECTIONS.REACTIONS, {
channelId: r.channelId,
messageId: r.messageId,
emoji: r.emoji,
userId: r.userId
})
}
const pins = await this.db.find(COLLECTIONS.PINS, { channelId })
for (const p of pins) {
await this.db.delete(COLLECTIONS.PINS, {
channelId: p.channelId,
messageId: p.messageId
})
}
const rateLimits = await this.db.find(COLLECTIONS.RATE_LIMITS, { channelId })
for (const r of rateLimits) {
await this.db.delete(COLLECTIONS.RATE_LIMITS, {
userId: r.userId,
channelId: r.channelId
})
}
}
for (const ch of channels) {
await this.db.delete(COLLECTIONS.CHANNELS, { guildId: gid, id: ch.id })
await this.db
.delete(COLLECTIONS.THREAD_META, { guildId: gid, channelId: ch.id })
.catch(() => {})
await this.db
.delete(COLLECTIONS.CHANNEL_SETTINGS, { guildId: gid, channelId: ch.id })
.catch(() => {})
}
const memberRows = await this.db.find(COLLECTIONS.MEMBERS, { guildId: gid })
for (const row of memberRows) {
await this.db.delete(COLLECTIONS.MEMBERS, { guildId: gid, userId: row.userId })
}
const banRows = await this.db.find(COLLECTIONS.BANS, { guildId: gid })
for (const row of banRows) {
await this.db.delete(COLLECTIONS.BANS, { guildId: gid, userId: row.userId })
}
const slashRows = await this.db.find(COLLECTIONS.SLASH_COMMANDS, { guildId: gid })
for (const row of slashRows) {
await this.db.delete(COLLECTIONS.SLASH_COMMANDS, { guildId: gid, name: row.name })
}
const readRows = await this.db.find(COLLECTIONS.READ_STATE, { guildId: gid })
for (const row of readRows) {
await this.db.delete(COLLECTIONS.READ_STATE, {
userId: row.userId,
guildId: gid,
channelId: row.channelId
})
}
const mentionRows = await this.db.find(COLLECTIONS.MENTION_ALERTS, { guildId: gid })
for (const row of mentionRows) {
await this.db.delete(COLLECTIONS.MENTION_ALERTS, {
userId: row.userId,
guildId: gid,
channelId: row.channelId
})
}
const invites = await this.db.find(COLLECTIONS.INVITES, { guildId: gid })
for (const inv of invites) {
await this.db.delete(COLLECTIONS.INVITES, { code: inv.code })
}
const bots = await this.db.find(COLLECTIONS.GUILD_BOTS, { guildId: gid })
for (const b of bots) {
await this.db.delete(COLLECTIONS.GUILD_BOTS, { id: b.id })
}
const audit = await this.db.find(COLLECTIONS.AUDIT_LOG, { guildId: gid })
for (const a of audit) {
await this.db.delete(COLLECTIONS.AUDIT_LOG, { id: a.id })
}
await this.db.delete(COLLECTIONS.GUILDS, { id: gid })
const rolesStore = new PearcordGuildRoles({ storagePath: this.storagePath, guildId: gid })
await rolesStore.ready()
await rolesStore.purgeGuild(gid).catch(() => {})
const emojiStore = this.emojiRegistry || new (require('pearcord-emoji').PearcordEmojiRegistry)({
storagePath: this.storagePath,
guildId: gid
})
if (!this.emojiRegistry) await emojiStore.ready()
await emojiStore.purgeGuild(gid).catch(() => {})
const stickerStore =
this.stickerRegistry ||
new (require('pearcord-stickers').PearcordStickerRegistry)({
storagePath: this.storagePath,
guildId: gid
})
if (!this.stickerRegistry) await stickerStore.ready()
await stickerStore.purgeGuild(gid).catch(() => {})
if (this.automationExport) {
await this._initAutomationExport(gid)
const hooks = await this.automationExport.listHooks().catch(() => [])
for (const h of hooks) {
await this.automationExport.deleteHook(h.id).catch(() => {})
}
}
this.clearGuildSearchIndex(gid)
await this.notifications?.clearForGuild(gid).catch(() => {})
this._guildPresenceSummary.delete(gid)
}
async _cleanupUserPrefsAfterGuildDelete (guildId) {
const gid = String(guildId || '')
if (!gid || !this.userSettings) return
const prefs = await this.userSettings.getPrefs().catch(() => ({}))
const lastChannelByGuild = { ...(prefs.lastChannelByGuild || {}) }
delete lastChannelByGuild[gid]
const searchRecentByGuild = { ...(prefs.searchRecentByGuild || {}) }
delete searchRecentByGuild[gid]
const mutedGuildIds = (prefs.mutedGuildIds || []).filter((id) => id !== gid)
let serverFolders = prefs.serverFolders
if (Array.isArray(serverFolders)) {
serverFolders = serverFolders.map((folder) => {
if (!folder || typeof folder !== 'object') return folder
const guildIds = (folder.guildIds || []).filter((id) => id !== gid)
return { ...folder, guildIds }
})
}
const patch = {
lastChannelByGuild,
searchRecentByGuild,
mutedGuildIds
}
if (serverFolders) patch.serverFolders = serverFolders
if (prefs.companionLastGuildId === gid) patch.companionLastGuildId = null
await this.userSettings.setPrefs(patch, { gossip: true }).catch(() => {})
}
async _navigateAfterGuildRemoved (guildId) {
const wasActive = this.guild?.guild?.id === guildId
if (!wasActive) return
await this._leaveMeshes()
this.guild = null
this.messages = null
this.activeChannelId = null
this.voice = null
this._invalidateSearchCache()
const remaining = await this.listGuilds()
this.guilds = remaining
if (remaining.length) {
await this.loadGuild(remaining[0].id)
} else {
this.mode = 'home'
this.emit('home')
}
}
async _ingestGuildDeleteFromNetwork (payload) {
const guildId = payload?.guildId || payload?.id
if (!guildId) return null
const exists = await this.db.get(COLLECTIONS.GUILDS, { id: guildId }).catch(() => null)
if (!exists) return null
if (this._guildDeleteInProgress === guildId) return payload
await this._purgeGuildLocalData(guildId)
await this._cleanupUserPrefsAfterGuildDelete(guildId)
await this.discovery?.removePublicListing(guildId).catch(() => {})
await this.discovery?.removeGuildDirectory(guildId).catch(() => {})
this.guilds = await this.listGuilds()
await this._navigateAfterGuildRemoved(guildId)
this.emit('guild-deleted', payload)
return payload
}
async deleteGuild (opts = {}) {
const user = this.identity.user
if (!user) throw new Error('register first')
if (!this.guild?.guild) throw new Error('no guild')
const guild = this.guild.guild
if (guild.ownerId !== user.id) {
throw new Error('only the server owner can delete this server')
}
const guildId = guild.id
const confirmName = String(opts.confirmName || '').trim()
if (confirmName && confirmName !== guild.name) {
throw new Error('server name does not match')
}
const span = this.log.time('guild.delete', {
guildId,
spanKind: 'guild.delete'
})
try {
this._guildDeleteInProgress = guildId
const payload = {
guildId,
deletedAt: Date.now(),
deletedBy: user.id,
name: guild.name
}
if (this.guild?.peers?.size) {
this.guild.gossipGuildDelete(payload)
}
await this.discovery?.revokeGuildListing(guildId).catch(() => {})
await this._leaveMeshes()
await this._purgeGuildLocalData(guildId)
await this._cleanupUserPrefsAfterGuildDelete(guildId)
this.guild = null
this.messages = null
this.activeChannelId = null
this._invalidateSearchCache()
this.guilds = await this.listGuilds()
const remaining = this.guilds
if (remaining.length) {
await this.loadGuild(remaining[0].id)
} else {
this.mode = 'home'
this.emit('home')
}
this.emit('guild-deleted', payload)
span.end({
guildId,
deletedAt: payload.deletedAt,
remainingGuildCount: remaining.length,
activeChannelId: this.activeChannelId,
guildCount: remaining.length,
spanKind: 'guild.delete'
})
return payload
} catch (err) {
this.log.error('guild.delete error', { guildId, err: err?.message || String(err) })
span.fail(err)
throw err
} finally {
if (this._guildDeleteInProgress === guildId) this._guildDeleteInProgress = null
}
}
async stageAttachment ({ data, filename, mimeType }) {
const guildId = this.mode === 'dm' ? DM_GUILD_ID : this.guild?.guild?.id || null
const channelId = this.activeChannelId
@@ -14619,6 +14866,10 @@ class PearcordPlatform extends EventEmitter {
activityFeed,
stats,
dbEngine: this.db.getEngine?.() || 'json',
isGuildOwner:
this.mode === 'guild' &&
!!this.guild?.guild?.ownerId &&
this.guild.guild.ownerId === this.identity?.user?.id,
myPermissions,
parentChannelPermissions,
canPostInActiveChannel,