feat: add persistent cron-based recurring reminders

Replace in-memory-only setTimeout scheduling with a DB-backed system
that survives broker restarts. Adds:

- `scheduled_message` table in mesh schema (Drizzle + raw CREATE TABLE
  for zero-downtime deploys)
- Minimal 5-field cron parser (no dependencies) with next-fire-time
  calculation for recurring entries
- On broker boot, all non-cancelled entries are loaded from PostgreSQL
  and timers re-armed automatically
- CLI `schedule_reminder` MCP tool accepts optional `cron` expression
- CLI `remind` command accepts `--cron` flag
- One-shot reminders remain backward compatible — no cron field = same
  behavior as before

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 23:33:47 +01:00
parent 58ba01f20f
commit e87380775f
6 changed files with 332 additions and 39 deletions

View File

@@ -427,6 +427,50 @@ export const meshStream = meshSchema.table(
(table) => [uniqueIndex("stream_mesh_name_idx").on(table.meshId, table.name)],
);
/**
* Persistent scheduled messages. Survives broker restarts — on boot the
* broker loads all non-cancelled, non-expired rows and re-arms timers.
* Supports both one-shot (deliverAt) and recurring (cron expression).
*/
export const scheduledMessage = meshSchema.table("scheduled_message", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
/** Nullable — the presence that created it may be gone after a restart. */
presenceId: text(),
memberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
to: text().notNull(),
message: text().notNull(),
/** Unix timestamp (ms) for one-shot delivery. Null for cron-only entries. */
deliverAt: timestamp(),
/** 5-field cron expression for recurring delivery. Null for one-shot. */
cron: text(),
subtype: text(),
firedCount: integer().notNull().default(0),
cancelled: boolean().notNull().default(false),
firedAt: timestamp(),
createdAt: timestamp().defaultNow().notNull(),
});
export const scheduledMessageRelations = relations(scheduledMessage, ({ one }) => ({
mesh: one(mesh, {
fields: [scheduledMessage.meshId],
references: [mesh.id],
}),
member: one(meshMember, {
fields: [scheduledMessage.memberId],
references: [meshMember.id],
}),
}));
export const selectScheduledMessageSchema = createSelectSchema(scheduledMessage);
export const insertScheduledMessageSchema = createInsertSchema(scheduledMessage);
export type SelectScheduledMessage = typeof scheduledMessage.$inferSelect;
export type InsertScheduledMessage = typeof scheduledMessage.$inferInsert;
export const meshRelations = relations(mesh, ({ one, many }) => ({
owner: one(user, {
fields: [mesh.ownerUserId],