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 | ||||
| 
 | ||||
| .env.local | ||||
| data/ | ||||
|  | ||||
							
								
								
									
										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 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 }); | ||||
|     } | ||||
|  | ||||
| @ -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" }, | ||||
|   }); | ||||
|     } | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "es2017", | ||||
|     "module": "commonjs", | ||||
|     "target": "ES2022", | ||||
|     "module": "ES2022", | ||||
|     "forceConsistentCasingInFileNames": true, | ||||
|     "strict": true, | ||||
|     "types": ["bun-types"] | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user