@devindex/api-kit 0.1.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,109 @@
1
+ import { noopLogger } from '../internal/logger.js';
2
+ import { assertHandler, assertName } from '../internal/validation.js';
3
+ import { normalizeSchedule } from './internal.js';
4
+ import { memoryBackend } from './drivers/memory.js';
5
+ import { bullmqBackend } from './drivers/bullmq.js';
6
+
7
+ // Named for what runs the schedules, not for its store: the distributed backend
8
+ // is BullMQ (Redis is only where it keeps state), so the semantics a caller gets —
9
+ // leaderless coordination, global serialization, retention — are BullMQ's.
10
+ const BACKENDS = { memory: memoryBackend, bullmq: bullmqBackend };
11
+
12
+ /**
13
+ * Creates an in-process Croner schedule or a distributed BullMQ schedule.
14
+ *
15
+ * The lifecycle — declaration, listing, state — lives here; each backend only
16
+ * schedule()s, unschedule()s and stop()s.
17
+ *
18
+ * @param {object} [options]
19
+ * @param {'memory'|'bullmq'} [options.driver='memory']
20
+ * @param {string} [options.redisUrl] - Required by the BullMQ driver.
21
+ * @param {string} [options.prefix='app']
22
+ * @param {object} [options.logger]
23
+ * @return {object} The schedule, not yet started; call start() first.
24
+ */
25
+ export function createSchedule({
26
+ driver = 'memory',
27
+ redisUrl,
28
+ prefix = 'app',
29
+ logger = noopLogger,
30
+ } = {}) {
31
+ const createBackend = BACKENDS[driver];
32
+ if (!createBackend) {
33
+ throw new Error(`unknown schedule driver "${driver}", expected ${Object.keys(BACKENDS).join(' or ')}`);
34
+ }
35
+
36
+ const definitions = new Map();
37
+ const backend = createBackend({ redisUrl, prefix, logger });
38
+ let state = 'idle';
39
+
40
+ /**
41
+ * Declares a schedule before start(); the name must be unique.
42
+ *
43
+ * @param {string} name
44
+ * @param {{pattern: string, timeZone?: string}} spec - Cron pattern (6- or 5-field); `timeZone` defaults to `'UTC'`.
45
+ * @param {(context: {name: string, signal: AbortSignal, log: object}) => (void|Promise<void>)} handler
46
+ * @return {void}
47
+ */
48
+ function define(name, spec, handler) {
49
+ if (state !== 'idle') throw new Error('schedules must be declared before start()');
50
+ const scheduleName = assertName(name, 'schedule');
51
+ assertHandler(scheduleName, handler);
52
+ if (definitions.has(scheduleName)) throw new Error(`schedule "${scheduleName}" is already declared`);
53
+ definitions.set(scheduleName, { spec: normalizeSchedule(scheduleName, spec), handler });
54
+ }
55
+
56
+ async function start() {
57
+ if (state === 'started') return;
58
+ if (state === 'stopped') throw new Error('a stopped schedule cannot be restarted');
59
+ if (state !== 'idle') throw new Error('the schedule is already changing state');
60
+ state = 'starting';
61
+ // allSettled so every scheduler finishes upserting before we roll back; a
62
+ // mid-flight reject would let a straggler upsert after cleanup, orphaning it.
63
+ const results = await Promise.allSettled(
64
+ [...definitions].map(([name, definition]) => backend.schedule(name, definition)),
65
+ );
66
+ const failed = results.find((result) => result.status === 'rejected');
67
+ if (failed) {
68
+ state = 'stopping';
69
+ // This replica upserted these schedulers in this aborting call, so it owns
70
+ // their removal; left behind they produce jobs into queues no worker drains.
71
+ await backend.stop({ removeSchedulers: true });
72
+ state = 'stopped';
73
+ throw failed.reason;
74
+ }
75
+ state = 'started';
76
+ }
77
+
78
+ /**
79
+ * Stops future ticks and drops the declaration.
80
+ *
81
+ * @param {string} name
82
+ * @return {Promise<boolean>} Whether the schedule was declared here.
83
+ */
84
+ async function remove(name) {
85
+ const scheduleName = assertName(name, 'schedule');
86
+ await backend.unschedule(scheduleName);
87
+ return definitions.delete(scheduleName);
88
+ }
89
+
90
+ async function stop(options = {}) {
91
+ if (state === 'stopped') return;
92
+ state = 'stopping';
93
+ await backend.stop(options);
94
+ state = 'stopped';
95
+ }
96
+
97
+ return Object.freeze({
98
+ driver,
99
+ define,
100
+ start,
101
+ stop,
102
+ remove,
103
+ /** @return {Array<{name: string, pattern: string, timeZone: string}>} Local declarations. */
104
+ list: () => [...definitions].map(([name, definition]) => ({ name, ...definition.spec })),
105
+ get state() {
106
+ return state;
107
+ },
108
+ });
109
+ }
@@ -0,0 +1,21 @@
1
+ import { assertName } from '../internal/validation.js';
2
+
3
+ /**
4
+ * Validates a schedule spec and freezes it with defaults applied.
5
+ *
6
+ * @param {string} name - Schedule name, used only in error messages.
7
+ * @param {{pattern?: string, timeZone?: string}} [spec] - `timeZone` defaults to `'UTC'`.
8
+ * @return {Readonly<{pattern: string, timeZone: string}>}
9
+ * @throws {TypeError} On an empty name, pattern or timezone.
10
+ */
11
+ export function normalizeSchedule(name, spec = {}) {
12
+ const scheduleName = assertName(name, 'schedule');
13
+ if (typeof spec.pattern !== 'string' || spec.pattern.trim().length === 0) {
14
+ throw new TypeError(`the schedule for "${scheduleName}" needs a non-empty cron pattern`);
15
+ }
16
+ const timeZone = spec.timeZone ?? 'UTC';
17
+ if (typeof timeZone !== 'string' || timeZone.length === 0) {
18
+ throw new TypeError(`the timezone for "${scheduleName}" must be a non-empty string`);
19
+ }
20
+ return Object.freeze({ pattern: spec.pattern.trim(), timeZone });
21
+ }