@meith/tasks 0.31.0 → 0.33.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/tasks",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,9 @@
10
10
  "type": "module",
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
+ "dependencies": {
14
+ "@meith/core": "^0.33.0"
15
+ },
13
16
  "files": [
14
17
  "src",
15
18
  "!src/**/*.test.*",
package/src/builtin.ts CHANGED
@@ -24,6 +24,7 @@ export interface TaskWorkers {
24
24
  expireWarnings(batchSize: number): Promise<number>
25
25
  notifySubscribers(batchSize: number, signal: AbortSignal): Promise<number>
26
26
  sendDigests(batchSize: number, signal: AbortSignal): Promise<number>
27
+ sendBoardDigests(batchSize: number, signal: AbortSignal): Promise<number>
27
28
  sweepAttachments(batchSize: number): Promise<{ deleted: number; failed: number }>
28
29
  sweepAvatars(batchSize: number): Promise<number>
29
30
  rollUpStatistics(): Promise<{ memberCount: number; online: number; record: boolean }>
@@ -361,6 +362,26 @@ function allDefinitions(workers: TaskWorkers): TaskDefinition[] {
361
362
  },
362
363
  },
363
364
 
365
+ {
366
+ id: 'board.digest_send',
367
+ title: 'Send board activity digests',
368
+ titleKey: 'adminSystem.task.boardDigestSend.title',
369
+ description:
370
+ 'Sends the recap a member opted into after they have been away long enough to ' +
371
+ 'count as lapsed, and only once their own cadence — weekly or monthly — is due. ' +
372
+ 'Content is fetched per recipient through the same permission filter as the rest ' +
373
+ 'of the board, so a digest never names a thread its reader could not open. Hourly, ' +
374
+ 'for the same reason the subscription digest is: a due check is a query that finds ' +
375
+ 'nobody most of the time.',
376
+ descriptionKey: 'adminSystem.task.boardDigestSend.description',
377
+ intervalSeconds: 3_600,
378
+ maxDurationSeconds: 60,
379
+ async run({ signal }) {
380
+ const notified = await workers.sendBoardDigests(50, signal)
381
+ return { detail: { notified } }
382
+ },
383
+ },
384
+
364
385
  {
365
386
  id: 'attachments.sweep',
366
387
  title: 'Collect abandoned attachment files',
@@ -446,6 +467,7 @@ const REQUIRED_WORKER: Readonly<Record<string, keyof TaskWorkers>> = {
446
467
  'warnings.expire': 'expireWarnings',
447
468
  'subscriptions.instant': 'notifySubscribers',
448
469
  'subscriptions.digest': 'sendDigests',
470
+ 'board.digest_send': 'sendBoardDigests',
449
471
  'attachments.sweep': 'sweepAttachments',
450
472
  'avatars.sweep': 'sweepAvatars',
451
473
  'stats.rollup': 'rollUpStatistics',
package/src/scheduler.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type CronSchedule, nextRun, parseCron } from '@meith/core'
2
+
1
3
  import type { TaskContext, TaskDefinition, TaskResult } from './types'
2
4
 
3
5
  export interface TaskRepository {
@@ -13,9 +15,17 @@ export interface TaskRepository {
13
15
  success: boolean
14
16
  detail?: Record<string, unknown>
15
17
  error?: string
18
+ nextRunAt?: Date
16
19
  }): Promise<void>
17
20
 
18
- ensureRegistered(tasks: readonly { id: string; intervalSeconds: number }[]): Promise<void>
21
+ ensureRegistered(
22
+ tasks: readonly {
23
+ id: string
24
+ intervalSeconds: number
25
+ schedule?: string
26
+ firstRunAt?: Date
27
+ }[],
28
+ ): Promise<void>
19
29
  }
20
30
 
21
31
  export interface TickOutcome {
@@ -44,8 +54,35 @@ export async function tick({
44
54
  onError,
45
55
  signal,
46
56
  }: TickDeps): Promise<TickOutcome[]> {
57
+ const schedules = new Map<string, { expression: string; parsed: CronSchedule }>()
58
+ const broken = new Map<string, string>()
59
+ for (const task of tasks) {
60
+ if (task.schedule === undefined) continue
61
+ try {
62
+ const parsed = parseCron(task.schedule)
63
+ nextRun(parsed, now)
64
+ schedules.set(task.id, { expression: task.schedule, parsed })
65
+ } catch (error) {
66
+ const message = error instanceof Error ? error.message : String(error)
67
+ broken.set(task.id, message)
68
+ onError?.(task.id, error)
69
+ }
70
+ }
71
+
47
72
  await repository.ensureRegistered(
48
- tasks.map((t) => ({ id: t.id, intervalSeconds: t.intervalSeconds })),
73
+ tasks
74
+ .filter((t) => !broken.has(t.id))
75
+ .map((t) => {
76
+ const entry = schedules.get(t.id)
77
+ return entry === undefined
78
+ ? { id: t.id, intervalSeconds: t.intervalSeconds }
79
+ : {
80
+ id: t.id,
81
+ intervalSeconds: t.intervalSeconds,
82
+ schedule: entry.expression,
83
+ firstRunAt: nextRun(entry.parsed, now),
84
+ }
85
+ }),
49
86
  )
50
87
 
51
88
  const outcomes: TickOutcome[] = []
@@ -54,6 +91,12 @@ export async function tick({
54
91
  for (const task of tasks) {
55
92
  const startedAt = Date.now()
56
93
 
94
+ const brokenReason = broken.get(task.id)
95
+ if (brokenReason !== undefined) {
96
+ outcomes.push({ taskId: task.id, status: 'failed', durationMs: 0, error: brokenReason })
97
+ continue
98
+ }
99
+
57
100
  if (signal?.aborted === true) {
58
101
  outcomes.push({ taskId: task.id, status: 'skipped', durationMs: 0 })
59
102
  continue
@@ -88,11 +131,15 @@ export async function tick({
88
131
 
89
132
  const result: TaskResult = await task.run(context)
90
133
 
134
+ const finishedAt = elapsedSince()
135
+ const entry = schedules.get(task.id)
136
+
91
137
  await repository.release({
92
138
  taskId: task.id,
93
- finishedAt: elapsedSince(),
139
+ finishedAt,
94
140
  success: true,
95
141
  ...(result.detail ? { detail: result.detail } : {}),
142
+ ...(entry ? { nextRunAt: nextRun(entry.parsed, finishedAt) } : {}),
96
143
  })
97
144
 
98
145
  outcomes.push({
@@ -106,11 +153,15 @@ export async function tick({
106
153
  const message = error instanceof Error ? error.message : String(error)
107
154
  onError?.(task.id, error)
108
155
 
156
+ const finishedAt = elapsedSince()
157
+ const entry = schedules.get(task.id)
158
+
109
159
  await repository.release({
110
160
  taskId: task.id,
111
- finishedAt: elapsedSince(),
161
+ finishedAt,
112
162
  success: false,
113
163
  error: message,
164
+ ...(entry ? { nextRunAt: nextRun(entry.parsed, finishedAt) } : {}),
114
165
  })
115
166
 
116
167
  outcomes.push({
package/src/types.ts CHANGED
@@ -16,6 +16,7 @@ export interface TaskDefinition {
16
16
  readonly titleKey?: string
17
17
  readonly descriptionKey?: string
18
18
  readonly intervalSeconds: number
19
+ readonly schedule?: string
19
20
  readonly maxDurationSeconds: number
20
21
  run(context: TaskContext): Promise<TaskResult>
21
22
  }