effect 2.0.2 → 2.0.3

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/src/Cron.ts ADDED
@@ -0,0 +1,525 @@
1
+ /**
2
+ * @since 2.0.0
3
+ */
4
+ import * as Either from "./Either.js"
5
+ import * as Equal from "./Equal.js"
6
+ import * as equivalence from "./Equivalence.js"
7
+ import { dual, pipe } from "./Function.js"
8
+ import * as Hash from "./Hash.js"
9
+ import { format, type Inspectable, NodeInspectSymbol } from "./Inspectable.js"
10
+ import * as N from "./Number.js"
11
+ import { type Pipeable, pipeArguments } from "./Pipeable.js"
12
+ import { hasProperty } from "./Predicate.js"
13
+ import * as ReadonlyArray from "./ReadonlyArray.js"
14
+ import * as String from "./String.js"
15
+ import type { Mutable } from "./Types.js"
16
+
17
+ /**
18
+ * @since 2.0.0
19
+ * @category symbols
20
+ */
21
+ export const TypeId: unique symbol = Symbol.for("effect/Cron")
22
+
23
+ /**
24
+ * @since 2.0.0
25
+ * @category symbol
26
+ */
27
+ export type TypeId = typeof TypeId
28
+
29
+ /**
30
+ * @since 2.0.0
31
+ * @category models
32
+ */
33
+ export interface Cron extends Pipeable, Equal.Equal, Inspectable {
34
+ readonly [TypeId]: TypeId
35
+ readonly minutes: ReadonlySet<number>
36
+ readonly hours: ReadonlySet<number>
37
+ readonly days: ReadonlySet<number>
38
+ readonly months: ReadonlySet<number>
39
+ readonly weekdays: ReadonlySet<number>
40
+ }
41
+
42
+ const CronProto: Omit<Cron, "minutes" | "hours" | "days" | "months" | "weekdays"> = {
43
+ [TypeId]: TypeId,
44
+ [Equal.symbol](this: Cron, that: unknown) {
45
+ return isCron(that) && equals(this, that)
46
+ },
47
+ [Hash.symbol](this: Cron): number {
48
+ return pipe(
49
+ Hash.array(ReadonlyArray.fromIterable(this.minutes)),
50
+ Hash.combine(Hash.array(ReadonlyArray.fromIterable(this.hours))),
51
+ Hash.combine(Hash.array(ReadonlyArray.fromIterable(this.days))),
52
+ Hash.combine(Hash.array(ReadonlyArray.fromIterable(this.months))),
53
+ Hash.combine(Hash.array(ReadonlyArray.fromIterable(this.weekdays)))
54
+ )
55
+ },
56
+ toString(this: Cron) {
57
+ return format(this.toJSON())
58
+ },
59
+ toJSON(this: Cron) {
60
+ return {
61
+ _id: "Cron",
62
+ minutes: ReadonlyArray.fromIterable(this.minutes),
63
+ hours: ReadonlyArray.fromIterable(this.hours),
64
+ days: ReadonlyArray.fromIterable(this.days),
65
+ months: ReadonlyArray.fromIterable(this.months),
66
+ weekdays: ReadonlyArray.fromIterable(this.weekdays)
67
+ }
68
+ },
69
+ [NodeInspectSymbol](this: Cron) {
70
+ return this.toJSON()
71
+ },
72
+ pipe() {
73
+ return pipeArguments(this, arguments)
74
+ }
75
+ } as const
76
+
77
+ /**
78
+ * Checks if a given value is a `Cron` instance.
79
+ *
80
+ * @param u - The value to check.
81
+ *
82
+ * @since 2.0.0
83
+ * @category guards
84
+ */
85
+ export const isCron = (u: unknown): u is Cron => hasProperty(u, TypeId)
86
+
87
+ /**
88
+ * Creates a `Cron` instance from.
89
+ *
90
+ * @param constraints - The cron constraints.
91
+ *
92
+ * @since 2.0.0
93
+ * @category constructors
94
+ */
95
+ export const make = ({
96
+ days,
97
+ hours,
98
+ minutes,
99
+ months,
100
+ weekdays
101
+ }: {
102
+ readonly minutes: Iterable<number>
103
+ readonly hours: Iterable<number>
104
+ readonly days: Iterable<number>
105
+ readonly months: Iterable<number>
106
+ readonly weekdays: Iterable<number>
107
+ }): Cron => {
108
+ const o: Mutable<Cron> = Object.create(CronProto)
109
+ o.minutes = new Set(ReadonlyArray.sort(minutes, N.Order))
110
+ o.hours = new Set(ReadonlyArray.sort(hours, N.Order))
111
+ o.days = new Set(ReadonlyArray.sort(days, N.Order))
112
+ o.months = new Set(ReadonlyArray.sort(months, N.Order))
113
+ o.weekdays = new Set(ReadonlyArray.sort(weekdays, N.Order))
114
+ return o
115
+ }
116
+
117
+ /**
118
+ * @since 2.0.0
119
+ * @category symbol
120
+ */
121
+ export const ParseErrorTypeId: unique symbol = Symbol.for("effect/Cron/errors/ParseError")
122
+
123
+ /**
124
+ * @since 2.0.0
125
+ * @category symbols
126
+ */
127
+ export type ParseErrorTypeId = typeof ParseErrorTypeId
128
+
129
+ /**
130
+ * Represents a checked exception which occurs when decoding fails.
131
+ *
132
+ * @since 2.0.0
133
+ * @category models
134
+ */
135
+ export interface ParseError {
136
+ readonly _tag: "ParseError"
137
+ readonly [ParseErrorTypeId]: ParseErrorTypeId
138
+ readonly message: string
139
+ readonly input?: string
140
+ }
141
+
142
+ const ParseErrorProto: Omit<ParseError, "input" | "message"> = {
143
+ _tag: "ParseError",
144
+ [ParseErrorTypeId]: ParseErrorTypeId
145
+ }
146
+
147
+ const ParseError = (message: string, input?: string): ParseError => {
148
+ const o: Mutable<ParseError> = Object.create(ParseErrorProto)
149
+ o.message = message
150
+ if (input !== undefined) {
151
+ o.input = input
152
+ }
153
+ return o
154
+ }
155
+
156
+ /**
157
+ * Returns `true` if the specified value is an `ParseError`, `false` otherwise.
158
+ *
159
+ * @param u - The value to check.
160
+ *
161
+ * @since 2.0.0
162
+ * @category guards
163
+ */
164
+ export const isParseError = (u: unknown): u is ParseError => hasProperty(u, ParseErrorTypeId)
165
+
166
+ /**
167
+ * Parses a cron expression into a `Cron` instance.
168
+ *
169
+ * @param cron - The cron expression to parse.
170
+ *
171
+ * @example
172
+ * import * as Cron from "effect/Cron"
173
+ * import * as Either from "effect/Either"
174
+ *
175
+ * // At 04:00 on every day-of-month from 8 through 14.
176
+ * assert.deepStrictEqual(Cron.parse("0 4 8-14 * *"), Either.right(Cron.make({
177
+ * minutes: [0],
178
+ * hours: [4],
179
+ * days: [8, 9, 10, 11, 12, 13, 14],
180
+ * months: [],
181
+ * weekdays: []
182
+ * })))
183
+ *
184
+ * @since 2.0.0
185
+ * @category constructors
186
+ */
187
+ export const parse = (cron: string): Either.Either<ParseError, Cron> => {
188
+ const segments = cron.split(" ").filter(String.isNonEmpty)
189
+ if (segments.length !== 5) {
190
+ return Either.left(ParseError(`Invalid number of segments in cron expression`, cron))
191
+ }
192
+
193
+ const [minutes, hours, days, months, weekdays] = segments
194
+ return Either.all({
195
+ minutes: parseSegment(minutes, minuteOptions),
196
+ hours: parseSegment(hours, hourOptions),
197
+ days: parseSegment(days, dayOptions),
198
+ months: parseSegment(months, monthOptions),
199
+ weekdays: parseSegment(weekdays, weekdayOptions)
200
+ }).pipe(Either.map((segments) => make(segments)))
201
+ }
202
+
203
+ /**
204
+ * Checks if a given `Date` falls within an active `Cron` time window.
205
+ *
206
+ * @param cron - The `Cron` instance.
207
+ * @param date - The `Date` to check against.
208
+ *
209
+ * @example
210
+ * import * as Cron from "effect/Cron"
211
+ * import * as Either from "effect/Either"
212
+ *
213
+ * const cron = Either.getOrThrow(Cron.parse("0 4 8-14 * *"))
214
+ * assert.deepStrictEqual(Cron.match(cron, new Date("2021-01-08 04:00:00")), true)
215
+ * assert.deepStrictEqual(Cron.match(cron, new Date("2021-01-08 05:00:00")), false)
216
+ *
217
+ * @since 2.0.0
218
+ */
219
+ export const match = (cron: Cron, date: Date): boolean => {
220
+ const { days, hours, minutes, months, weekdays } = cron
221
+
222
+ const minute = date.getMinutes()
223
+ if (minutes.size !== 0 && !minutes.has(minute)) {
224
+ return false
225
+ }
226
+
227
+ const hour = date.getHours()
228
+ if (hours.size !== 0 && !hours.has(hour)) {
229
+ return false
230
+ }
231
+
232
+ const month = date.getMonth() + 1
233
+ if (months.size !== 0 && !months.has(month)) {
234
+ return false
235
+ }
236
+
237
+ if (days.size === 0 && weekdays.size === 0) {
238
+ return true
239
+ }
240
+
241
+ const day = date.getDate()
242
+ if (weekdays.size === 0) {
243
+ return days.has(day)
244
+ }
245
+
246
+ const weekday = date.getDay()
247
+ if (days.size === 0) {
248
+ return weekdays.has(weekday)
249
+ }
250
+
251
+ return days.has(day) || weekdays.has(weekday)
252
+ }
253
+
254
+ /**
255
+ * Returns the next run `Date` for the given `Cron` instance.
256
+ *
257
+ * Uses the current time as a starting point if no value is provided for `now`.
258
+ *
259
+ * @example
260
+ * import * as Cron from "effect/Cron"
261
+ * import * as Either from "effect/Either"
262
+ *
263
+ * const after = new Date("2021-01-01 00:00:00")
264
+ * const cron = Either.getOrThrow(Cron.parse("0 4 8-14 * *"))
265
+ * assert.deepStrictEqual(Cron.next(cron, after), new Date("2021-01-08 04:00:00"))
266
+ *
267
+ * @param cron - The `Cron` instance.
268
+ * @param now - The `Date` to start searching from.
269
+ *
270
+ * @since 2.0.0
271
+ */
272
+ export const next = (cron: Cron, now?: Date): Date => {
273
+ const { days, hours, minutes, months, weekdays } = cron
274
+
275
+ const restrictMinutes = minutes.size !== 0
276
+ const restrictHours = hours.size !== 0
277
+ const restrictDays = days.size !== 0
278
+ const restrictMonths = months.size !== 0
279
+ const restrictWeekdays = weekdays.size !== 0
280
+
281
+ const current = now ? new Date(now.getTime()) : new Date()
282
+ // Increment by one minute to ensure we don't match the current date.
283
+ current.setMinutes(current.getMinutes() + 1)
284
+ current.setSeconds(0)
285
+ current.setMilliseconds(0)
286
+
287
+ // Only search 8 years into the future.
288
+ const limit = new Date(current).setFullYear(current.getFullYear() + 8)
289
+ while (current.getTime() <= limit) {
290
+ if (restrictMonths && !months.has(current.getMonth() + 1)) {
291
+ current.setMonth(current.getMonth() + 1)
292
+ current.setDate(1)
293
+ current.setHours(0)
294
+ current.setMinutes(0)
295
+ continue
296
+ }
297
+
298
+ if (restrictDays && restrictWeekdays) {
299
+ if (!days.has(current.getDate()) && !weekdays.has(current.getDay())) {
300
+ current.setDate(current.getDate() + 1)
301
+ current.setHours(0)
302
+ current.setMinutes(0)
303
+ continue
304
+ }
305
+ } else if (restrictDays) {
306
+ if (!days.has(current.getDate())) {
307
+ current.setDate(current.getDate() + 1)
308
+ current.setHours(0)
309
+ current.setMinutes(0)
310
+ continue
311
+ }
312
+ } else if (restrictWeekdays) {
313
+ if (!weekdays.has(current.getDay())) {
314
+ current.setDate(current.getDate() + 1)
315
+ current.setHours(0)
316
+ current.setMinutes(0)
317
+ continue
318
+ }
319
+ }
320
+
321
+ if (restrictHours && !hours.has(current.getHours())) {
322
+ current.setHours(current.getHours() + 1)
323
+ current.setMinutes(0)
324
+ continue
325
+ }
326
+
327
+ if (restrictMinutes && !minutes.has(current.getMinutes())) {
328
+ current.setMinutes(current.getMinutes() + 1)
329
+ continue
330
+ }
331
+
332
+ return current
333
+ }
334
+
335
+ throw new Error("Unable to find next cron date")
336
+ }
337
+
338
+ /**
339
+ * Returns an `IterableIterator` which yields the sequence of `Date`s that match the `Cron` instance.
340
+ *
341
+ * @param cron - The `Cron` instance.
342
+ * @param now - The `Date` to start searching from.
343
+ *
344
+ * @since 2.0.0
345
+ */
346
+ export const sequence = function*(cron: Cron, now?: Date): IterableIterator<Date> {
347
+ while (true) {
348
+ yield now = next(cron, now)
349
+ }
350
+ }
351
+
352
+ /**
353
+ * @category instances
354
+ * @since 2.0.0
355
+ */
356
+ export const Equivalence: equivalence.Equivalence<Cron> = equivalence.make((self, that) =>
357
+ restrictionsEquals(self.minutes, that.minutes) &&
358
+ restrictionsEquals(self.hours, that.hours) &&
359
+ restrictionsEquals(self.days, that.days) &&
360
+ restrictionsEquals(self.months, that.months) &&
361
+ restrictionsEquals(self.weekdays, that.weekdays)
362
+ )
363
+
364
+ const restrictionsArrayEquals = equivalence.array(equivalence.number)
365
+ const restrictionsEquals = (self: ReadonlySet<number>, that: ReadonlySet<number>): boolean =>
366
+ restrictionsArrayEquals(ReadonlyArray.fromIterable(self), ReadonlyArray.fromIterable(that))
367
+
368
+ /**
369
+ * Checks if two `Cron`s are equal.
370
+ *
371
+ * @since 2.0.0
372
+ * @category predicates
373
+ */
374
+ export const equals: {
375
+ (that: Cron): (self: Cron) => boolean
376
+ (self: Cron, that: Cron): boolean
377
+ } = dual(2, (self: Cron, that: Cron): boolean => Equivalence(self, that))
378
+
379
+ interface SegmentOptions {
380
+ segment: string
381
+ min: number
382
+ max: number
383
+ aliases?: Record<string, number> | undefined
384
+ }
385
+
386
+ const minuteOptions: SegmentOptions = {
387
+ segment: "minute",
388
+ min: 0,
389
+ max: 59
390
+ }
391
+
392
+ const hourOptions: SegmentOptions = {
393
+ segment: "hour",
394
+ min: 0,
395
+ max: 23
396
+ }
397
+
398
+ const dayOptions: SegmentOptions = {
399
+ segment: "day",
400
+ min: 1,
401
+ max: 31
402
+ }
403
+
404
+ const monthOptions: SegmentOptions = {
405
+ segment: "month",
406
+ min: 1,
407
+ max: 12,
408
+ aliases: {
409
+ jan: 1,
410
+ feb: 2,
411
+ mar: 3,
412
+ apr: 4,
413
+ may: 5,
414
+ jun: 6,
415
+ jul: 7,
416
+ aug: 8,
417
+ sep: 9,
418
+ oct: 10,
419
+ nov: 11,
420
+ dec: 12
421
+ }
422
+ }
423
+
424
+ const weekdayOptions: SegmentOptions = {
425
+ segment: "weekday",
426
+ min: 0,
427
+ max: 6,
428
+ aliases: {
429
+ sun: 0,
430
+ mon: 1,
431
+ tue: 2,
432
+ wed: 3,
433
+ thu: 4,
434
+ fri: 5,
435
+ sat: 6
436
+ }
437
+ }
438
+
439
+ const parseSegment = (
440
+ input: string,
441
+ options: SegmentOptions
442
+ ): Either.Either<ParseError, ReadonlySet<number>> => {
443
+ const capacity = options.max - options.min + 1
444
+ const values = new Set<number>()
445
+ const fields = input.split(",")
446
+
447
+ for (const field of fields) {
448
+ const [raw, step] = splitStep(field)
449
+ if (raw === "*" && step === undefined) {
450
+ return Either.right(new Set())
451
+ }
452
+
453
+ if (step !== undefined) {
454
+ if (!Number.isInteger(step)) {
455
+ return Either.left(ParseError(`Expected step value to be a positive integer`, input))
456
+ }
457
+ if (step < 1) {
458
+ return Either.left(ParseError(`Expected step value to be greater than 0`, input))
459
+ }
460
+ if (step > options.max) {
461
+ return Either.left(ParseError(`Expected step value to be less than ${options.max}`, input))
462
+ }
463
+ }
464
+
465
+ if (raw === "*") {
466
+ for (let i = options.min; i <= options.max; i += step ?? 1) {
467
+ values.add(i)
468
+ }
469
+ } else {
470
+ const [left, right] = splitRange(raw, options.aliases)
471
+ if (!Number.isInteger(left)) {
472
+ return Either.left(ParseError(`Expected a positive integer`, input))
473
+ }
474
+ if (left < options.min || left > options.max) {
475
+ return Either.left(ParseError(`Expected a value between ${options.min} and ${options.max}`, input))
476
+ }
477
+
478
+ if (right === undefined) {
479
+ values.add(left)
480
+ } else {
481
+ if (!Number.isInteger(right)) {
482
+ return Either.left(ParseError(`Expected a positive integer`, input))
483
+ }
484
+ if (right < options.min || right > options.max) {
485
+ return Either.left(ParseError(`Expected a value between ${options.min} and ${options.max}`, input))
486
+ }
487
+ if (left > right) {
488
+ return Either.left(ParseError(`Invalid value range`, input))
489
+ }
490
+
491
+ for (let i = left; i <= right; i += step ?? 1) {
492
+ values.add(i)
493
+ }
494
+ }
495
+ }
496
+
497
+ if (values.size >= capacity) {
498
+ return Either.right(new Set())
499
+ }
500
+ }
501
+
502
+ return Either.right(values)
503
+ }
504
+
505
+ const splitStep = (input: string): [string, number | undefined] => {
506
+ const seperator = input.indexOf("/")
507
+ if (seperator !== -1) {
508
+ return [input.slice(0, seperator), Number(input.slice(seperator + 1))]
509
+ }
510
+
511
+ return [input, undefined]
512
+ }
513
+
514
+ const splitRange = (input: string, aliases?: Record<string, number>): [number, number | undefined] => {
515
+ const seperator = input.indexOf("-")
516
+ if (seperator !== -1) {
517
+ return [aliasOrValue(input.slice(0, seperator), aliases), aliasOrValue(input.slice(seperator + 1), aliases)]
518
+ }
519
+
520
+ return [aliasOrValue(input, aliases), undefined]
521
+ }
522
+
523
+ function aliasOrValue(field: string, aliases?: Record<string, number>): number {
524
+ return aliases?.[field.toLocaleLowerCase()] ?? Number(field)
525
+ }
package/src/Fiber.ts CHANGED
@@ -148,6 +148,13 @@ export interface RuntimeFiber<out E, out A> extends Fiber<E, A>, Fiber.RuntimeVa
148
148
  * already done.
