feat: initial mini simple event sourcing for pending application updates

This commit is contained in:
Pierre Martin 2024-02-07 23:48:42 +01:00
parent 76a8909dc1
commit df5bfe4e1a
14 changed files with 207 additions and 14 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules
.env.local
data/

View File

@ -7,3 +7,6 @@ test:
tdd:
bun test --watch
lint:
bun lint

9
domain/AppProjections.ts Normal file
View File

@ -0,0 +1,9 @@
import ApplicationUpdates from "./projections/ApplicationUpdates";
export default class AppProjections {
public readonly ApplicationUpdates = new ApplicationUpdates();
getAll(): DomainProjection[] {
return [this.ApplicationUpdates];
}
}

11
domain/AppQueries.ts Normal file
View File

@ -0,0 +1,11 @@
import AppProjections from "./AppProjections";
import { UpdateDefinition } from "./projections/ApplicationUpdates";
export default class AppQueries {
constructor(private readonly projections: AppProjections) {}
pendingApplicationUpdates(appName?: string): UpdateDefinition[] {
// TODO: Implement filtering by appName
return this.projections.ApplicationUpdates.getPendingUpdates();
}
}

5
domain/DomainEvent.ts Normal file
View File

@ -0,0 +1,5 @@
interface DomainEvent<T> {
readonly type: string;
readonly createdAt: Date;
readonly payload: T;
}

View File

@ -0,0 +1,3 @@
interface DomainProjection {
handle(event: DomainEvent<any>): void;
}

5
domain/EventStore.ts Normal file
View File

@ -0,0 +1,5 @@
export default interface EventStore {
append(event: DomainEvent<any>): void;
subscribe(projection: DomainProjection): void;
replay(): Promise<void>;
}

View File

@ -0,0 +1,14 @@
type ApplicationUpdateStartedPayload = {
id: string;
newVersion: string;
};
export class ApplicationUpdateStarted
implements DomainEvent<ApplicationUpdateStartedPayload>
{
readonly type = "ApplicationUpdateStarted" as const;
constructor(
public readonly payload: ApplicationUpdateStartedPayload,
public readonly createdAt: Date
) {}
}

View File

@ -0,0 +1,18 @@
export type UpdateDefinition = {
id: string;
newVersion: string;
};
export default class ApplicationUpdates implements DomainProjection {
private readonly pendingUpdates: UpdateDefinition[] = [];
handle(event: DomainEvent<any>): void {
if (event.type === "ApplicationUpdateStarted") {
console.log("ApplicationUpdateStarted", event.payload);
this.pendingUpdates.push(event.payload);
}
}
getPendingUpdates(): UpdateDefinition[] {
return this.pendingUpdates;
}
}

View File

@ -0,0 +1,60 @@
import { appendFile } from "node:fs/promises";
import EventStore from "../domain/EventStore";
import { ApplicationUpdateStarted } from "../domain/events/ApplicationUpdateStarted";
export default class FileEventStore implements EventStore {
private handlers: DomainProjection[] = [];
constructor(private readonly filePath: string) {}
append(event: DomainEvent<any>): void {
appendFile(this.filePath, this.serialize(event) + "\n");
this.emit(event);
}
subscribe(projection: DomainProjection) {
this.handlers.push(projection);
}
async replay() {
// TODO Improve this with streaming
console.log("Replaying events from", this.filePath);
const file = Bun.file(this.filePath);
const content = await file.text();
const lines = content.split("\n");
for (const line of lines) {
if (!line) {
continue;
}
console.log("Deserializing", line);
const event = this.deserialize(line);
this.emit(event);
}
console.log("Replaying done");
}
private emit(event: DomainEvent<any>) {
for (const handler of this.handlers) {
handler.handle(event);
}
}
private serialize(event: DomainEvent<any>) {
return JSON.stringify(event);
}
private deserialize(line: string) {
const event = JSON.parse(line);
switch (event.type) {
case "ApplicationUpdateStarted":
return new ApplicationUpdateStarted(
{
id: event.payload.id,
newVersion: event.payload.newVersion,
},
new Date(event.createdAt)
);
default:
throw new Error("Unknown event type" + event.type);
}
}
}

18
joe.ts
View File

