morpho-socket 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +7 -2
- package/packages/ask/README.md +99 -0
- package/packages/ask/index.js +183 -0
- package/packages/ask/package.json +1 -0
- package/packages/capture/README.md +49 -0
- package/packages/capture/index.js +327 -0
- package/packages/capture/package.json +1 -0
- package/packages/links/README.md +85 -0
- package/packages/links/index.js +191 -0
- package/packages/links/package.json +1 -0
- package/packages/pending/README.md +47 -0
- package/packages/pending/index.js +117 -0
- package/packages/pending/package.json +1 -0
- package/packages/scheduler/README.md +79 -0
- package/packages/scheduler/index.js +251 -0
- package/packages/scheduler/package.json +1 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @morpho/pending
|
|
2
|
+
//
|
|
3
|
+
// "Somebody proposes something, somebody else accepts it, or it expires": a challenge, an invite, a trade offer,
|
|
4
|
+
// a "are you sure? yes/no". Morpho doesn't know what the request MEANS — it only holds it, lets exactly one
|
|
5
|
+
// caller take it, and forgets it when its time is up.
|
|
6
|
+
//
|
|
7
|
+
// const pending = createPending({ ttlMs: 2 * 60_000 }) // in memory
|
|
8
|
+
// const pending = createPending({ store: db.collection("pending") }) // survives restarts (@morpho/kv)
|
|
9
|
+
//
|
|
10
|
+
// const key = pending.key(g.chatId, target.number) // who is expected to answer
|
|
11
|
+
// const r = pending.create(key, { from: g.user.number, kind: "duel" }) // -> { ok: true, request }
|
|
12
|
+
// // r.ok === false, r.reason === "already-pending", r.request -> there is one open already
|
|
13
|
+
//
|
|
14
|
+
// const t = pending.take(key, { match: req => req.data.kind === "duel" }) // -> { ok: true, request }
|
|
15
|
+
// // t.ok === false, t.reason === "not-found" (never existed / expired / already taken) | "mismatch" (left untouched)
|
|
16
|
+
//
|
|
17
|
+
// Expected outcomes are values, never exceptions. Wrong usage (bad key, bad ttl) throws TypeError.
|
|
18
|
+
// `store` is duck-typed and must be SYNCHRONOUS: get(key, fallback), set(key, value, { ttl }), delete(key), entries(prefix).
|
|
19
|
+
// A @morpho/kv collection fits. Expiry is also checked here (request.expiresAt), so it never depends on the store's clock.
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TTL_MS = 2 * 60_000
|
|
22
|
+
|
|
23
|
+
function memoryStore(now) {
|
|
24
|
+
const m = new Map()
|
|
25
|
+
const alive = e => e && (e.exp == null || e.exp > now())
|
|
26
|
+
return {
|
|
27
|
+
get(k, fallback) { const e = m.get(k); if (alive(e)) return e.v; m.delete(k); return fallback },
|
|
28
|
+
set(k, v, { ttl } = {}) { m.set(k, { v, exp: ttl == null ? null : now() + ttl }); return v },
|
|
29
|
+
delete(k) { return m.delete(k) },
|
|
30
|
+
entries(prefix = "") {
|
|
31
|
+
const out = []
|
|
32
|
+
for (const [k, e] of m) {
|
|
33
|
+
if (!alive(e)) { m.delete(k); continue }
|
|
34
|
+
if (k.startsWith(prefix)) out.push([k, e.v])
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const part = x => {
|
|
42
|
+
if (typeof x === "number" && Number.isFinite(x)) x = String(x)
|
|
43
|
+
if (typeof x !== "string" || !x) throw new TypeError("pending: keys and key parts must be non-empty strings")
|
|
44
|
+
return x
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createPending({ store, ttlMs = DEFAULT_TTL_MS, now = Date.now } = {}) {
|
|
48
|
+
if (!(ttlMs > 0) || !Number.isFinite(ttlMs)) throw new TypeError("pending: ttlMs must be a positive number (ms)")
|
|
49
|
+
store = store || memoryStore(now)
|
|
50
|
+
for (const fn of ["get", "set", "delete", "entries"])
|
|
51
|
+
if (typeof store[fn] !== "function") throw new TypeError(`pending: store needs a ${fn}() method (a @morpho/kv collection works)`)
|
|
52
|
+
|
|
53
|
+
function make(prefix) {
|
|
54
|
+
const full = k => prefix + part(k)
|
|
55
|
+
const view = rec => (rec ? { ...rec, msLeft: Math.max(0, rec.expiresAt - now()) } : null)
|
|
56
|
+
// the live record for a key (or null); an expired one is dropped on the way
|
|
57
|
+
const read = k => {
|
|
58
|
+
const f = full(k)
|
|
59
|
+
const rec = store.get(f)
|
|
60
|
+
if (!rec) return null
|
|
61
|
+
if (rec.expiresAt <= now()) { store.delete(f); return null }
|
|
62
|
+
return rec
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const api = {
|
|
66
|
+
// a key from parts: pending.key(chatId, number) -> "chatId|number"
|
|
67
|
+
key(...parts) { if (!parts.length) throw new TypeError("pending.key: pass at least one part"); return parts.map(part).join("|") },
|
|
68
|
+
|
|
69
|
+
// opts: { ttl: ms (default ttlMs), replace: false }
|
|
70
|
+
create(key, data, { ttl = ttlMs, replace = false } = {}) {
|
|
71
|
+
if (!(ttl > 0) || !Number.isFinite(ttl)) throw new TypeError("pending.create: ttl must be a positive number (ms)")
|
|
72
|
+
const existing = read(key)
|
|
73
|
+
if (existing && !replace) return { ok: false, reason: "already-pending", request: view(existing) }
|
|
74
|
+
const t = now()
|
|
75
|
+
const rec = { key: part(key), data: data === undefined ? null : data, createdAt: t, expiresAt: t + ttl }
|
|
76
|
+
store.set(full(key), rec, { ttl })
|
|
77
|
+
return { ok: true, request: view(rec) }
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// look without consuming
|
|
81
|
+
peek(key) { return view(read(key)) },
|
|
82
|
+
has(key) { return !!read(key) },
|
|
83
|
+
|
|
84
|
+
// consume it. opts.match(request) -> false leaves it in place and answers "mismatch"
|
|
85
|
+
take(key, { match } = {}) {
|
|
86
|
+
const rec = read(key)
|
|
87
|
+
if (!rec) return { ok: false, reason: "not-found" }
|
|
88
|
+
const v = view(rec)
|
|
89
|
+
if (match && !match(v)) return { ok: false, reason: "mismatch", request: v }
|
|
90
|
+
store.delete(full(key))
|
|
91
|
+
return { ok: true, request: v }
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
cancel(key) { const had = !!read(key); if (had) store.delete(full(key)); return had },
|
|
95
|
+
|
|
96
|
+
// every open request under a key prefix, oldest first
|
|
97
|
+
list(keyPrefix = "") {
|
|
98
|
+
return store.entries(prefix + keyPrefix)
|
|
99
|
+
.map(([, rec]) => rec)
|
|
100
|
+
.filter(rec => rec && rec.expiresAt > now())
|
|
101
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
102
|
+
.map(view)
|
|
103
|
+
},
|
|
104
|
+
clear(keyPrefix = "") { let n = 0; for (const r of api.list(keyPrefix)) if (store.delete(full(r.key))) n++; return n },
|
|
105
|
+
|
|
106
|
+
// a narrower view: pending.sub(clientId, chatId) -> keys are isolated from everyone else's
|
|
107
|
+
sub(...parts) {
|
|
108
|
+
if (!parts.length) throw new TypeError("pending.sub: pass at least one part")
|
|
109
|
+
return make(prefix + parts.map(p => { const s = part(p); if (s.includes("/")) throw new TypeError(`pending.sub: "${s}" can't contain "/"`); return s }).join("/") + "/")
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
return api
|
|
113
|
+
}
|
|
114
|
+
return make("")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { createPending, DEFAULT_TTL_MS }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/pending", "version": "0.1.0", "description": "Requests that wait for an answer and expire by themselves: invites, challenges, trades, confirmations. In memory or on top of @morpho/kv.", "main": "index.js", "license": "MIT" }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# morpho-socket/scheduler
|
|
2
|
+
|
|
3
|
+
"Do this later" and "do this every day at 18:30" — and still do it after a restart. Morpho doesn't know what a job *means* (a reminder, a group opening, a buff expiring, a weekly ranking). It stores the job in [`kv`](../kv), wakes up at the right time and hands it to the handler you registered for its `type`.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
const { openKV } = require("morpho-socket/kv")
|
|
7
|
+
const { createScheduler } = require("morpho-socket/scheduler")
|
|
8
|
+
|
|
9
|
+
const db = openKV("data/bot.sqlite")
|
|
10
|
+
const sched = createScheduler({
|
|
11
|
+
db,
|
|
12
|
+
tz: "America/Sao_Paulo", // default zone for daily()
|
|
13
|
+
onError: (job, err) => console.error(job.type, err),
|
|
14
|
+
onMissed: (job, { delayMs }) => console.warn("missed", job.type, delayMs),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
sched.on("reminder", async (job, ctx) => {
|
|
18
|
+
const sock = sockets.get(ctx.scope[0]) // scope = [clientId, chatId] (see sub below)
|
|
19
|
+
await sock.sendMessage(job.payload.to, { text: ctx.late ? `(late) ${job.payload.text}` : job.payload.text })
|
|
20
|
+
})
|
|
21
|
+
sched.start()
|
|
22
|
+
|
|
23
|
+
sched.after(30 * 60_000, "reminder", { to, text }) // once, in 30 minutes
|
|
24
|
+
sched.at(new Date("2026-12-25T09:00:00-03:00"), "reminder", { to, text })
|
|
25
|
+
sched.every(3600e3, "sweep", null) // every hour
|
|
26
|
+
sched.daily("18:30", "close-group", { jid }, { days: [1, 2, 3, 4, 5] }) // Mon–Fri, 0 = Sunday
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Scopes
|
|
30
|
+
|
|
31
|
+
`sub(...)` gives a view over one client or group. Jobs created through it remember their scope, and the handler receives it in `ctx.scope`:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const room = sched.sub(clientId, chatId)
|
|
35
|
+
room.after(5000, "ping", null, { owner: g.user.number, max: 10 }) // { ok: false, reason: "limit-reached" } past 10 per owner
|
|
36
|
+
room.list({ owner: g.user.number }) // "my reminders"
|
|
37
|
+
room.cancel(id)
|
|
38
|
+
room.clear() // group removed: forget all its jobs
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The root sees every scope (handy for a dashboard); a `sub` sees its own and deeper ones. The same `id` can exist in different scopes.
|
|
42
|
+
|
|
43
|
+
## Guarantees
|
|
44
|
+
|
|
45
|
+
- **Claim before run.** A job is rescheduled (or deleted) *before* its handler runs, so a crash never runs it twice. One-shots are at-most-once unless you ask for `attempts: 3` (retried after `retryMs`, default 30 s). Two processes on the same database never run the same job twice.
|
|
46
|
+
- **Downtime.** A job later than its `lateMs` (default 10 min) is **not** run; `onMissed` hears about it. Pass `lateMs: Infinity` per job for "run it however late" (a reminder); `ctx.late` and `ctx.delayMs` tell the handler how late it is. Recurring jobs never replay a backlog: after downtime they skip to the next slot on their original rhythm.
|
|
47
|
+
- **A broken handler** goes to `onError`; it never stops the other jobs or the loop. A recurring job simply carries on.
|
|
48
|
+
- **No handler yet?** The job is left untouched, so you can `on()` after `start()`.
|
|
49
|
+
- **Time zones** come from `Intl` (no dependency) and follow daylight saving.
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
| | |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `createScheduler({ db, name = "scheduler", tickMs = 15 s, lateMs = 10 min, retryMs = 30 s, tz = "UTC", now?, onError?, onMissed?, log? })` | |
|
|
56
|
+
| `on(type, fn)` | `fn(job, { late, delayMs, attempt, scope })`; returns a function that removes it |
|
|
57
|
+
| `start()` · `stop()` · `running` | timer (unref'd); `start()` also runs one tick at once |
|
|
58
|
+
| `tick()` | runs what is due right now; returns how many handlers ran (use it in tests) |
|
|
59
|
+
| `at(when, type, payload?, opts?)` | `Date`, epoch ms or ISO string |
|
|
60
|
+
| `after(ms, type, payload?, opts?)` | |
|
|
61
|
+
| `every(ms, type, payload?, opts?)` | `ms >= 1000`; `opts.startAt` for the first run |
|
|
62
|
+
| `daily("HH:MM", type, payload?, opts?)` | `opts.tz`, `opts.days` |
|
|
63
|
+
| `get(id)` · `cancel(id)` | |
|
|
64
|
+
| `list(filter?)` · `count(filter?)` · `clear(filter?)` | `filter`: `{ type, owner }`; `list` is soonest first |
|
|
65
|
+
| `sub(...parts)` | scoped view |
|
|
66
|
+
|
|
67
|
+
`opts` for every creator: `id` · `owner` · `max` (per owner, needs `owner`) · `replace` · `allowPast` · `attempts` · `retryMs` · `lateMs`.
|
|
68
|
+
|
|
69
|
+
Creators return `{ ok: true, job }` or `{ ok: false, reason }` with `"exists"` (+ `job`), `"in-the-past"` or `"limit-reached"`. A repeated `id` is refused unless `replace: true`, which makes ids good de-duplication keys (`"birthday:5511999"`).
|
|
70
|
+
Wrong usage (bad time, type name or time zone, a payload that isn't JSON) throws `TypeError`.
|
|
71
|
+
|
|
72
|
+
A `job` is `{ id, type, payload, at, every, owner, scope, createdAt, attempts, tries }`.
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
- Parsing human input ("30min", "tomorrow 9am") is **yours**: it depends on the language. Turn it into milliseconds or a `Date` and call `after`/`at`.
|
|
77
|
+
- Each tick reads the scheduler's jobs from SQLite; thousands of jobs are fine. For millions you would want a different design.
|
|
78
|
+
- `payload` is stored as JSON: keep ids and data in it, not sockets or functions. Look the socket up in the handler.
|
|
79
|
+
- Delivery is "about `tickMs` after the due time" (15 s by default). Lower `tickMs` if you need more precision.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// @morpho/scheduler
|
|
2
|
+
//
|
|
3
|
+
// "Do this later" / "do this every day at 18:30", and still do it after a restart. Morpho doesn't know what a job
|
|
4
|
+
// MEANS (a reminder, a group opening, a buff expiring, a weekly ranking...) — it stores it, wakes up at the right
|
|
5
|
+
// time, and hands it to the handler you registered for its `type`.
|
|
6
|
+
//
|
|
7
|
+
// const sched = createScheduler({ db, tz: "America/Sao_Paulo" }) // db from @morpho/kv
|
|
8
|
+
// sched.on("reminder", async (job, ctx) => { await send(job.payload.to, job.payload.text) })
|
|
9
|
+
// sched.start()
|
|
10
|
+
//
|
|
11
|
+
// sched.after(30 * 60_000, "reminder", { to, text }) // once, in 30 minutes
|
|
12
|
+
// sched.at(new Date("2026-12-25T09:00:00-03:00"), "reminder", { to, text })
|
|
13
|
+
// sched.every(3600e3, "sweep", {}) // every hour
|
|
14
|
+
// sched.daily("18:30", "close-group", { jid }, { days: [1, 2, 3, 4, 5] }) // Mon–Fri 18:30 in `tz`
|
|
15
|
+
//
|
|
16
|
+
// const room = sched.sub(clientId, chatId) // jobs of one client/group; job.scope tells the handler where it came from
|
|
17
|
+
// room.after(5000, "ping", null, { owner: g.user.number, max: 10 }) // { ok: false, reason: "limit-reached" } past 10
|
|
18
|
+
// room.clear() // group removed: forget its jobs
|
|
19
|
+
//
|
|
20
|
+
// Guarantees
|
|
21
|
+
// - A job is claimed (rescheduled or deleted) BEFORE its handler runs: a crash never runs it twice.
|
|
22
|
+
// One-shots are at-most-once unless you ask for `attempts`.
|
|
23
|
+
// - Downtime: a job later than its `lateMs` (default 10 min) is NOT run; `onMissed` hears about it. Use lateMs: Infinity
|
|
24
|
+
// for "always run it, however late" (ctx.late tells the handler). Recurring jobs never "catch up": they skip to the next slot.
|
|
25
|
+
// - Expected outcomes are values: { ok: false, reason: "exists" | "in-the-past" | "limit-reached" }.
|
|
26
|
+
// Wrong usage (bad time, bad type name, non-JSON payload) throws TypeError.
|
|
27
|
+
//
|
|
28
|
+
// Handler: (job, { late, delayMs, attempt, scope }) — a throw goes to onError (and retries a one-shot if attempts > 1).
|
|
29
|
+
// Jobs whose type has no handler yet are left untouched, so handlers can be registered after start().
|
|
30
|
+
|
|
31
|
+
const crypto = require("node:crypto")
|
|
32
|
+
|
|
33
|
+
const TYPE = /^[A-Za-z0-9_.:-]+$/
|
|
34
|
+
const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/
|
|
35
|
+
const DEFAULTS = { tickMs: 15_000, lateMs: 10 * 60_000, retryMs: 30_000, tz: "UTC" }
|
|
36
|
+
|
|
37
|
+
// ---------- time zones (no dependency: Intl) ----------
|
|
38
|
+
const dtfs = new Map()
|
|
39
|
+
function dtf(tz) {
|
|
40
|
+
let f = dtfs.get(tz)
|
|
41
|
+
if (!f) {
|
|
42
|
+
try { f = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }) }
|
|
43
|
+
catch { throw new TypeError(`scheduler: unknown time zone "${tz}"`) }
|
|
44
|
+
dtfs.set(tz, f)
|
|
45
|
+
}
|
|
46
|
+
return f
|
|
47
|
+
}
|
|
48
|
+
const parts = (tz, ms) => Object.fromEntries(dtf(tz).formatToParts(new Date(ms)).map(x => [x.type, x.value]))
|
|
49
|
+
function offsetMs(tz, ms) { // local wall clock minus UTC, at that instant
|
|
50
|
+
const p = parts(tz, ms)
|
|
51
|
+
return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute, +p.second) - Math.floor(ms / 1000) * 1000
|
|
52
|
+
}
|
|
53
|
+
function zoned(tz, y, m, d, h, mi) { // wall clock in tz -> epoch ms (m is 0-based)
|
|
54
|
+
const naive = Date.UTC(y, m, d, h, mi)
|
|
55
|
+
let t = naive - offsetMs(tz, naive)
|
|
56
|
+
t = naive - offsetMs(tz, t) // second pass settles DST edges
|
|
57
|
+
return t
|
|
58
|
+
}
|
|
59
|
+
function nextDaily(spec, after) {
|
|
60
|
+
const p = parts(spec.tz, after)
|
|
61
|
+
const [h, mi] = spec.time.split(":").map(Number)
|
|
62
|
+
for (let off = 0; off <= 14; off++) {
|
|
63
|
+
const day = new Date(Date.UTC(+p.year, +p.month - 1, +p.day + off))
|
|
64
|
+
if (spec.days && !spec.days.includes(day.getUTCDay())) continue
|
|
65
|
+
const cand = zoned(spec.tz, day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), h, mi)
|
|
66
|
+
if (cand > after) return cand
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
// first slot of a recurring spec strictly after `t`, staying on the original rhythm
|
|
71
|
+
function nextAfter(spec, t, anchor) {
|
|
72
|
+
if (spec.kind === "interval") return anchor + (Math.floor((t - anchor) / spec.ms) + 1) * spec.ms
|
|
73
|
+
return nextDaily(spec, t)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- helpers ----------
|
|
77
|
+
const typeName = t => { if (typeof t !== "string" || !TYPE.test(t)) throw new TypeError("scheduler: type must match [A-Za-z0-9_.:-]+"); return t }
|
|
78
|
+
const part = x => {
|
|
79
|
+
if (typeof x === "number" && Number.isFinite(x)) x = String(x)
|
|
80
|
+
if (typeof x !== "string" || !x || x.includes("/")) throw new TypeError('scheduler: ids and scope parts must be non-empty strings without "/"')
|
|
81
|
+
return x
|
|
82
|
+
}
|
|
83
|
+
const toMs = when => {
|
|
84
|
+
const ms = when instanceof Date ? when.getTime() : typeof when === "string" ? Date.parse(when) : when
|
|
85
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) throw new TypeError("scheduler.at: pass a Date, an epoch in ms or an ISO string")
|
|
86
|
+
return ms
|
|
87
|
+
}
|
|
88
|
+
const view = r => ({ id: r.id, type: r.type, payload: r.payload, at: r.at, every: r.every ?? null, owner: r.owner ?? null, scope: [...r.scope], createdAt: r.createdAt, attempts: r.attempts, tries: r.tries })
|
|
89
|
+
|
|
90
|
+
function createScheduler({
|
|
91
|
+
db, name = "scheduler", tickMs = DEFAULTS.tickMs, lateMs = DEFAULTS.lateMs, retryMs = DEFAULTS.retryMs, tz = DEFAULTS.tz,
|
|
92
|
+
now = Date.now, onError = () => {}, onMissed = () => {}, log = () => {},
|
|
93
|
+
} = {}) {
|
|
94
|
+
if (!db || typeof db.collection !== "function" || typeof db.transaction !== "function")
|
|
95
|
+
throw new TypeError("scheduler: pass { db } from @morpho/kv (openKV(...))")
|
|
96
|
+
dtf(tz) // validates the default zone early
|
|
97
|
+
const col = db.collection(name)
|
|
98
|
+
const handlers = new Map()
|
|
99
|
+
const fullKey = r => [...r.scope, r.id].join("/")
|
|
100
|
+
let timer = null, ticking = false
|
|
101
|
+
|
|
102
|
+
// ----- claim: atomically move a due job to its next slot (or delete it) before running it -----
|
|
103
|
+
function claim(job, t) {
|
|
104
|
+
return db.transaction(() => {
|
|
105
|
+
const fk = fullKey(job)
|
|
106
|
+
const cur = col.get(fk)
|
|
107
|
+
if (!cur || cur.at !== job.at) return null // taken by another process, cancelled or rescheduled meanwhile
|
|
108
|
+
const delayMs = t - cur.at
|
|
109
|
+
const missed = delayMs > (cur.lateMs ?? lateMs)
|
|
110
|
+
if (cur.every) {
|
|
111
|
+
const next = nextAfter(cur.every, t, cur.anchor ?? cur.at)
|
|
112
|
+
if (next == null) col.delete(fk); else col.set(fk, { ...cur, at: next })
|
|
113
|
+
} else col.delete(fk)
|
|
114
|
+
return { job: cur, missed, delayMs }
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function run(job, delayMs, t) {
|
|
119
|
+
const attempt = (job.tries || 0) + 1
|
|
120
|
+
try {
|
|
121
|
+
await handlers.get(job.type)(view({ ...job, tries: attempt }), { late: delayMs > tickMs, delayMs, attempt, scope: [...job.scope] })
|
|
122
|
+
} catch (e) {
|
|
123
|
+
try { onError(view(job), e) } catch { /* a broken onError must not break the loop */ }
|
|
124
|
+
// retry only one-shots, and only if nobody re-created the same id meanwhile
|
|
125
|
+
if (!job.every && attempt < (job.attempts || 1) && !col.has(fullKey(job)))
|
|
126
|
+
col.set(fullKey(job), { ...job, at: now() + (job.retryMs ?? retryMs), tries: attempt })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// runs everything that is due right now. Called by the timer; call it yourself in tests. Returns how many handlers ran.
|
|
131
|
+
async function tick() {
|
|
132
|
+
if (ticking) return 0
|
|
133
|
+
ticking = true
|
|
134
|
+
try {
|
|
135
|
+
const t = now()
|
|
136
|
+
const due = col.entries("")
|
|
137
|
+
.map(([, j]) => j)
|
|
138
|
+
.filter(j => j && j.at <= t && handlers.has(j.type))
|
|
139
|
+
.sort((a, b) => a.at - b.at)
|
|
140
|
+
const runs = []
|
|
141
|
+
for (const job of due) {
|
|
142
|
+
const c = claim(job, t)
|
|
143
|
+
if (!c) continue
|
|
144
|
+
if (c.missed) { try { onMissed(view(c.job), { delayMs: c.delayMs }) } catch { /* ignore */ } continue }
|
|
145
|
+
runs.push(run(c.job, c.delayMs, t))
|
|
146
|
+
}
|
|
147
|
+
await Promise.all(runs)
|
|
148
|
+
return runs.length
|
|
149
|
+
} finally { ticking = false }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ----- a view over one scope (the root sees every scope; sub(...) sees its own) -----
|
|
153
|
+
function make(scope) {
|
|
154
|
+
const prefix = scope.length ? scope.join("/") + "/" : ""
|
|
155
|
+
const mine = () => col.entries(prefix).map(([, j]) => j).filter(Boolean)
|
|
156
|
+
const matches = ({ type, owner } = {}) => j => (type === undefined || j.type === type) && (owner === undefined || j.owner === owner)
|
|
157
|
+
|
|
158
|
+
function create(at, every, type, payload, o = {}) {
|
|
159
|
+
typeName(type)
|
|
160
|
+
const { id = crypto.randomBytes(8).toString("hex"), owner, max, replace = false, allowPast = false, attempts = 1, lateMs: jobLate, retryMs: jobRetry } = o
|
|
161
|
+
part(id)
|
|
162
|
+
if (payload !== undefined && JSON.stringify(payload) === undefined) throw new TypeError("scheduler: payload must be JSON-serializable")
|
|
163
|
+
if (max != null && owner == null) throw new TypeError("scheduler: `max` needs an `owner` (the limit is per owner)")
|
|
164
|
+
if (!(attempts >= 1)) throw new TypeError("scheduler: attempts must be >= 1")
|
|
165
|
+
const t = now()
|
|
166
|
+
if (at <= t && !allowPast) return { ok: false, reason: "in-the-past" }
|
|
167
|
+
return db.transaction(() => {
|
|
168
|
+
const fk = prefix + id
|
|
169
|
+
const existing = col.get(fk)
|
|
170
|
+
if (existing && !replace) return { ok: false, reason: "exists", job: view(existing) }
|
|
171
|
+
if (max != null) {
|
|
172
|
+
const n = mine().filter(j => j.owner === owner && j.scope.join("/") === scope.join("/") && fullKey(j) !== fk).length
|
|
173
|
+
if (n >= max) return { ok: false, reason: "limit-reached" }
|
|
174
|
+
}
|
|
175
|
+
const rec = {
|
|
176
|
+
id, type, payload: payload === undefined ? null : payload, at, anchor: at, every, owner: owner ?? null, scope: [...scope],
|
|
177
|
+
createdAt: t, attempts, tries: 0,
|
|
178
|
+
...(jobLate !== undefined ? { lateMs: jobLate === Infinity ? 1e18 : jobLate } : {}),
|
|
179
|
+
...(jobRetry !== undefined ? { retryMs: jobRetry } : {}),
|
|
180
|
+
}
|
|
181
|
+
col.set(fk, rec)
|
|
182
|
+
return { ok: true, job: view(rec) }
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const api = {
|
|
187
|
+
// opts for all creators: { id, owner, max, replace, allowPast, attempts, retryMs, lateMs }
|
|
188
|
+
at(when, type, payload, opts) { return create(toMs(when), null, type, payload, opts) },
|
|
189
|
+
after(ms, type, payload, opts) {
|
|
190
|
+
if (!Number.isFinite(ms) || ms < 0) throw new TypeError("scheduler.after: ms must be >= 0")
|
|
191
|
+
return create(now() + ms, null, type, payload, opts)
|
|
192
|
+
},
|
|
193
|
+
// opts.startAt: first run (default: now + ms). Keeps its rhythm after downtime and never runs a backlog.
|
|
194
|
+
every(ms, type, payload, opts = {}) {
|
|
195
|
+
if (!Number.isFinite(ms) || ms < 1000) throw new TypeError("scheduler.every: ms must be >= 1000")
|
|
196
|
+
return create(opts.startAt !== undefined ? toMs(opts.startAt) : now() + ms, { kind: "interval", ms }, type, payload, { allowPast: true, ...opts })
|
|
197
|
+
},
|
|
198
|
+
// "HH:MM" in opts.tz (default: the scheduler's). opts.days: [0..6], 0 = Sunday (default: every day)
|
|
199
|
+
daily(time, type, payload, opts = {}) {
|
|
200
|
+
if (typeof time !== "string" || !TIME.test(time)) throw new TypeError('scheduler.daily: time must be "HH:MM" (24h)')
|
|
201
|
+
const zone = opts.tz ?? tz
|
|
202
|
+
dtf(zone)
|
|
203
|
+
let days = null
|
|
204
|
+
if (opts.days !== undefined) {
|
|
205
|
+
if (!Array.isArray(opts.days) || !opts.days.length || opts.days.some(d => !Number.isInteger(d) || d < 0 || d > 6))
|
|
206
|
+
throw new TypeError("scheduler.daily: days must be a non-empty array of 0..6 (0 = Sunday)")
|
|
207
|
+
days = [...new Set(opts.days)].sort()
|
|
208
|
+
}
|
|
209
|
+
const spec = { kind: "daily", time, tz: zone, days }
|
|
210
|
+
return create(nextDaily(spec, now()), spec, type, payload, opts)
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
get(id) { const r = col.get(prefix + part(id)); return r ? view(r) : null },
|
|
214
|
+
cancel(id) { return col.delete(prefix + part(id)) },
|
|
215
|
+
// jobs of this scope (the root: all of them), soonest first. filter: { type, owner }
|
|
216
|
+
list(filter) { return mine().filter(matches(filter)).sort((a, b) => a.at - b.at).map(view) },
|
|
217
|
+
count(filter) { return mine().filter(matches(filter)).length },
|
|
218
|
+
// forget jobs (a group was removed, a person left). Returns how many.
|
|
219
|
+
clear(filter) { let n = 0; for (const j of mine().filter(matches(filter))) if (col.delete(fullKey(j))) n++; return n },
|
|
220
|
+
|
|
221
|
+
sub(...p) {
|
|
222
|
+
if (!p.length) throw new TypeError("scheduler.sub: pass at least one part")
|
|
223
|
+
return make([...scope, ...p.map(part)])
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
return api
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const root = make([])
|
|
230
|
+
return {
|
|
231
|
+
...root,
|
|
232
|
+
// handler for a job type. Returns a function that removes it.
|
|
233
|
+
on(type, fn) {
|
|
234
|
+
typeName(type)
|
|
235
|
+
if (typeof fn !== "function") throw new TypeError("scheduler.on: pass a function")
|
|
236
|
+
handlers.set(type, fn)
|
|
237
|
+
return () => { if (handlers.get(type) === fn) handlers.delete(type) }
|
|
238
|
+
},
|
|
239
|
+
tick,
|
|
240
|
+
start({ immediate = true } = {}) {
|
|
241
|
+
if (timer) return
|
|
242
|
+
timer = setInterval(() => { tick().catch(e => log("tick-error", e)) }, tickMs)
|
|
243
|
+
timer.unref?.()
|
|
244
|
+
if (immediate) tick().catch(e => log("tick-error", e))
|
|
245
|
+
},
|
|
246
|
+
stop() { if (timer) { clearInterval(timer); timer = null } },
|
|
247
|
+
get running() { return !!timer },
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { createScheduler }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/scheduler", "version": "0.1.0", "description": "Jobs that run later or on repeat and survive restarts: one-shot, every N ms, daily/weekly at HH:MM in a timezone. Tolerance for downtime, retries, scopes. Built on @morpho/kv.", "main": "index.js", "engines": { "node": ">=22.5.0" }, "license": "MIT" }
|