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