diff --git a/src/canary/email.ts b/src/canary/email.ts index f20da8d..ecb13c6 100644 --- a/src/canary/email.ts +++ b/src/canary/email.ts @@ -3,11 +3,11 @@ import * as TE from "fp-ts/lib/TaskEither"; import * as O from "fp-ts/lib/Option"; import { createTransport } from "nodemailer"; import { toError } from "fp-ts/lib/Either"; -import { pipe } from "fp-ts/lib/function"; +import { flow, pipe } from "fp-ts/lib/function"; import { ImapFlow, type FetchMessageObject, type FetchQueryObject, type MailboxLockObject } from "imapflow"; import * as IO from "fp-ts/lib/IO"; import * as T from "fp-ts/lib/Task"; -import { ConsoleLogger } from "../util"; +import { ConsoleLogger, type Logger } from "../util"; interface ImapClientI { fetchAll: (range: string, options: FetchQueryObject) => Promise; @@ -145,9 +145,10 @@ type FindEmailUidInInbox = ( imap: ImapClientI, equalsEmail: (message: FetchMessageObject) => boolean, retries: number, - pollIntervalMs: number + pollIntervalMs: number, + logger?: Logger ) => TE.TaskEither; -const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs) => +const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs, logger = ConsoleLogger) => pipe( fetchMessages(imap), TE.flatMap((messages) => { @@ -160,11 +161,16 @@ const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, po TE.fold( (e) => pipe( - TE.fromIO(ConsoleLogger.log(`failed; ${retries} retries left.`)), + TE.fromIO(logger.log(`email failed; ${retries} retries left.`)), TE.chain(() => (retries === 0 ? TE.left(e) : T.delay(pollIntervalMs)(TE.right(null)))), TE.chain(() => findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs)) ), - TE.of + (s) => + pipe( + s, + TE.of, + TE.tap(() => TE.fromIO(logger.log("Email succeeded"))) + ) ) ); @@ -207,11 +213,7 @@ export const perform = ( ), // cleanup. TE.bind("deleted", ({ imap, uid, mailboxLock }) => - TE.tryCatch( - // () => imap.messageDelete([uid], { uid: true }), - () => imap.messageDelete([uid]), - ToErrorWithLock(mailboxLock) - ) + TE.tryCatch(() => imap.messageDelete([uid]), ToErrorWithLock(mailboxLock)) ), TE.fold( (e) => { diff --git a/src/canary/scheduler.ts b/src/canary/scheduler.ts index 2975991..797e701 100644 --- a/src/canary/scheduler.ts +++ b/src/canary/scheduler.ts @@ -14,7 +14,7 @@ import { ConsoleLogger, type Logger } from "../util"; export interface Job { id: string; toString: () => string; - execute: () => TE.TaskEither; + execute: () => TE.TaskEither; schedule: Schedule; maxRetries: number; } @@ -52,6 +52,13 @@ export type JobsTableRow = { retries: number; }; export type JobsTable = ReadonlyMap; +export const constructJobsTable = (jobs: ReadonlyArray, now: Date): JobsTable => { + return pipe( + jobs, + RA.map((job) => [job.id, { retries: 0, scheduled: now }] as readonly [string, JobsTableRow]), + RM.fromFoldable(S.Eq, jobsTableRowMagma, RA.Foldable) + ); +}; export const jobsTableRowMagma: Magma = { concat: (a: JobsTableRow, b: JobsTableRow): JobsTableRow => { @@ -94,7 +101,7 @@ export const formatJobResults = ( ) .join("\n"); const successMessages = right.map(([job, val]) => job.toString() + " | (success) :) | " + val).join("\n"); - return `FAILURES:\n${failureMessages}\nRETRIES:\n${retryMessages}\nSUCCESSES:\n${successMessages}`; + return `FAILURES:\n${failureMessages}\n\nRETRIES:\n${retryMessages}\n\nSUCCESSES:\n${successMessages}`; }; export const perform = ( @@ -136,7 +143,7 @@ export const perform = ( RA.wilt(T.ApplicativePar)(identity), T.tap(({ left }) => pipe( - left + left.length ? logger.error("Encountered publishing errors: " + left.toString()) : logger.log("Published successfully"), T.fromIO @@ -155,7 +162,7 @@ export const perform = ( return [job.id, { retries: 1, scheduled: now }]; } if (row.retries >= job.maxRetries) { - logger.error(`Hit max retries; scheduling again ;-; ${job.toString()}`); + logger.error(`Hit max retries for; scheduling again ;-; ${job.toString()}`); return [job.id, { retries: 0, scheduled: nextSchedule(job.schedule, now) }] as const; } return [ diff --git a/src/canary/test.ts b/src/canary/test.ts index d60d2ec..89f9fce 100644 --- a/src/canary/test.ts +++ b/src/canary/test.ts @@ -1,7 +1,8 @@ import * as TE from "fp-ts/lib/TaskEither"; import * as O from "fp-ts/lib/Option"; -import type { TestJob } from "./job"; +import type { EmailJob, TestJob } from "./job"; import type { D } from "../util"; +import { perform } from "./email"; export enum TestType { EMAIL = "email", @@ -22,10 +23,16 @@ export interface Test { schedule: Schedule; } -export type Testable = (job: TJob) => TE.TaskEither; - export const parseTestType = (testType: string): O.Option => Object.values(TestType).includes(testType as TestType) ? O.some(testType as TestType) : O.none; export const nextSchedule = (schedule: Schedule, date: Date) => new Date(date.getTime() + schedule.every + Math.random() * schedule.jitter); + +export const executorFor = (type: TestType, job: TestJob): (() => TE.TaskEither) => { + switch (type) { + case TestType.EMAIL: + return () => perform(job as EmailJob); + } + return () => TE.right(true); +}; diff --git a/src/config.ts b/src/config.ts index d4527d9..46d572b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,9 @@ -import * as IO from "fp-ts/IO"; +import * as E from "fp-ts/Either"; import type { Publisher } from "./publisher"; import { readFileSync } from "fs"; -import type { Test } from "./canary"; +import { TestType, type Test, type TestJob } from "./canary"; +import * as D from "./util/duration"; +import { pipe } from "fp-ts/lib/function"; export interface Config { result_publishers: Publisher[]; @@ -10,9 +12,28 @@ export interface Config { tests: Test[]; } -export const readConfig = - (filePath: string): IO.IO => - () => { - const confStr = readFileSync(filePath, "utf-8"); - return JSON.parse(confStr); +export const transformDurations = (obj: any): E.Either => { + const transform = (o: any): E.Either => { + const entries = Object.entries(o); + + for (let [key, value] of entries) { + if (key === "duration" && typeof value === "string") { + return D.parse(value); + } else if (typeof value === "object" && value !== null) { + const result = transform(value); + if (E.isLeft(result)) { + return result; + } else { + o[key] = result.right; + } + } + } + + return E.right(o); }; + + return transform(obj); +}; + +export const readConfig = (filePath: string): E.Either => + pipe(readFileSync(filePath, "utf-8"), JSON.parse, transformDurations) as E.Either; diff --git a/src/index.ts b/src/index.ts index c7d8438..4841e87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,51 @@ import * as TE from "fp-ts/lib/TaskEither"; +import * as O from "fp-ts/lib/Option"; +import * as RA from "fp-ts/ReadonlyArray"; import { pipe } from "fp-ts/lib/function"; -import { toError } from "fp-ts/lib/Either"; import { readConfig } from "./config"; -import { parseArgs } from "./args"; +import { parseArgs, type Args } from "./args"; +import { executorFor, type Test, type TestType } from "./canary"; +import { constructJobsTable, perform, type Job } from "./canary/scheduler"; +import { publish, PublisherType } from "./publisher"; +import { ConsoleLogger } from "./util"; -const main: TE.TaskEither = pipe( +const testFilters = (args: Args): ReadonlyArray<(test: Test) => boolean> => + ( + [ + [args.testType, (testType: TestType) => (test: Test) => test.type === testType], + [args.testName, (testName: string) => (test: Test) => testName === test.name] + ] as ReadonlyArray<[O.Option, (t: any) => (test: Test) => boolean]> + ) + .map(([o, pred]) => + pipe( + o, + O.map((val) => pred(val)) + ) + ) + .filter(O.isSome) + .map(({ value }) => value); + +const main: TE.TaskEither = pipe( TE.fromEither(parseArgs(Bun.argv)), TE.bindTo("args"), - TE.bind("config", ({ args }) => TE.fromIO(readConfig(args.testsFile))), - TE.map(({ config }) => TE.tryCatch(() => {}, toError)) + TE.bind("now", () => TE.fromIO(() => new Date())), + TE.bind("config", ({ args }) => TE.fromEither(readConfig(args.testsFile))), + TE.flatMap(({ config, args, now }) => { + const filters = testFilters(args); + const jobs = config.tests + .filter((test) => filters.every((filter) => filter(test))) + .map((test, i): Job => { + const toString = () => test.name; + const id = i.toString(); + const schedule = test.schedule; + const maxRetries = 3; + const execute = executorFor(test.type, test.job); + return { toString, id, schedule, maxRetries, execute }; + }); + const jobsTable = constructJobsTable(jobs, now); + const publishers = pipe([{ publisherType: PublisherType.CONSOLE, post: ConsoleLogger }] as const, RA.map(publish)); + + return pipe(perform(jobsTable, jobs, publishers, new Date()), TE.fromTask); + }) ); -main(); +main().then(() => process.exit()); diff --git a/src/publisher/index.ts b/src/publisher/index.ts index 5d17b5b..77633b3 100644 --- a/src/publisher/index.ts +++ b/src/publisher/index.ts @@ -2,25 +2,35 @@ import { toError } from "fp-ts/lib/Either"; import * as TE from "fp-ts/TaskEither"; import { publishDiscord, type DiscordPost } from "./discord"; import { publishNtfy, type NtfyPost } from "./ntfy"; +import type { Logger } from "../util"; +import { pipe } from "fp-ts/lib/function"; export enum PublisherType { DISCORD = "discord", - NTFY = "ntfy" + NTFY = "ntfy", + CONSOLE = "console" } -export type PublisherPost = DiscordPost | NtfyPost; +export type PublisherPost = DiscordPost | NtfyPost | Logger; export interface Publisher { - type: PublisherType; + publisherType: PublisherType; post: PublisherPost; } -export const publish = (publisher: Publisher, message: string): TE.TaskEither => { - switch (publisher.type) { +export const publish = (publisher: Publisher): ((message: string) => TE.TaskEither) => { + switch (publisher.publisherType) { case PublisherType.DISCORD: - return TE.tryCatch(() => publishDiscord(publisher.post as DiscordPost, message), toError); + return (message) => TE.tryCatch(() => publishDiscord(publisher.post as DiscordPost, message), toError); case PublisherType.NTFY: - return TE.tryCatch(() => publishNtfy(publisher.post as NtfyPost, message), toError); + return (message) => TE.tryCatch(() => publishNtfy(publisher.post as NtfyPost, message), toError); + case PublisherType.CONSOLE: + return (message) => + pipe( + (publisher.post as Logger).log("\n>=====<\n" + message + "\n>=====<"), + TE.fromIO, + TE.map(() => 200) + ); default: - return TE.left(new Error("unknown publisher type: " + publisher.type)); + return (_message) => TE.left(new Error("unknown publisher type: " + publisher.publisherType)); } }; diff --git a/src/util/logger.ts b/src/util/logger.ts index 3215844..8fe8d67 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -3,9 +3,21 @@ import type { IO } from "fp-ts/lib/IO"; export interface Logger { log: (message: string) => IO; error: (message: string) => IO; + addPrefix: (prefix: string) => Logger; } -export const ConsoleLogger: Logger = { - log: (message: string) => () => console.log(new Date(), "[INFO]", message), - error: (message: string) => () => console.error(new Date(), "[ERROR]", message) -}; +export class ConsoleLoggerI implements Logger { + constructor(private readonly prefix = "") {} + + public log(message: string) { + return () => console.log(new Date(), "[INFO]", this.prefix, message); + } + public error(message: string) { + return () => console.error(new Date(), "[ERROR]", this.prefix, message); + } + public addPrefix(prefix: string): ConsoleLoggerI { + return new ConsoleLoggerI(this.prefix + prefix); + } +} + +export const ConsoleLogger = new ConsoleLoggerI();