enqiu 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.
package/dist/cron.js ADDED
@@ -0,0 +1,217 @@
1
+ const monthNames = new Map([
2
+ ["jan", 1],
3
+ ["feb", 2],
4
+ ["mar", 3],
5
+ ["apr", 4],
6
+ ["may", 5],
7
+ ["jun", 6],
8
+ ["jul", 7],
9
+ ["aug", 8],
10
+ ["sep", 9],
11
+ ["oct", 10],
12
+ ["nov", 11],
13
+ ["dec", 12],
14
+ ]);
15
+ const weekdayNames = new Map([
16
+ ["sun", 0],
17
+ ["mon", 1],
18
+ ["tue", 2],
19
+ ["wed", 3],
20
+ ["thu", 4],
21
+ ["fri", 5],
22
+ ["sat", 6],
23
+ ]);
24
+ const formatters = new Map();
25
+ export class CronExpressionError extends TypeError {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "CronExpressionError";
29
+ }
30
+ }
31
+ export function parseCron(expression) {
32
+ const fields = expression.trim().split(/\s+/);
33
+ if (fields.length !== 5) {
34
+ throw new CronExpressionError("Cron expressions must contain five fields: minute hour day month weekday");
35
+ }
36
+ return {
37
+ expression,
38
+ minute: parseField(fields[0], 0, 59),
39
+ hour: parseField(fields[1], 0, 23),
40
+ dayOfMonth: parseField(fields[2], 1, 31),
41
+ month: parseField(fields[3], 1, 12, monthNames),
42
+ dayOfWeek: parseField(fields[4], 0, 7, weekdayNames, true),
43
+ };
44
+ }
45
+ export function validateTimeZone(timeZone) {
46
+ const normalized = timeZone.trim();
47
+ if (!normalized) {
48
+ throw new RangeError("timezone must not be empty");
49
+ }
50
+ try {
51
+ getFormatter(normalized).format(0);
52
+ }
53
+ catch {
54
+ throw new RangeError(`Invalid IANA timezone "${timeZone}"`);
55
+ }
56
+ return normalized;
57
+ }
58
+ export function nextCronOccurrence(cron, timeZone, after) {
59
+ if (!Number.isFinite(after)) {
60
+ throw new RangeError("The cron cursor must be a finite timestamp");
61
+ }
62
+ const parsed = typeof cron === "string" ? parseCron(cron) : cron;
63
+ const zone = validateTimeZone(timeZone);
64
+ const current = zonedParts(after, zone);
65
+ let localCursor = Date.UTC(current.year, current.month - 1, current.day, current.hour, current.minute);
66
+ const finalCursor = localCursor + 366 * 24 * 60 * 60 * 1000 * 5;
67
+ while (localCursor <= finalCursor) {
68
+ const date = new Date(localCursor);
69
+ const parts = {
70
+ year: date.getUTCFullYear(),
71
+ month: date.getUTCMonth() + 1,
72
+ day: date.getUTCDate(),
73
+ hour: date.getUTCHours(),
74
+ minute: date.getUTCMinutes(),
75
+ weekday: date.getUTCDay(),
76
+ };
77
+ if (matches(parsed, parts)) {
78
+ const candidates = localPartsToEpochs(parts, zone);
79
+ const next = candidates.find((candidate) => candidate > after);
80
+ if (next !== undefined) {
81
+ return next;
82
+ }
83
+ }
84
+ localCursor += 60_000;
85
+ }
86
+ throw new CronExpressionError(`Cron expression "${parsed.expression}" has no occurrence within five years`);
87
+ }
88
+ function parseField(source, minimum, maximum, names, normalizeSunday = false) {
89
+ const values = new Set();
90
+ const wildcard = source === "*" || source.startsWith("*/");
91
+ for (const segment of source.toLowerCase().split(",")) {
92
+ if (!segment) {
93
+ throw new CronExpressionError(`Invalid empty cron field in "${source}"`);
94
+ }
95
+ const [rangeSource, stepSource, extra] = segment.split("/");
96
+ if (extra !== undefined || rangeSource === undefined) {
97
+ throw new CronExpressionError(`Invalid cron field "${source}"`);
98
+ }
99
+ const step = stepSource === undefined
100
+ ? 1
101
+ : parseInteger(stepSource, "step");
102
+ if (step < 1) {
103
+ throw new CronExpressionError("Cron steps must be positive");
104
+ }
105
+ let start;
106
+ let end;
107
+ if (rangeSource === "*") {
108
+ start = minimum;
109
+ end = maximum;
110
+ }
111
+ else {
112
+ const range = rangeSource.split("-");
113
+ if (range.length > 2) {
114
+ throw new CronExpressionError(`Invalid cron range "${rangeSource}"`);
115
+ }
116
+ start = parseValue(range[0], names);
117
+ end =
118
+ range[1] === undefined ? start : parseValue(range[1], names);
119
+ }
120
+ if (start < minimum ||
121
+ start > maximum ||
122
+ end < minimum ||
123
+ end > maximum ||
124
+ start > end) {
125
+ throw new CronExpressionError(`Cron value "${rangeSource}" must be between ${minimum} and ${maximum}`);
126
+ }
127
+ for (let value = start; value <= end; value += step) {
128
+ values.add(normalizeSunday && value === 7 ? 0 : value);
129
+ }
130
+ }
131
+ return { wildcard, values };
132
+ }
133
+ function parseValue(source, names) {
134
+ const named = names?.get(source);
135
+ return named ?? parseInteger(source, "value");
136
+ }
137
+ function parseInteger(source, label) {
138
+ if (!/^\d+$/.test(source)) {
139
+ throw new CronExpressionError(`Invalid cron ${label} "${source}"`);
140
+ }
141
+ return Number.parseInt(source, 10);
142
+ }
143
+ function matches(cron, parts) {
144
+ if (!cron.minute.values.has(parts.minute) ||
145
+ !cron.hour.values.has(parts.hour) ||
146
+ !cron.month.values.has(parts.month)) {
147
+ return false;
148
+ }
149
+ const dayOfMonth = cron.dayOfMonth.values.has(parts.day);
150
+ const dayOfWeek = cron.dayOfWeek.values.has(parts.weekday);
151
+ if (!cron.dayOfMonth.wildcard && !cron.dayOfWeek.wildcard) {
152
+ return dayOfMonth || dayOfWeek;
153
+ }
154
+ return dayOfMonth && dayOfWeek;
155
+ }
156
+ function localPartsToEpochs(parts, timeZone) {
157
+ const naive = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute);
158
+ const guesses = new Set();
159
+ let candidate = naive;
160
+ for (let iteration = 0; iteration < 4; iteration += 1) {
161
+ const actual = zonedParts(candidate, timeZone);
162
+ const represented = Date.UTC(actual.year, actual.month - 1, actual.day, actual.hour, actual.minute);
163
+ candidate += naive - represented;
164
+ guesses.add(candidate);
165
+ }
166
+ for (const delta of [
167
+ -3_600_000,
168
+ 3_600_000,
169
+ -7_200_000,
170
+ 7_200_000,
171
+ ]) {
172
+ guesses.add(candidate + delta);
173
+ }
174
+ return [...guesses]
175
+ .filter((value) => equalLocalParts(zonedParts(value, timeZone), parts))
176
+ .sort((left, right) => left - right);
177
+ }
178
+ function equalLocalParts(left, right) {
179
+ return (left.year === right.year &&
180
+ left.month === right.month &&
181
+ left.day === right.day &&
182
+ left.hour === right.hour &&
183
+ left.minute === right.minute);
184
+ }
185
+ function zonedParts(timestamp, timeZone) {
186
+ const parts = getFormatter(timeZone).formatToParts(timestamp);
187
+ const values = new Map(parts.map((part) => [part.type, part.value]));
188
+ const weekday = weekdayNames.get((values.get("weekday") ?? "").toLowerCase());
189
+ if (weekday === undefined) {
190
+ throw new RangeError(`Could not resolve timezone "${timeZone}"`);
191
+ }
192
+ return {
193
+ year: Number(values.get("year")),
194
+ month: Number(values.get("month")),
195
+ day: Number(values.get("day")),
196
+ hour: Number(values.get("hour")),
197
+ minute: Number(values.get("minute")),
198
+ weekday,
199
+ };
200
+ }
201
+ function getFormatter(timeZone) {
202
+ let formatter = formatters.get(timeZone);
203
+ if (!formatter) {
204
+ formatter = new Intl.DateTimeFormat("en-US", {
205
+ timeZone,
206
+ year: "numeric",
207
+ month: "2-digit",
208
+ day: "2-digit",
209
+ hour: "2-digit",
210
+ minute: "2-digit",
211
+ weekday: "short",
212
+ hourCycle: "h23",
213
+ });
214
+ formatters.set(timeZone, formatter);
215
+ }
216
+ return formatter;
217
+ }
@@ -0,0 +1,5 @@
1
+ export { JobCancelledError, JobExpiredError, JobFailedError, JobSerializationError, JobTimeoutError, JobValidationError, QueueClosedError, job, enqiu, } from "./api.js";
2
+ export type { AnyJobSnapshot, BulkOptions, CleanupQuery, ConcurrencyPolicy, DebouncePolicy, HandlerJobDefinition, InferSchemaInput, InferSchemaOutput, JobCallable, JobContext, JobDefinition, JobDefinitions, JobHandle, JobHandler, JobListPage, JobListQuery, JobLogger, JobPolicyOptions, JobsApi, MemoryEnqiuOptions, Progress, EnqiuOptions, QueueApi, RedisEnqiuOptions, RetryPolicy, ScheduleHandle, ScheduleOptions, ScheduleSnapshot, SchemaJobDefinition, SharedEnqiuOptions, StandardSchemaIssue, StandardSchemaV1, SubmitOptions, Telemetry, TelemetryEvent, ThrottlePolicy, WorkerApi, WorkerOptions, WorkerStartOptions, } from "./api.js";
3
+ export { redis, type RedisCommandClient, type RedisDriver, type RedisDriverOptions, } from "./redis.js";
4
+ export type { JobSnapshot, JobStatus, JobLogEntry, JobLogLevel, QueueEventMap, QueueStats, SerializedError, } from "./memory.js";
5
+ export { enqiu as default } from "./api.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { JobCancelledError, JobExpiredError, JobFailedError, JobSerializationError, JobTimeoutError, JobValidationError, QueueClosedError, job, enqiu, } from "./api.js";
2
+ export { redis, } from "./redis.js";
3
+ export { enqiu as default } from "./api.js";
@@ -0,0 +1,24 @@
1
+ import type { ScheduleHandle, ScheduleSnapshot } from "./api.js";
2
+ export interface MemoryScheduleRegistration {
3
+ id?: string;
4
+ jobName: string;
5
+ cron: string;
6
+ timezone?: string;
7
+ input: unknown;
8
+ catchUp?: boolean;
9
+ enqueue(input: unknown, occurrence: number): Promise<void>;
10
+ }
11
+ export declare class MemoryScheduler {
12
+ private readonly schedules;
13
+ private closed;
14
+ upsert(registration: MemoryScheduleRegistration): Promise<ScheduleHandle>;
15
+ get(id: string): ScheduleSnapshot | undefined;
16
+ pause(id: string): void;
17
+ resume(id: string): void;
18
+ remove(id: string): void;
19
+ close(): void;
20
+ private arm;
21
+ private tick;
22
+ private require;
23
+ private assertOpen;
24
+ }
@@ -0,0 +1,163 @@
1
+ import { nextCronOccurrence, parseCron, validateTimeZone, } from "./cron.js";
2
+ export class MemoryScheduler {
3
+ schedules = new Map();
4
+ closed = false;
5
+ async upsert(registration) {
6
+ this.assertOpen();
7
+ parseCron(registration.cron);
8
+ const timezone = validateTimeZone(registration.timezone ?? "UTC");
9
+ const id = registration.id?.trim() || registration.jobName;
10
+ if (!id) {
11
+ throw new TypeError("schedule.id must not be empty");
12
+ }
13
+ const existing = this.schedules.get(id);
14
+ const now = Date.now();
15
+ const schedule = existing ?? {
16
+ id,
17
+ jobName: registration.jobName,
18
+ cron: registration.cron,
19
+ timezone,
20
+ status: "active",
21
+ nextRunAt: 0,
22
+ input: registration.input,
23
+ catchUp: registration.catchUp ?? false,
24
+ enqueue: registration.enqueue,
25
+ timer: undefined,
26
+ revision: 0,
27
+ };
28
+ if (existing && existing.jobName !== registration.jobName) {
29
+ throw new Error(`Schedule "${id}" already belongs to job "${existing.jobName}"`);
30
+ }
31
+ schedule.cron = registration.cron;
32
+ schedule.timezone = timezone;
33
+ schedule.input = registration.input;
34
+ schedule.catchUp = registration.catchUp ?? false;
35
+ schedule.enqueue = registration.enqueue;
36
+ schedule.nextRunAt = nextCronOccurrence(schedule.cron, schedule.timezone, now);
37
+ schedule.revision += 1;
38
+ this.schedules.set(id, schedule);
39
+ this.arm(schedule);
40
+ return new MemoryScheduleHandle(this, id);
41
+ }
42
+ get(id) {
43
+ const schedule = this.schedules.get(id);
44
+ return schedule ? snapshot(schedule) : undefined;
45
+ }
46
+ pause(id) {
47
+ const schedule = this.require(id);
48
+ schedule.status = "paused";
49
+ schedule.revision += 1;
50
+ clearScheduleTimer(schedule);
51
+ }
52
+ resume(id) {
53
+ const schedule = this.require(id);
54
+ schedule.status = "active";
55
+ schedule.nextRunAt = nextCronOccurrence(schedule.cron, schedule.timezone, Date.now());
56
+ schedule.revision += 1;
57
+ this.arm(schedule);
58
+ }
59
+ remove(id) {
60
+ const schedule = this.require(id);
61
+ clearScheduleTimer(schedule);
62
+ this.schedules.delete(id);
63
+ }
64
+ close() {
65
+ if (this.closed) {
66
+ return;
67
+ }
68
+ this.closed = true;
69
+ for (const schedule of this.schedules.values()) {
70
+ clearScheduleTimer(schedule);
71
+ }
72
+ }
73
+ arm(schedule) {
74
+ clearScheduleTimer(schedule);
75
+ if (this.closed || schedule.status !== "active") {
76
+ return;
77
+ }
78
+ const revision = schedule.revision;
79
+ const delay = Math.max(0, schedule.nextRunAt - Date.now());
80
+ schedule.timer = setTimeout(() => {
81
+ schedule.timer = undefined;
82
+ void this.tick(schedule.id, revision);
83
+ }, Math.min(delay, 2_147_483_647));
84
+ }
85
+ async tick(id, revision) {
86
+ const schedule = this.schedules.get(id);
87
+ if (!schedule ||
88
+ schedule.status !== "active" ||
89
+ schedule.revision !== revision ||
90
+ this.closed) {
91
+ return;
92
+ }
93
+ const occurrence = schedule.nextRunAt;
94
+ const now = Date.now();
95
+ schedule.nextRunAt = nextCronOccurrence(schedule.cron, schedule.timezone, schedule.catchUp ? occurrence : now);
96
+ schedule.revision += 1;
97
+ this.arm(schedule);
98
+ try {
99
+ await schedule.enqueue(schedule.input, occurrence);
100
+ }
101
+ catch {
102
+ // Submission failures remain observable through queue events/telemetry.
103
+ // A schedule must continue advancing instead of creating a hot loop.
104
+ }
105
+ }
106
+ require(id) {
107
+ const schedule = this.schedules.get(id);
108
+ if (!schedule) {
109
+ throw new Error(`Schedule "${id}" does not exist`);
110
+ }
111
+ return schedule;
112
+ }
113
+ assertOpen() {
114
+ if (this.closed) {
115
+ throw new Error("Scheduler is closed");
116
+ }
117
+ }
118
+ }
119
+ class MemoryScheduleHandle {
120
+ owner;
121
+ id;
122
+ constructor(owner, id) {
123
+ this.owner = owner;
124
+ this.id = id;
125
+ }
126
+ get nextRunAt() {
127
+ return this.owner.get(this.id)?.nextRunAt ?? 0;
128
+ }
129
+ async pause() {
130
+ this.owner.pause(this.id);
131
+ }
132
+ async resume() {
133
+ this.owner.resume(this.id);
134
+ }
135
+ async remove() {
136
+ this.owner.remove(this.id);
137
+ }
138
+ async refresh() {
139
+ const value = this.owner.get(this.id);
140
+ if (!value) {
141
+ throw new Error(`Schedule "${this.id}" does not exist`);
142
+ }
143
+ return value;
144
+ }
145
+ }
146
+ function snapshot(schedule) {
147
+ return {
148
+ id: schedule.id,
149
+ jobName: schedule.jobName,
150
+ cron: schedule.cron,
151
+ timezone: schedule.timezone,
152
+ status: schedule.status,
153
+ nextRunAt: schedule.nextRunAt,
154
+ input: schedule.input,
155
+ catchUp: schedule.catchUp,
156
+ };
157
+ }
158
+ function clearScheduleTimer(schedule) {
159
+ if (schedule.timer !== undefined) {
160
+ clearTimeout(schedule.timer);
161
+ schedule.timer = undefined;
162
+ }
163
+ }