@ -3,14 +3,26 @@ import applications from "./routes/applications";
import applicationsUpdate from "./routes/applications.update";
import Layout, { html } from "./ui/Layout";
import DockerHub from "./services/DockerHub";
import FileEventStore from "./infrastructure/FileEventStore";
import AppQueries from "./domain/AppQueries";
import AppProjections from "./domain/AppProjections";
console.log("Hello Pierrot!");
// Domain
const eventStore = new FileEventStore("./data/events.jsonl");
const projections = new AppProjections();
const queries = new AppQueries(projections);
projections.getAll().forEach((projection) => {
eventStore.subscribe(projection);
});
await eventStore.replay();
// External services
const caprover = new Caprover(
eventStore,
process.env.CAPTAIN_DOMAIN,
process.env.CAPTAIN_PASSWORD
);
const dockerHub = new DockerHub();
const server = Bun.serve({
@ -29,7 +41,7 @@ const server = Bun.serve({
case "/applications":
return applications(req, caprover);
case "/applications/update":
return applicationsUpdate(req, caprover, dockerHub);
return applicationsUpdate(req, caprover, dockerHub, queries);
default:
return new Response("Not Found", { status: 404 });
}

View File

@ -1,3 +1,5 @@
import AppQueries from "../domain/AppQueries";
import { UpdateDefinition } from "../domain/projections/ApplicationUpdates";
import Caprover, { Application } from "../services/Caprover";
import DockerHub from "../services/DockerHub";
import Layout, { html } from "../ui/Layout";
@ -19,10 +21,33 @@ const UpdateForm = (application: Application, latestVersions: string[]) => {
</form>`;
};
const Page = (application: Application, latestVersions: string[]) => {
const PendingUpdates = (pendingUpdates: UpdateDefinition[]) => {
if (pendingUpdates.length === 0) {
return "";
}
return html`<div>
<h2>Pending updates</h2>
<ul>
${pendingUpdates
.map((update) => {
return html`<li>${update.newVersion}</li>`;
})
.join("")}
</ul>
</div>`;
};
const Page = (
application: Application,
latestVersions: string[],
pendingUpdates: UpdateDefinition[]
) => {
return html`<div>
<h1>Updating ${application.name}</h1>
${PendingUpdates(pendingUpdates)}
<dl>
<dt>Last deployment</dt>
<dd>${application.lastDeployedAt.toLocaleString("fr-FR")}</dd>
@ -49,12 +74,15 @@ const Page = (application: Application, latestVersions: string[]) => {
export default async (
req: Request,
caprover: Caprover,
dockerHub: DockerHub
dockerHub: DockerHub,
queries: AppQueries
): Promise<Response> => {
if (req.method === "POST") {
const body = await req.formData();
console.log("TODO Implement application update", [...body.entries()]);
throw new Error("Not implemented");
caprover.updateApplication(
body.get("appName") as string,
body.get("version") as string
);
}
const applications = await caprover.getApps();
@ -76,7 +104,12 @@ export default async (
appToUpdate.dockerImage!.name
);
return new Response(Layout(Page(appToUpdate, latestVersions)), {
const pendingUpdates = queries.pendingApplicationUpdates(appToUpdate.name);
return new Response(
Layout(Page(appToUpdate, latestVersions, pendingUpdates)),
{
headers: { "Content-Type": "text/html" },
});
}
);
};

View File

@ -1,3 +1,6 @@
import EventStore from "../domain/EventStore";
import { ApplicationUpdateStarted } from "../domain/events/ApplicationUpdateStarted";
type TODO_TypeDefinition = any;
class Application {
@ -52,12 +55,18 @@ class Application {
class Caprover {
private authToken: string = "";
private readonly apiUrl: string;
private readonly eventStore: EventStore;
constructor(readonly domain?: string, private readonly password?: string) {
constructor(
eventStore: EventStore,
readonly domain?: string,
private readonly password?: string
) {
if (!domain || !password) {
throw new Error("Missing domain or password");
}
this.apiUrl = `https://${domain}/api/v2`;
this.eventStore = eventStore;
}
async getApps(): Promise<Application[]> {
@ -70,6 +79,16 @@ class Caprover {
);
}
async updateApplication(appName: string, version: string) {
console.log("TODO: Implement remote call", appName, version); // TODO
this.eventStore.append(
new ApplicationUpdateStarted(
{ id: appName, newVersion: version },
new Date()
)
);
}
private async authenticate() {
if (this.authToken) {
return;

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"target": "ES2022",
"module": "ES2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"types": ["bun-types"]