morpho-socket 0.3.0 → 0.4.0

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.
@@ -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" }