chore: publish pearcord-scheduled-messages from pearcord workspace

This commit is contained in:
Pearcord
2026-07-12 23:41:27 -04:00
commit 063edb51e8
4 changed files with 248 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# pearcord-scheduled-messages
Guild + DM scheduled message queue persistence for Pearcord.
## Overview
Phase 674 module wrapping HyperDB `@pearcord/scheduled-messages` with JsonStore fallback. Metadata is gossiped on the guild mesh; message body is sent only at fire time.
## Exports
| Symbol | Purpose |
|--------|---------|
| `ScheduledMessageStore` | Queue CRUD, capacity checks, list by guild/channel |
## Policy
Uses `pearcord-shared/dm-scheduled-policy` for recurrence computation and queue caps (per channel / per guild).
## Platform integration
- `pearcord-platform/polls-scheduling.js``scheduleMessage`, `updateScheduledMessage`, `cancelScheduledMessage`, `sendNowScheduledMessage`, due runner, gossip + heal
- View fields: `scheduledQueue`, `scheduledQueueCount`
## Testing
```bash
npm run test:phase674-polls-scheduling
PEARCORD_SKIP_MESH_ROUNDTRIP=1 npm run test:agentctl-phase674-polls-scheduling
```
See [SCHEDULED_MESSAGES.md](../../docs/SCHEDULED_MESSAGES.md).
+143
View File
@@ -0,0 +1,143 @@
'use strict'
const path = require('bare-path')
const { JsonStore } = require('pearcord-db/store-json')
const { COLLECTIONS } = require('pearcord-shared')
const {
DM_SCHEDULE_MAX_QUEUED_PER_CHANNEL,
DM_SCHEDULE_STALE_RETENTION_MS
} = require('pearcord-shared/dm-scheduled-policy')
const GUILD_SCHEDULE_MAX_QUEUED_PER_CHANNEL = DM_SCHEDULE_MAX_QUEUED_PER_CHANNEL
const GUILD_SCHEDULE_MAX_QUEUED_PER_GUILD = 100
class ScheduledMessageStore {
constructor (opts = {}) {
this.db = opts.db || null
this.storagePath = opts.storagePath || './pearcord-storage'
this.collection = COLLECTIONS.SCHEDULED_MESSAGES
this._json = opts.jsonStore || new JsonStore(path.join(this.storagePath, 'scheduled-messages'))
this._hyperDbReady = false
}
get engine () {
return this.db?.getEngine?.() === 'hyperdb' && this._hyperDbReady ? 'hyperdb' : 'json'
}
async ready () {
await this._json.ready()
if (this.db) {
await this.db.ready()
if (this.db.getEngine?.() === 'hyperdb') {
try {
await this.db.get(this.collection, { guildId: '__probe__', id: '__probe__' })
this._hyperDbReady = true
} catch (err) {
if (/Unknown index|Unknown collection/.test(err.message)) this._hyperDbReady = false
else this._hyperDbReady = true
}
}
}
return this
}
_disableHyperDb (err) {
if (err && /Unknown index|Unknown collection/.test(err.message)) {
this._hyperDbReady = false
return true
}
return false
}
async upsert (row) {
if (!row?.id || !row?.guildId) throw new Error('id and guildId required')
const merged = { ...row, updatedAt: Date.now() }
if (this.engine === 'hyperdb') {
try {
await this.db.insert(this.collection, merged)
return merged
} catch (err) {
if (!this._disableHyperDb(err)) throw err
}
}
await this._json.insert('@pearcord/scheduled-messages-json', merged)
return merged
}
async get (guildId, id) {
if (!guildId || !id) return null
if (this.engine === 'hyperdb') {
try {
return await this.db.get(this.collection, { guildId, id })
} catch (err) {
if (this._disableHyperDb(err)) {
return this._json.get('@pearcord/scheduled-messages-json', { guildId, id })
}
if (/not found/i.test(err.message)) return null
throw err
}
}
return this._json.get('@pearcord/scheduled-messages-json', { guildId, id })
}
async listByGuild (guildId, { channelId = null, status = 'queued', userId = null } = {}) {
if (!guildId) return []
let rows = []
if (this.engine === 'hyperdb') {
try {
rows = await this.db.find(this.collection, { guildId })
} catch (err) {
if (this._disableHyperDb(err)) rows = []
else throw err
}
} else {
const raw = await this._json.find('@pearcord/scheduled-messages-json', { guildId })
rows = Array.isArray(raw) ? raw : raw ? [raw] : []
}
return rows
.filter((r) => !channelId || r.channelId === channelId)
.filter((r) => !status || r.status === status)
.filter((r) => !userId || r.userId === userId)
.sort((a, b) => (a.sendAt || 0) - (b.sendAt || 0))
}
async countQueued ({ guildId, channelId = null }) {
const rows = await this.listByGuild(guildId, { channelId, status: 'queued' })
return rows.length
}
async assertQueueCapacity ({ guildId, channelId }) {
const channelCount = await this.countQueued({ guildId, channelId })
if (channelCount >= GUILD_SCHEDULE_MAX_QUEUED_PER_CHANNEL) {
throw new Error(`scheduled message queue full (${GUILD_SCHEDULE_MAX_QUEUED_PER_CHANNEL} per channel)`)
}
const guildCount = await this.countQueued({ guildId })
if (guildCount >= GUILD_SCHEDULE_MAX_QUEUED_PER_GUILD) {
throw new Error(`scheduled message queue full (${GUILD_SCHEDULE_MAX_QUEUED_PER_GUILD} per guild)`)
}
}
async pruneStale (guildId) {
const cutoff = Date.now() - DM_SCHEDULE_STALE_RETENTION_MS
const rows = await this.listByGuild(guildId, { status: null })
let pruned = 0
for (const row of rows) {
if (row.status === 'queued') continue
const ts = row.sentAt || row.updatedAt || row.createdAt || 0
if (ts >= cutoff) continue
if (this.engine === 'hyperdb') {
await this.db.delete(this.collection, { guildId: row.guildId, id: row.id }).catch(() => {})
} else {
await this._json.delete('@pearcord/scheduled-messages-json', { guildId: row.guildId, id: row.id })
}
pruned++
}
return { pruned }
}
}
module.exports = {
ScheduledMessageStore,
GUILD_SCHEDULE_MAX_QUEUED_PER_CHANNEL,
GUILD_SCHEDULE_MAX_QUEUED_PER_GUILD
}
+62
View File
@@ -0,0 +1,62 @@
{
"name": "pearcord-scheduled-messages",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pearcord-scheduled-messages",
"version": "0.1.0",
"dependencies": {
"bare-path": "^3.0.0",
"pearcord-db": "file:../pearcord-db",
"pearcord-shared": "file:../pearcord-shared"
}
},
"../pearcord-db": {
"version": "0.1.0",
"dependencies": {
"bare-fs": "^4.0.0",
"bare-path": "^3.0.0"
},
"optionalDependencies": {
"hyperdb": "^5.0.0",
"hyperschema": "^1.0.0"
}
},
"../pearcord-shared": {
"version": "0.1.0",
"dependencies": {
"b4a": "^1.6.7",
"compact-encoding": "^2.0.0",
"hypercore-crypto": "^3.0.0"
}
},
"node_modules/bare-os": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz",
"integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==",
"license": "Apache-2.0",
"engines": {
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz",
"integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==",
"license": "Apache-2.0",
"dependencies": {
"bare-os": "^3.0.1"
}
},
"node_modules/pearcord-db": {
"resolved": "../pearcord-db",
"link": true
},
"node_modules/pearcord-shared": {
"resolved": "../pearcord-shared",
"link": true
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "pearcord-scheduled-messages",
"version": "0.1.0",
"main": "index.js",
"type": "commonjs",
"description": "Guild + DM scheduled message queue for Pearcord",
"dependencies": {
"bare-path": "^3.0.0",
"pearcord-db": "git+https://git.ssh.surf/pearcord/pearcord-db.git#main",
"pearcord-shared": "git+https://git.ssh.surf/pearcord/pearcord-shared.git#main"
}
}