149
149
  */
150
150
  unsafePoll(): Exit.Exit<E, A> | null
151
+
152
+ /**
153
+ * In the background, interrupts the fiber as if interrupted from the
154
+ * specified fiber. If the fiber has already exited, the returned effect will
155
+ * resume immediately. Otherwise, the effect will resume when the fiber exits.
156
+ */
157
+ unsafeInterruptAsFork(fiberId: FiberId.FiberId): void
151
158
  }
152
159
 
153
160
  /**
package/src/Schedule.ts CHANGED
@@ -405,6 +405,18 @@ export const mapInputEffect: {
405
405
  */
406
406
  export const count: Schedule<never, unknown, number> = internal.count
407
407
 
408
+ /**
409
+ * Cron schedule that recurs every `minute` that matches the schedule.
410
+ *
411
+ * It triggers at zero second of the minute. Producing the timestamps of the cron window.
412
+ *
413
+ * NOTE: `expression` parameter is validated lazily. Must be a valid cron expression.
414
+ *
415
+ * @since 2.0.0
416
+ * @category constructors
417
+ */
418
+ export const cron: (expression: string) => Schedule<never, unknown, [number, number]> = internal.cron
419
+
408
420
  /**
409
421
  * Cron-like schedule that recurs every specified `day` of month. Won't recur
410
422
  * on months containing less days than specified in `day` param.
package/src/index.ts CHANGED
@@ -170,6 +170,11 @@ export * as Console from "./Console.js"
170
170
  */
