From 063edb51e8e596e804901e2f2c6e165267b56a8c Mon Sep 17 00:00:00 2001 From: Pearcord Date: Sun, 12 Jul 2026 23:41:27 -0400 Subject: [PATCH] chore: publish pearcord-scheduled-messages from pearcord workspace --- README.md | 31 ++++++++++ index.js | 143 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 62 ++++++++++++++++++++ package.json | 12 ++++ 4 files changed, 248 insertions(+) create mode 100644 README.md create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..c02e4ff --- /dev/null +++ b/README.md @@ -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). diff --git a/index.js b/index.js new file mode 100644 index 0000000..b765d0b --- /dev/null +++ b/index.js @@ -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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cea6c9b --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..083d180 --- /dev/null +++ b/package.json @@ -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" + } +}