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 * as O from "fp-ts/lib/Option";
|
||||||
import { createTransport } from "nodemailer";
|
import { createTransport } from "nodemailer";
|
||||||
import { toError } from "fp-ts/lib/Either";
|
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 { ImapFlow, type FetchMessageObject, type FetchQueryObject, type MailboxLockObject } from "imapflow";
|
||||||
import * as IO from "fp-ts/lib/IO";
|
import * as IO from "fp-ts/lib/IO";
|
||||||
import * as T from "fp-ts/lib/Task";
|
import * as T from "fp-ts/lib/Task";
|
||||||
import { ConsoleLogger } from "../util";
|
import { ConsoleLogger, type Logger } from "../util";
|
||||||
|
|
||||||
interface ImapClientI {
|
interface ImapClientI {
|
||||||
fetchAll: (range: string, options: FetchQueryObject) => Promise<FetchMessageObject[]>;
|
fetchAll: (range: string, options: FetchQueryObject) => Promise<FetchMessageObject[]>;
|
||||||
|
@ -145,9 +145,10 @@ type FindEmailUidInInbox = (
|
||||||
imap: ImapClientI,
|
imap: ImapClientI,
|
||||||
equalsEmail: (message: FetchMessageObject) => boolean,
|
equalsEmail: (message: FetchMessageObject) => boolean,
|
||||||
retries: number,
|
retries: number,
|
||||||
pollIntervalMs: number
|
pollIntervalMs: number,
|
||||||
|
logger?: Logger
|
||||||
) => TE.TaskEither<Error, number>;
|
) => TE.TaskEither<Error, number>;
|
||||||
const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs) =>
|
const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs, logger = ConsoleLogger) =>
|
||||||
pipe(
|
pipe(
|
||||||
fetchMessages(imap),
|
fetchMessages(imap),
|
||||||
TE.flatMap((messages) => {
|
TE.flatMap((messages) => {
|
||||||
|
@ -160,11 +161,16 @@ const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, po
|
||||||
TE.fold(
|
TE.fold(
|
||||||
(e) =>
|
(e) =>
|
||||||
pipe(
|
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(() => (retries === 0 ? TE.left(e) : T.delay(pollIntervalMs)(TE.right(null)))),
|
||||||
TE.chain(() => findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs))
|
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.
|
// cleanup.
|
||||||
TE.bind("deleted", ({ imap, uid, mailboxLock }) =>
|
TE.bind("deleted", ({ imap, uid, mailboxLock }) =>
|
||||||
TE.tryCatch(
|
TE.tryCatch(() => imap.messageDelete([uid]), ToErrorWithLock(mailboxLock))
|
||||||
// () => imap.messageDelete([uid], { uid: true }),
|
|
||||||
() => imap.messageDelete([uid]),
|
|
||||||
ToErrorWithLock(mailboxLock)
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
TE.fold(
|
TE.fold(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { ConsoleLogger, type Logger } from "../util";
|
||||||
export interface Job {
|
export interface Job {
|
||||||
id: string;
|
id: string;
|
||||||
toString: () => string;
|
toString: () => string;
|
||||||
execute: <TResult>() => TE.TaskEither<Error, TResult>;
|
execute: () => TE.TaskEither<Error, boolean>;
|
||||||
schedule: Schedule;
|
schedule: Schedule;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,13 @@ export type JobsTableRow = {
|
||||||
retries: number;
|
retries: number;
|
||||||
};
|
};
|
||||||
export type JobsTable = ReadonlyMap<string, JobsTableRow>;
|
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> = {
|
export const jobsTableRowMagma: Magma<JobsTableRow> = {
|
||||||
concat: (a: JobsTableRow, b: JobsTableRow): JobsTableRow => {
|
concat: (a: JobsTableRow, b: JobsTableRow): JobsTableRow => {
|
||||||
|
@ -94,7 +101,7 @@ export const formatJobResults = <TResult>(
|
||||||
)
|
)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const successMessages = right.map(([job, val]) => job.toString() + " | (success) :) | " + val).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 = (
|
export const perform = (
|
||||||
|
@ -136,7 +143,7 @@ export const perform = (
|
||||||
RA.wilt(T.ApplicativePar)(identity),
|
RA.wilt(T.ApplicativePar)(identity),
|
||||||
T.tap(({ left }) =>
|
T.tap(({ left }) =>
|
||||||
pipe(
|
pipe(
|
||||||
left
|
left.length
|
||||||
? logger.error("Encountered publishing errors: " + left.toString())
|
? logger.error("Encountered publishing errors: " + left.toString())
|
||||||
: logger.log("Published successfully"),
|
: logger.log("Published successfully"),
|
||||||
T.fromIO
|
T.fromIO
|
||||||
|
@ -155,7 +162,7 @@ export const perform = (
|
||||||
return [job.id, { retries: 1, scheduled: now }];
|
return [job.id, { retries: 1, scheduled: now }];
|
||||||
}
|
}
|
||||||
if (row.retries >= job.maxRetries) {
|
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 [job.id, { retries: 0, scheduled: nextSchedule(job.schedule, now) }] as const;
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import * as TE from "fp-ts/lib/TaskEither";
|
import * as TE from "fp-ts/lib/TaskEither";
|
||||||
import * as O from "fp-ts/lib/Option";
|
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 type { D } from "../util";
|
||||||
|
import { perform } from "./email";
|
||||||
|
|
||||||
export enum TestType {
|
export enum TestType {
|
||||||
EMAIL = "email",
|
EMAIL = "email",
|
||||||
|
@ -22,10 +23,16 @@ export interface Test {
|
||||||
schedule: Schedule;
|
schedule: Schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Testable<TJob extends TestJob> = (job: TJob) => TE.TaskEither<Error, boolean>;
|
|
||||||
|
|
||||||
export const parseTestType = (testType: string): O.Option<TestType> =>
|
export const parseTestType = (testType: string): O.Option<TestType> =>
|
||||||
Object.values(TestType).includes(testType as TestType) ? O.some(testType as TestType) : O.none;
|
Object.values(TestType).includes(testType as TestType) ? O.some(testType as TestType) : O.none;
|
||||||
|
|
||||||
export const nextSchedule = (schedule: Schedule, date: Date) =>
|
export const nextSchedule = (schedule: Schedule, date: Date) =>
|
||||||
new Date(date.getTime() + schedule.every + Math.random() * schedule.jitter);
|
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 type { Publisher } from "./publisher";
|
||||||
import { readFileSync } from "fs";
|
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 {
|
export interface Config {
|
||||||
result_publishers: Publisher[];
|
result_publishers: Publisher[];
|
||||||
|
@ -10,9 +12,28 @@ export interface Config {
|
||||||
tests: Test[];
|
tests: Test[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const readConfig =
|
export const transformDurations = (obj: any): E.Either<string, any> => {
|
||||||
(filePath: string): IO.IO<Config> =>
|
const transform = (o: any): E.Either<string, any> => {
|
||||||
() => {
|
const entries = Object.entries(o);
|
||||||
const confStr = readFileSync(filePath, "utf-8");
|
|
||||||
return JSON.parse(confStr);
|
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 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 { pipe } from "fp-ts/lib/function";
|
||||||
import { toError } from "fp-ts/lib/Either";
|
|
||||||
import { readConfig } from "./config";
|
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.fromEither(parseArgs(Bun.argv)),
|
||||||
TE.bindTo("args"),
|
TE.bindTo("args"),
|
||||||
TE.bind("config", ({ args }) => TE.fromIO(readConfig(args.testsFile))),
|
TE.bind("now", () => TE.fromIO(() => new Date())),
|
||||||
TE.map(({ config }) => TE.tryCatch(() => {}, toError))
|
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 * as TE from "fp-ts/TaskEither";
|
||||||
import { publishDiscord, type DiscordPost } from "./discord";
|
import { publishDiscord, type DiscordPost } from "./discord";
|
||||||
import { publishNtfy, type NtfyPost } from "./ntfy";
|
import { publishNtfy, type NtfyPost } from "./ntfy";
|
||||||
|
import type { Logger } from "../util";
|
||||||
|
import { pipe } from "fp-ts/lib/function";
|
||||||
|
|
||||||
export enum PublisherType {
|
export enum PublisherType {
|
||||||
DISCORD = "discord",
|
DISCORD = "discord",
|
||||||
NTFY = "ntfy"
|
NTFY = "ntfy",
|
||||||
|
CONSOLE = "console"
|
||||||
}
|
}
|
||||||
export type PublisherPost = DiscordPost | NtfyPost;
|
export type PublisherPost = DiscordPost | NtfyPost | Logger;
|
||||||
|
|
||||||
export interface Publisher {
|
export interface Publisher {
|
||||||
type: PublisherType;
|
publisherType: PublisherType;
|
||||||
post: PublisherPost;
|
post: PublisherPost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publish = (publisher: Publisher, message: string): TE.TaskEither<Error, number> => {
|
export const publish = (publisher: Publisher): ((message: string) => TE.TaskEither<Error, number>) => {
|
||||||
switch (publisher.type) {
|
switch (publisher.publisherType) {
|
||||||
case PublisherType.DISCORD:
|
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:
|
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:
|
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 {
|
export interface Logger {
|
||||||
log: (message: string) => IO<void>;
|
log: (message: string) => IO<void>;
|
||||||
error: (message: string) => IO<void>;
|
error: (message: string) => IO<void>;
|
||||||
|
addPrefix: (prefix: string) => Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConsoleLogger: Logger = {
|
export class ConsoleLoggerI implements Logger {
|
||||||
log: (message: string) => () => console.log(new Date(), "[INFO]", message),
|
constructor(private readonly prefix = "") {}
|
||||||
error: (message: string) => () => console.error(new Date(), "[ERROR]", message)
|
|
||||||
};
|
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