171
171
  export * as Context from "./Context.js"
172
172
 
173
+ /**
174
+ * @since 2.0.0
175
+ */
176
+ export * as Cron from "./Cron.js"
177
+
173
178
  /**
174
179
  * @since 2.0.0
175
180
  */
@@ -2,6 +2,7 @@ import type * as Cause from "../Cause.js"
2
2
  import * as Chunk from "../Chunk.js"
3
3
  import * as Clock from "../Clock.js"
4
4
  import * as Context from "../Context.js"
5
+ import * as Cron from "../Cron.js"
5
6
  import * as Duration from "../Duration.js"
6
7
  import type * as Effect from "../Effect.js"
7
8
  import * as Either from "../Either.js"
@@ -419,6 +420,47 @@ export const mapInputEffect = dual<
419
420
  (input) => self.step(now, input, state)
420
421
  )))
421
422
 
423
+ /** @internal */
424
+ export const cron = (expression: string): Schedule.Schedule<never, unknown, [number, number]> => {
425
+ const parsed = Cron.parse(expression)
426
+ return makeWithState<[boolean, [number, number, number]], never, unknown, [number, number]>(
427
+ [true, [Number.MIN_SAFE_INTEGER, 0, 0]],
428
+ (now, _, [initial, previous]) => {
429
+ if (now < previous[0]) {
430
+ return core.succeed([
431
+ [false, previous],
432
+ [previous[1], previous[2]],
433
+ ScheduleDecision.continueWith(Interval.make(previous[1], previous[2]))
434
+ ])
435
+ }
436
+
437
+ if (Either.isLeft(parsed)) {
438
+ return core.die(parsed.left)
439
+ }
440
+
441
+ const cron = parsed.right
442
+ const date = new Date(now)
443
+
444
+ let next: number
445
+ if (initial && Cron.match(cron, date)) {
446
+ next = now
447
+ } else {
448
+ const result = Cron.next(cron, date)
449
+ next = result.getTime()
450
+ }
451
+
452
+ const start = beginningOfMinute(next)
453
+ const end = endOfMinute(next)
454
+ const interval = Interval.make(start, end)
455
+ return core.succeed([
456
+ [false, [next, start, end]],
457
+ [start, end],
458
+ ScheduleDecision.continueWith(interval)
459
+ ])
460
+ }
461
+ )
462
+ }
463
+
422
464
  /** @internal */
423
465
  export const dayOfMonth = (day: number): Schedule.Schedule<never, unknown, number> => {
424
466
  return makeWithState<[number, number], never, unknown, number>(
@@ -1 +1 @@
1
- export const moduleVersion = "2.0.2"
1
+ export const moduleVersion = "2.0.3"