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); | ||||||
|  | 
 | ||||||
|  |   return new Response( | ||||||
|  |     Layout(Page(appToUpdate, latestVersions, pendingUpdates)), | ||||||
|  |     { | ||||||
|       headers: { "Content-Type": "text/html" }, |       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