add a console publisher
This commit is contained in:
		
							parent
							
								
									8f584b5b05
								
							
						
					
					
						commit
						18456c13d4
					
				|  | @ -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<FetchMessageObject[]>; | ||||
|  | @ -145,9 +145,10 @@ type FindEmailUidInInbox = ( | |||
|   imap: ImapClientI, | ||||
|   equalsEmail: (message: FetchMessageObject) => boolean, | ||||
|   retries: number, | ||||
|   pollIntervalMs: number | ||||
|   pollIntervalMs: number, | ||||
|   logger?: Logger | ||||
| ) => TE.TaskEither<Error, number>; | ||||
| 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) => { | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ import { ConsoleLogger, type Logger } from "../util"; | |||
| export interface Job { | ||||
|   id: string; | ||||
|   toString: () => string; | ||||
|   execute: <TResult>() => TE.TaskEither<Error, TResult>; | ||||
|   execute: () => TE.TaskEither<Error, boolean>; | ||||
|   schedule: Schedule; | ||||
|   maxRetries: number; | ||||
| } | ||||
|  | @ -52,6 +52,13 @@ export type JobsTableRow = { | |||
|   retries: number; | ||||
| }; | ||||
| export type JobsTable = ReadonlyMap<string, JobsTableRow>; | ||||
| export const constructJobsTable = (jobs: ReadonlyArray<Job>, 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<JobsTableRow> = { | ||||
|   concat: (a: JobsTableRow, b: JobsTableRow): JobsTableRow => { | ||||
|  | @ -94,7 +101,7 @@ export const formatJobResults = <TResult>( | |||
|     ) | ||||
|     .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 [ | ||||
|  |  | |||
|  | @ -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<TJob extends TestJob> = (job: TJob) => TE.TaskEither<Error, boolean>; | ||||
| 
 | ||||
| export const parseTestType = (testType: string): O.Option<TestType> => | ||||
|   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<Error, boolean>) => { | ||||
|   switch (type) { | ||||
|     case TestType.EMAIL: | ||||
|       return () => perform(job as EmailJob); | ||||
|   } | ||||
|   return () => TE.right(true); | ||||
| }; | ||||
|  |  | |||
|  | @ -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<Config> => | ||||
|   () => { | ||||
|     const confStr = readFileSync(filePath, "utf-8"); | ||||
|     return JSON.parse(confStr); | ||||
| export const transformDurations = (obj: any): E.Either<string, any> => { | ||||
|   const transform = (o: any): E.Either<string, any> => { | ||||
|     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<string, Config> => | ||||
|   pipe(readFileSync(filePath, "utf-8"), JSON.parse, transformDurations) as E.Either<string, Config>; | ||||
|  |  | |||
							
								
								
									
										50
									
								
								src/index.ts
								
								
								
								
							
							
						
						
									
										50
									
								
								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<Error, void> = 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<any>, (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<string | Error, any> = 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()); | ||||
|  |  | |||
|  | @ -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<Error, number> => { | ||||
|   switch (publisher.type) { | ||||
| export const publish = (publisher: Publisher): ((message: string) => TE.TaskEither<Error, number>) => { | ||||
|   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)); | ||||
|   } | ||||
| }; | ||||
|  |  | |||
|  | @ -3,9 +3,21 @@ import type { IO } from "fp-ts/lib/IO"; | |||
| export interface Logger { | ||||
|   log: (message: string) => IO<void>; | ||||
|   error: (message: string) => IO<void>; | ||||
|   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(); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue