feat: initial mini simple event sourcing for pending application updates
This commit is contained in:
parent
76a8909dc1
commit
df5bfe4e1a
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
.env.local
|
.env.local
|
||||||
|
data/
|
||||||
|
3
Makefile
3
Makefile
@ -7,3 +7,6 @@ test:
|
|||||||
|
|
||||||
tdd:
|
tdd:
|
||||||
bun test --watch
|
bun test --watch
|
||||||
|
|
||||||
|
lint:
|
||||||
|
bun lint
|
9
domain/AppProjections.ts
Normal file
9
domain/AppProjections.ts
Normal 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
11
domain/AppQueries.ts
Normal 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
5
domain/DomainEvent.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
interface DomainEvent<T> {
|
||||||
|
readonly type: string;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly payload: T;
|
||||||
|
}
|
3
domain/DomainProjection.ts
Normal file
3
domain/DomainProjection.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
interface DomainProjection {
|
||||||
|
handle(event: DomainEvent<any>): void;
|
||||||
|
}
|
5
domain/EventStore.ts
Normal file
5
domain/EventStore.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default interface EventStore {
|
||||||
|
append(event: DomainEvent<any>): void;
|
||||||
|
subscribe(projection: DomainProjection): void;
|
||||||
|
replay(): Promise<void>;
|
||||||
|
}
|
14
domain/events/ApplicationUpdateStarted.ts
Normal file
14
domain/events/ApplicationUpdateStarted.ts
Normal 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
|
||||||
|
) {}
|
||||||
|
}
|
18
domain/projections/ApplicationUpdates.ts
Normal file
18
domain/projections/ApplicationUpdates.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
60
infrastructure/FileEventStore.ts
Normal file
60
infrastructure/FileEventStore.ts
Normal 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
18
joe.ts
@ -3,14 +3,26 @@ import applications from "./routes/applications";
|
|||||||
import applicationsUpdate from "./routes/applications.update";
|
import applicationsUpdate from "./routes/applications.update";
|
||||||
import Layout, { html } from "./ui/Layout";
|
import Layout, { html } from "./ui/Layout";
|
||||||
import DockerHub from "./services/DockerHub";
|
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(
|
const caprover = new Caprover(
|
||||||
|
eventStore,
|
||||||
process.env.CAPTAIN_DOMAIN,
|
process.env.CAPTAIN_DOMAIN,
|
||||||
process.env.CAPTAIN_PASSWORD
|
process.env.CAPTAIN_PASSWORD
|
||||||
);
|
);
|
||||||
|
|
||||||
const dockerHub = new DockerHub();
|
const dockerHub = new DockerHub();
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
@ -29,7 +41,7 @@ const server = Bun.serve({
|
|||||||
case "/applications":
|
case "/applications":
|
||||||
return applications(req, caprover);
|
return applications(req, caprover);
|
||||||
case "/applications/update":
|
case "/applications/update":
|
||||||
return applicationsUpdate(req, caprover, dockerHub);
|
return applicationsUpdate(req, caprover, dockerHub, queries);
|
||||||
default:
|
default:
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import AppQueries from "../domain/AppQueries";
|
||||||
|
import { UpdateDefinition } from "../domain/projections/ApplicationUpdates";
|
||||||
import Caprover, { Application } from "../services/Caprover";
|
import Caprover, { Application } from "../services/Caprover";
|
||||||
import DockerHub from "../services/DockerHub";
|
import DockerHub from "../services/DockerHub";
|
||||||
import Layout, { html } from "../ui/Layout";
|
import Layout, { html } from "../ui/Layout";
|
||||||
@ -19,10 +21,33 @@ const UpdateForm = (application: Application, latestVersions: string[]) => {
|
|||||||
</form>`;
|
</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>
|
return html`<div>
|
||||||
<h1>Updating ${application.name}</h1>
|
<h1>Updating ${application.name}</h1>
|
||||||
|
|
||||||
|
${PendingUpdates(pendingUpdates)}
|
||||||
|
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Last deployment</dt>
|
<dt>Last deployment</dt>
|
||||||
<dd>${application.lastDeployedAt.toLocaleString("fr-FR")}</dd>
|
<dd>${application.lastDeployedAt.toLocaleString("fr-FR")}</dd>
|
||||||
@ -49,12 +74,15 @@ const Page = (application: Application, latestVersions: string[]) => {
|
|||||||
export default async (
|
export default async (
|
||||||
req: Request,
|
req: Request,
|
||||||
caprover: Caprover,
|
caprover: Caprover,
|
||||||
dockerHub: DockerHub
|
dockerHub: DockerHub,
|
||||||
|
queries: AppQueries
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const body = await req.formData();
|
const body = await req.formData();
|
||||||
console.log("TODO Implement application update", [...body.entries()]);
|
caprover.updateApplication(
|
||||||
throw new Error("Not implemented");
|
body.get("appName") as string,
|
||||||
|
body.get("version") as string
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const applications = await caprover.getApps();
|
const applications = await caprover.getApps();
|
||||||
@ -76,7 +104,12 @@ export default async (
|
|||||||
appToUpdate.dockerImage!.name
|
appToUpdate.dockerImage!.name
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(Layout(Page(appToUpdate, latestVersions)), {
|
const pendingUpdates = queries.pendingApplicationUpdates(appToUpdate.name);
|
||||||
headers: { "Content-Type": "text/html" },
|
|
||||||
});
|
return new Response(
|
||||||
|
Layout(Page(appToUpdate, latestVersions, pendingUpdates)),
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import EventStore from "../domain/EventStore";
|
||||||
|
import { ApplicationUpdateStarted } from "../domain/events/ApplicationUpdateStarted";
|
||||||
|
|
||||||
type TODO_TypeDefinition = any;
|
type TODO_TypeDefinition = any;
|
||||||
|
|
||||||
class Application {
|
class Application {
|
||||||
@ -52,12 +55,18 @@ class Application {
|
|||||||
class Caprover {
|
class Caprover {
|
||||||
private authToken: string = "";
|
private authToken: string = "";
|
||||||
private readonly apiUrl: 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) {
|
if (!domain || !password) {
|
||||||
throw new Error("Missing domain or password");
|
throw new Error("Missing domain or password");
|
||||||
}
|
}
|
||||||
this.apiUrl = `https://${domain}/api/v2`;
|
this.apiUrl = `https://${domain}/api/v2`;
|
||||||
|
this.eventStore = eventStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getApps(): Promise<Application[]> {
|
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() {
|
private async authenticate() {
|
||||||
if (this.authToken) {
|
if (this.authToken) {
|
||||||
return;
|
return;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2017",
|
"target": "ES2022",
|
||||||
"module": "commonjs",
|
"module": "ES2022",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
|
Loading…
Reference in New Issue
Block a user