diff --git a/package-lock.json b/package-lock.json index 39307c5..11c8d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "class-validator": "^0.14.2", "express": "^4.18.2", "fluture": "^14.0.0", + "luxon": "^3.7.2", "minio": "^8.0.5", "mongodb": "^5.4.0", "nodemailer": "^7.0.5", @@ -23,6 +24,7 @@ }, "devDependencies": { "@types/express": "^4.17.21", + "@types/luxon": "^3.7.1", "@types/node": "^24.3.0", "@types/nodemailer": "^7.0.1", "@types/pg": "^8.6.6", @@ -2357,6 +2359,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3477,6 +3486,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 670f21b..8ef9749 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "main": "dist/index.js", "scripts": { "build": "tsc", + "watch": "tsc --watch", "start": "tsx src/index.ts", "test": "tsx tests/unit/main.ts", "format": "prettier --write .", @@ -14,6 +15,7 @@ "class-validator": "^0.14.2", "express": "^4.18.2", "fluture": "^14.0.0", + "luxon": "^3.7.2", "minio": "^8.0.5", "mongodb": "^5.4.0", "nodemailer": "^7.0.5", @@ -29,6 +31,7 @@ }, "devDependencies": { "@types/express": "^4.17.21", + "@types/luxon": "^3.7.1", "@types/node": "^24.3.0", "@types/nodemailer": "^7.0.1", "@types/pg": "^8.6.6", diff --git a/src/app/commandHandler.ts b/src/app/commandHandler.ts new file mode 100644 index 0000000..3d00628 --- /dev/null +++ b/src/app/commandHandler.ts @@ -0,0 +1,59 @@ +export { handleCommand }; + +import { Response } from '@/lib/router'; +import { EventStore } from '@/lib/eventSourcing/eventStore'; +import { Decoder, decode } from '@/lib/json/decoder'; +import * as express from 'express'; +import * as router from '@/lib/router'; +import { Future } from '@/lib/Future'; +import { Result, Failure } from '@/lib/Result'; + +type Projections = {}; +type Services = {}; + +type CommandController = { + decoder: Decoder; + handler: (v: { + command: Command; + store: EventStore; + projections: Projections; + services: Services; + }) => Future; +}; + +function handleCommand( + withEventStore: (f: (store: EventStore) => T) => T, + services: Services, + projections: Projections, + { decoder, handler }: CommandController, +): express.Handler { + return router.route((req) => + decodeCommand(decoder, req).chain((command) => + withEventStore((store) => + handler({ + command, + store, + projections, + services, + }), + ), + ), + ); +} + +function decodeCommand( + decoder: Decoder, + req: express.Request, +): Future { + const decoded: Result = decode(decoder, req.body); + if (decoded instanceof Failure) { + return Future.reject( + router.json({ + status: 400, + content: { message: `Unable to decode command: ${decoded.error}` }, + }), + ); + } + + return Future.resolve(decoded.value); +} diff --git a/src/app/event.ts b/src/app/event.ts new file mode 100644 index 0000000..5c078c3 --- /dev/null +++ b/src/app/event.ts @@ -0,0 +1,87 @@ +import { + Aggregate, + Id, + CreationEvent, + TransformationEvent, +} from '@/lib/eventSourcing/event'; + +import * as s from '@/lib/json/schema'; + +class User implements Aggregate { + constructor( + readonly aggregateId: Id, + readonly aggregateVersion: number, + readonly name: string, + ) {} +} + +export class CreateUser implements CreationEvent { + static type: 'CreateUserr' = 'CreateUserr'; + static schemaArgs = s.object({ + type: s.stringLiteral(CreateUser.type), + aggregateId: Id.schema(), + name: s.string, + }); + static schema = CreateUser.schemaArgs.dimap( + (v) => new CreateUser(v), + (v) => v.values, + ); + + schema = CreateUser.schema; + constructor(readonly values: s.Infer) {} + createAggregate() { + return new User(new Id('wat'), 0, this.values.name); + } +} + +export class AddName implements TransformationEvent { + static type: 'AddName' = 'AddName'; + constructor(readonly values: s.Infer) {} + + static schemaArgs = s.object({ + type: s.stringLiteral(AddName.type), + aggregateId: Id.schema(), + name: s.string, + }); + + static schema = AddName.schemaArgs.dimap( + (v) => new AddName(v), + (v) => v.values, + ); + readonly schema = AddName.schema; + + transformAggregate(agg: User): User { + const u = new User( + agg.aggregateId, + agg.aggregateVersion + 1, + this.values.name, + ); + return u; + } +} + +export class RemoveName implements TransformationEvent { + static type: 'RemoveName' = 'RemoveName'; + constructor(readonly values: s.Infer) {} + + static schemaArgs = s.object({ + type: s.stringLiteral(RemoveName.type), + aggregateId: Id.schema(), + name: s.string, + }); + + static schema = RemoveName.schemaArgs.dimap( + (v) => new RemoveName(v), + (v) => v.values, + ); + readonly schema = RemoveName.schema; + + transformAggregate(agg: User): User { + const u = new User( + agg.aggregateId, + agg.aggregateVersion + 1, + this.values.name, + ); + return u; + } +} diff --git a/src/app/postgresEventStore.ts b/src/app/postgresEventStore.ts index 6b3f81c..6d42f1f 100644 --- a/src/app/postgresEventStore.ts +++ b/src/app/postgresEventStore.ts @@ -1,87 +1,119 @@ export { initialize, PostgresEventStore }; -import { Serializer } from '@/common/serializedEvent/Serializer'; -import { Deserializer } from '@/common/serializedEvent/Deserializer'; -import { SerializedEvent } from '@/common/serializedEvent/SerializedEvent'; -import { Event } from '@/common/event/Event'; -import { CreationEvent } from '@/common/event/CreationEvent'; -import { TransformationEvent } from '@/common/event/TransformationEvent'; -import { Aggregate } from '@/common/aggregate/Aggregate'; -import { AggregateAndEventIdsInLastEvent } from '@/common/eventStore/AggregateAndEventIdsInLastEvent'; -import { EventStore } from '@/lib/eventSourcing/eventStore'; +import { Json } from '@/lib/json/types'; +import { + Id, + Event, + Aggregate, + CreationEvent, + TransformationEvent, + EventClass, + EventInfo, +} from '@/lib/eventSourcing/event'; +import { + EventStore, + Hydrator, + Constructor, + EventData, + schema_EventData, +} from '@/lib/eventSourcing/eventStore'; import { PostgresTransaction } from '@/lib/postgres'; import { log } from '@/common/util/Logger'; +import { IdGenerator } from '@/common/util/IdGenerator'; +import { encode } from '@/lib/json/schema'; +import { POSIX } from '@/lib/time'; class PostgresEventStore implements EventStore { constructor( private transaction: PostgresTransaction, - private readonly serializer: Serializer, - private readonly deserializer: Deserializer, + private readonly hydrator: Hydrator, private readonly eventStoreTable: string, ) {} - async findAggregate( - aggregateId: string, - ): Promise> { - const serializedEvents = - await this.findAllSerializedEventsByAggregateId(aggregateId); - const events = serializedEvents.map((e) => - this.deserializer.deserialize(e), - ); + async find>( + cls: Constructor, + aggregateId: Id, + ): Promise<{ aggregate: T; lastEvent: EventInfo }> { + const events = await this.findAll(aggregateId); + const { lastEvent, aggregate } = this.hydrator + .hydrate(cls, events) + .unwrap((e) => e); - const firstEvent: Event | undefined = events[0]; - if (firstEvent == undefined) { - throw new Error(`No events found for aggregateId: ${aggregateId}`); - } - const creationEvent: Event = firstEvent; - if (!this.isCreationEventForAggregate(creationEvent)) { - throw new Error('First event is not a creation event'); - } - const transformationEvents = events.slice(1); - - let aggregate = creationEvent.createAggregate(); - let eventIdOfLastEvent = creationEvent.eventId; - let correlationIdOfLastEvent = creationEvent.correlationId; + return { aggregate, lastEvent }; + } - for (const transformationEvent of transformationEvents) { - if (!this.isTransformationEventForAggregate(transformationEvent)) { - throw new Error('Event is not a transformation event'); + async save, T extends Aggregate>(args: { + aggregate: Constructor; + event: CreationEvent | TransformationEvent; + event_id?: Id>; + correlation_id?: Id>; + causation_id?: Id>; + }): Promise { + const event = args.event; + const event_id = args.event_id || new Id(IdGenerator.generateRandomId()); + let info: EventInfo; + switch (true) { + case event instanceof CreationEvent: { + const aggregate: T = event.createAggregate(); + info = { + event_id, + aggregate_id: aggregate.aggregateId, + aggregate_version: 0, + correlation_id: args.correlation_id || event_id, + causation_id: args.causation_id || event_id, + recorded_on: POSIX.now(), + }; + break; } - aggregate = transformationEvent.transformAggregate(aggregate); - eventIdOfLastEvent = transformationEvent.eventId; - correlationIdOfLastEvent = transformationEvent.correlationId; + case event instanceof TransformationEvent: { + const { aggregate, lastEvent } = await this.find( + args.aggregate, + event.values.aggregateId, + ); + info = { + event_id, + aggregate_id: aggregate.aggregateId, + aggregate_version: aggregate.aggregateVersion + 1, + correlation_id: lastEvent.correlation_id, + causation_id: lastEvent.causation_id, + recorded_on: POSIX.now(), + }; + break; + } + default: + return event satisfies never; } - return { - aggregate, - eventIdOfLastEvent, - correlationIdOfLastEvent, - }; + await this.insert>({ info, event }); } - async saveEvent(event: Event): Promise { - await this.saveSerializedEvent(this.serializer.serialize(event)); - } + async doesEventAlreadyExist(eventId: Id>): Promise { + const sql = ` + SELECT 1 + FROM ${this.eventStoreTable} + WHERE event_id = $1`; - async doesEventAlreadyExist(eventId: string): Promise { - const event = await this.findSerializedEventByEventId(eventId); - return event !== null; + try { + const result = await this.transaction.query(sql, [eventId.value]); + return result.rows.length > 0; + } catch (error) { + throw new Error(`Failed to fetch event: ${eventId}: ${error}`); + } } - private async findAllSerializedEventsByAggregateId( - aggregateId: string, - ): Promise { + private async findAll>( + aggregateId: Id, + ): Promise { const sql = ` - SELECT id, event_id, aggregate_id, causation_id, correlation_id, - aggregate_version, json_payload, json_metadata, recorded_on, event_name - FROM ${this.eventStoreTable} - WHERE aggregate_id = $1 - ORDER BY aggregate_version ASC - `; + SELECT id, event_id, aggregate_id, causation_id, correlation_id, + aggregate_version, json_payload, json_metadata, recorded_on, event_name + FROM ${this.eventStoreTable} + WHERE aggregate_id = $1 + ORDER BY aggregate_version ASC`; try { - const result = await this.transaction.query(sql, [aggregateId]); - return result.rows.map(this.mapRowToSerializedEvent); + const result = await this.transaction.query(sql, [aggregateId.value]); + return result.rows; } catch (error) { throw new Error( `Failed to fetch events for aggregate: ${aggregateId}: ${error}`, @@ -89,83 +121,40 @@ class PostgresEventStore implements EventStore { } } - private async saveSerializedEvent( - serializedEvent: SerializedEvent, - ): Promise { + private async insert>(edata: EventData) { const sql = ` - INSERT INTO ${this.eventStoreTable} ( - event_id, aggregate_id, causation_id, correlation_id, - aggregate_version, json_payload, json_metadata, recorded_on, event_name - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - `; + INSERT INTO ${this.eventStoreTable} ( + event_id, aggregate_id, causation_id, correlation_id, + aggregate_version, json_payload, json_metadata, recorded_on, event_name + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`; + + const serialized = encode(schema_EventData(edata.event.schema), edata); const values = [ - serializedEvent.event_id, - serializedEvent.aggregate_id, - serializedEvent.causation_id, - serializedEvent.correlation_id, - serializedEvent.aggregate_version.toString(), - serializedEvent.json_payload, - serializedEvent.json_metadata, - serializedEvent.recorded_on, - serializedEvent.event_name, + // @ts-ignore + serialized.event_id, + // @ts-ignore + serialized.aggregate_id, + // @ts-ignore + serialized.causation_id, + // @ts-ignore + serialized.correlation_id, + // @ts-ignore + serialized.aggregate_version, + // @ts-ignore + serialized.payload, + '{}', + // @ts-ignore + serialized.recorded_on, + edata.event.values.type, ]; try { await this.transaction.query(sql, values); } catch (error) { - throw new Error( - `Failed to save event: ${serializedEvent.event_id}: ${error}`, - ); - } - } - - private async findSerializedEventByEventId( - eventId: string, - ): Promise { - const sql = ` - SELECT id, event_id, aggregate_id, causation_id, correlation_id, - aggregate_version, json_payload, json_metadata, recorded_on, event_name - FROM ${this.eventStoreTable} - WHERE event_id = $1 - `; - - try { - const result = await this.transaction.query(sql, [eventId]); - return result.rows.length > 0 - ? this.mapRowToSerializedEvent(result.rows[0]) - : null; - } catch (error) { - throw new Error(`Failed to fetch event: ${eventId}: ${error}`); + throw new Error(`Failed to save event: ${edata.info.event_id}: ${error}`); } } - - private mapRowToSerializedEvent(row: any): SerializedEvent { - return { - id: row.id, - event_id: row.event_id, - aggregate_id: row.aggregate_id, - causation_id: row.causation_id, - correlation_id: row.correlation_id, - aggregate_version: row.aggregate_version, - json_payload: row.json_payload, - json_metadata: row.json_metadata, - recorded_on: row.recorded_on, - event_name: row.event_name, - }; - } - - private isCreationEventForAggregate( - event: Event, - ): event is CreationEvent { - return event instanceof CreationEvent; - } - - private isTransformationEventForAggregate( - event: Event, - ): event is TransformationEvent { - return event instanceof TransformationEvent; - } } // Prepare the database to be used as an event store. @@ -199,7 +188,7 @@ async function initialize({ aggregate_version BIGINT NOT NULL, causation_id TEXT NOT NULL, correlation_id TEXT NOT NULL, - recorded_on TEXT NOT NULL, + recorded_on TIMESTAMPTZ NOT NULL, event_name TEXT NOT NULL, json_payload TEXT NOT NULL, json_metadata TEXT NOT NULL, diff --git a/src/app/projectionHandler.ts b/src/app/projectionHandler.ts new file mode 100644 index 0000000..ec95c6f --- /dev/null +++ b/src/app/projectionHandler.ts @@ -0,0 +1,99 @@ +export { handleProjection, decodeEvent, accept }; + +import { Response } from '@/lib/router'; +import { Event } from '@/lib/eventSourcing/event'; +import { Decoder, decode } from '@/lib/json/decoder'; +import * as express from 'express'; +import * as router from '@/lib/router'; +import * as d from '@/lib/json/decoder'; +import { Future } from '@/lib/Future'; +import { Result, Failure } from '@/lib/Result'; +import { Maybe, Nothing, Just } from '@/lib/Maybe'; +import { Schema } from '@/lib/json/schema'; +import * as s from '@/lib/json/schema'; + +type Projections = {}; +type ProjectionStore = {}; +type Mongo = {}; + +type ProjectionController> = { + decoder: Decoder>; + handler: (v: { + event: E; + projections: Projections; + store: ProjectionStore; + }) => Future; +}; + +function handleProjection>( + projections: Projections, + mongo: Mongo, + { decoder, handler }: ProjectionController, +): express.Handler { + return router.route((req) => + decodeEvent(decoder, req).chain((event) => + withProjectionStore(mongo, (store) => + handler({ + event, + projections, + store, + }), + ), + ), + ); +} + +function decodeEvent( + decoder: Decoder>, + req: express.Request, +): Future { + const bodyDecoder: Decoder> = d + .object({ payload: decoder }) + .map((r) => r.payload); + + const decoded: Result> = decode(bodyDecoder, req.body); + + if (decoded instanceof Failure) { + return Future.reject( + router.json({ + status: 400, + content: { message: `Unable to decode command: ${decoded.error}` }, + }), + ); + } + + if (decoded.value instanceof Nothing) { + return Future.reject( + router.json({ + status: 200, + content: { message: 'Ignored' }, + }), + ); + } + + return Future.resolve(decoded.value.value); +} + +function withProjectionStore( + _mongo: Mongo, + _f: (s: ProjectionStore) => Future, +): Future { + throw new Error('TODO'); +} + +type EventClass = { type: string; schema: Schema }; + +// Given some event classes, creates a decoder for those classes. +// Makes sure to error if decoding those class object fail, but +// succeeds if the encoded event was of another class. +function accept( + ts: T, +): Decoder>> { + type Ty = s.Infer; + return d.object({ type: d.string }).then(({ type: ty }) => { + const c: undefined | EventClass = ts.find((t) => t.type === ty); + return c + ? (c.schema.decoder.map(Just) as Decoder>) + : d.succeed(Nothing()); + }); +} diff --git a/src/app/queryHandler.ts b/src/app/queryHandler.ts new file mode 100644 index 0000000..057db14 --- /dev/null +++ b/src/app/queryHandler.ts @@ -0,0 +1,54 @@ +export { handleQuery }; + +import { Response } from '@/lib/router'; +import { Event } from '@/lib/eventSourcing/event'; +import { Decoder, decode } from '@/lib/json/decoder'; +import * as express from 'express'; +import * as router from '@/lib/router'; +import { Future } from '@/lib/Future'; +import { Result, Failure } from '@/lib/Result'; + +type Projections = {}; +type Services = {}; + +type QueryController = { + decoder: Decoder; + handler: (v: { + query: Query; + projections: Projections; + services: Services; + }) => Future; +}; + +function handleQuery>( + projections: Projections, + services: Services, + { decoder, handler }: QueryController, +): express.Handler { + return router.route((req) => + decodeQuery(decoder, req).chain((query) => + handler({ + query, + projections, + services, + }), + ), + ); +} + +function decodeQuery( + decoder: Decoder, + req: express.Request, +): Future { + const decoded: Result = decode(decoder, req.body); + if (decoded instanceof Failure) { + return Future.reject( + router.json({ + status: 400, + content: { message: `Unable to decode request: ${decoded.error}` }, + }), + ); + } + + return Future.resolve(decoded.value); +} diff --git a/src/app/reactionHandler.ts b/src/app/reactionHandler.ts new file mode 100644 index 0000000..1791196 --- /dev/null +++ b/src/app/reactionHandler.ts @@ -0,0 +1,44 @@ +export { handleReaction }; + +import { Response } from '@/lib/router'; +import { EventStore } from '@/lib/eventSourcing/eventStore'; +import { Event } from '@/lib/eventSourcing/event'; +import { Decoder } from '@/lib/json/decoder'; +import * as express from 'express'; +import * as router from '@/lib/router'; +import { Future } from '@/lib/Future'; +import { Maybe } from '@/lib/Maybe'; +import { decodeEvent } from '@/app/projectionHandler'; + +type Projections = {}; +type Services = {}; + +type ReactionController> = { + decoder: Decoder>; + handler: (v: { + event: E; + projections: Projections; + services: Services; + store: EventStore; + }) => Future; +}; + +function handleReaction>( + withEventStore: (f: (s: EventStore) => T) => T, + projections: Projections, + services: Services, + { decoder, handler }: ReactionController, +): express.Handler { + return router.route((req) => + decodeEvent(decoder, req).chain((event) => + withEventStore((store) => + handler({ + event, + projections, + services, + store, + }), + ), + ), + ); +} diff --git a/src/di/container.ts b/src/di/container.ts index 7d597fb..e8f90b2 100644 --- a/src/di/container.ts +++ b/src/di/container.ts @@ -21,6 +21,8 @@ import { MembershipApplicationRepository } from '@/domain/cookingClub/membership import { CuisineRepository } from '@/domain/cookingClub/membership/projection/membersByCuisine/CuisineRepository'; import env from '@/app/environment'; import { Postgres, defaultPoolSettings } from '@/lib/postgres'; +import { Mongo } from '@/lib/mongo'; +import { ServerApiVersion } from 'mongodb'; import * as postgresEventStore from '@/app/postgresEventStore'; function registerEnvironmentVariables() { @@ -102,6 +104,7 @@ function registerScopedServices() { type Dependencies = { postgres: Postgres; + mongo: Mongo; }; export async function configureDependencies(): Promise { @@ -118,7 +121,28 @@ export async function configureDependencies(): Promise { poolSettings: defaultPoolSettings, }); - await postgres.withTransaction((transaction) => + const mongo = new Mongo({ + user: env.MONGODB_PROJECTION_DATABASE_USERNAME, + password: env.MONGODB_PROJECTION_DATABASE_PASSWORD, + host: env.MONGODB_PROJECTION_HOST, + port: env.MONGODB_PROJECTION_PORT, + database: env.MONGODB_PROJECTION_DATABASE_NAME, + settings: { + maxPoolSize: 20, + minPoolSize: 5, + maxIdleTimeMS: 10 * 60 * 1000, // 10 minutes + maxConnecting: 30, + waitQueueTimeoutMS: 2000, + replicaSet: 'rs0', + serverApi: { + version: ServerApiVersion.v1, + strict: true, + deprecationErrors: true, + }, + }, + }); + + await postgres.withTransactionP((transaction) => postgresEventStore.initialize({ transaction, database: env.EVENT_STORE_DATABASE_NAME, @@ -131,5 +155,5 @@ export async function configureDependencies(): Promise { }), ); - return { postgres }; + return { postgres, mongo }; } diff --git a/src/lib/Maybe.ts b/src/lib/Maybe.ts index fc2447b..f8be9f7 100644 --- a/src/lib/Maybe.ts +++ b/src/lib/Maybe.ts @@ -17,6 +17,7 @@ Values can be extracted using `instsanceof` tests. export { type Maybe, type Nullable, + type Infer, CallableJust as Just, CallableNothing as Nothing, from, @@ -29,6 +30,9 @@ import Callable from '@/lib/Callable'; type Maybe = Just | Nothing; type Nullable = T | null; +// Infer the type from a Maybe definition +type Infer> = A extends Maybe ? B : never; + // prettier-ignore export interface IMaybe { isJust() : boolean; diff --git a/src/lib/eventSourcing/event.ts b/src/lib/eventSourcing/event.ts new file mode 100644 index 0000000..e014725 --- /dev/null +++ b/src/lib/eventSourcing/event.ts @@ -0,0 +1,74 @@ +export { + type Event, + type Aggregate, + EventClass, + TransformationEvent, + CreationEvent, + type EventInfo, + EventInfo_schema, + Id, +}; + +import * as s from '@/lib/json/schema'; +import { Schema } from '@/lib/json/schema'; +import { POSIX } from '@/lib/time'; + +// @ts-ignore +class Id { + // @ts-expect-error _tag's existence prevents structural comparison + private readonly _tag: null = null; + + constructor(public value: string) {} + + static schema(): Schema> { + return s.string.dimap( + (v) => new Id(v), + (id) => id.value, + ); + } +} + +// Class which all events derive from. Used for type constraints. +interface Aggregate { + readonly aggregateId: Id>; + aggregateVersion: number; +} + +type Event> = EventClass; + +// Class which all events derive from. Used for type constraints. +abstract class EventClass> { + abstract values: { + type: string; + aggregateId: Id; + }; + abstract schema: Schema; +} + +// The first event for an aggregate. +abstract class CreationEvent> extends EventClass< + Self, + T +> { + abstract createAggregate(): T; +} + +// Any event that is not the first one for an aggregate. +abstract class TransformationEvent< + Self, + T extends Aggregate, +> extends EventClass { + abstract transformAggregate(aggregate: T): T; +} + +// Information about an event. Not the event payload. +type EventInfo = s.Infer; + +const EventInfo_schema = s.object({ + event_id: Id.schema>>(), + aggregate_id: Id.schema>(), + aggregate_version: s.number, + correlation_id: Id.schema>>(), + causation_id: Id.schema>>(), + recorded_on: POSIX.schema, +}); diff --git a/src/lib/eventSourcing/eventStore.ts b/src/lib/eventSourcing/eventStore.ts index ecb7a90..f4dba1b 100644 --- a/src/lib/eventSourcing/eventStore.ts +++ b/src/lib/eventSourcing/eventStore.ts @@ -1,7 +1,30 @@ -export { type EventStore }; -import { Event } from '@/common/event/Event'; -import { Aggregate } from '@/common/aggregate/Aggregate'; -import { AggregateAndEventIdsInLastEvent } from '@/common/eventStore/AggregateAndEventIdsInLastEvent'; +export { + type EventStore, + type AggregateAndEventIdsInLastEvent, + Hydrator, + type Constructor, + type EventData, + schema_EventData, + makeSchema, +}; + +import { + CreationEvent, + TransformationEvent, + EventInfo, + Event, + Aggregate, + Id, +} from '@/lib/eventSourcing/event'; +import { Json } from '@/lib/json/types'; +import { Schema } from '@/lib/json/schema'; +import { Encoder } from '@/lib/json/encoder'; +import { Decoder } from '@/lib/json/decoder'; +import * as s from '@/lib/json/schema'; +import * as d from '@/lib/json/decoder'; +import { POSIX } from '@/lib/time'; +import { Result, Failure } from '@/lib/Result'; +import { DateTime } from 'luxon'; /* Note [Event Store] @@ -18,12 +41,194 @@ import { AggregateAndEventIdsInLastEvent } from '@/common/eventStore/AggregateAn */ +interface AggregateAndEventIdsInLastEvent> { + aggregate: T; + eventIdOfLastEvent: Id>; + correlationIdOfLastEvent: Id>; +} + interface EventStore { - findAggregate( - aggregateId: string, - ): Promise>; + find>( + cls: Constructor, + aggregateId: Id, + ): Promise<{ aggregate: T; lastEvent: EventInfo }>; + + save, T extends Aggregate>(args: { + aggregate: Constructor; + event: CreationEvent | TransformationEvent; + event_id?: Id>; + correlation_id?: Id>; + causation_id?: Id>; + }): Promise; + + doesEventAlreadyExist(eventId: Id>): Promise; +} + +// ----------------------------------------------------------------------- + +// Serialized representation of an event. +type Serialized

= s.Infer>>; + +const schema_Serialized = (payload: Schema) => + s.object({ + event_id: Id.schema>>(), + aggregate_id: Id.schema>(), + aggregate_version: s.number, + correlation_id: Id.schema>>(), + causation_id: Id.schema>>(), + recorded_on: schema_UTC, + payload: schema_StringifiedJSON(payload), + }); + +const schema_UTC: s.Schema = s.string.then( + (s) => { + const date = DateTime.fromISO(s, { zone: 'UTC' }); + return date.isValid + ? d.succeed(new POSIX(date.toMillis())) + : d.fail(`Invalid ISO date: ${s}`); + }, + (s) => { + const { date, time } = s.toUTCDateAndTime(); + return `${date.pretty()}T${time.pretty()}Z`; + }, +); + +const schema_StringifiedJSON = (inner: s.Schema): s.Schema => + s.string.then( + (str: string): Decoder => + new Decoder((_: unknown) => inner.decoder.run(JSON.parse(str))), + (t: T): string => JSON.stringify(inner.encoder.run(t)), + ); + +// ----------------------------------------------------------------------- + +// Convenient runtime representation of data in a serialized event. +type EventData = { info: EventInfo; event: E }; + +function toSerialized

({ + info, + event, +}: { + info: EventInfo; + event: P; +}): Serialized

{ + return { ...info, payload: event }; +} + +function fromSerialized

(serialized: Serialized

): { + info: EventInfo; + event: P; +} { + const { payload, ...info } = serialized; + return { info, event: payload }; +} + +const schema_EventData = (s: Schema): Schema> => + schema_Serialized(s).dimap(fromSerialized, toSerialized); + +// ----------------------------------------------------------------------- + +type Constructor = new (...args: any[]) => T; + +type Schemas> = { + creation: Schema>>; + transformation: Schema>>; +}; + +/* Note [Hydrator] + + We need some type-safe way to decode events for an aggregate. That is, without casting. + We perform type-directed decoding, where we specify the type of the aggregate, + then use the decoders we have for creation and transformation events for that aggregate. + + This ensures that we will never apply an incorrect aggregate transformation or create + an aggregate of the incorrect type. +*/ +class Hydrator { + private tmap = new Map, Schemas>(); + + constructor() {} + + // add support for deserializing an aggregate's events. + add>({ + aggregate, + creation, + transformation, + }: { + aggregate: Constructor; + creation: Schema>; + transformation: Schema>; + }): void { + this.tmap.set(aggregate, { + creation: schema_EventData(creation), + transformation: schema_EventData(transformation), + }); + } + + // Build an aggregate from all its serialized events. + hydrate>( + cls: Constructor, + serialized: Json[], + ): Result { + const schemas = this.tmap.get(cls) as undefined | Schemas; + if (schemas == undefined) { + throw new Error(`Unknown aggregate ${cls.name}`); + } + + if (serialized.length === 0) { + return Failure('No events'); + } + + return d + .decode(serialized[0], schemas.creation.decoder) + .then(({ event: first, info }) => + d + .decode(serialized.slice(1), d.array(schemas.transformation.decoder)) + .map((es) => { + let aggregate = first.createAggregate(); + let lastEvent = info; + + for (const t of es) { + aggregate = t.event.transformAggregate(aggregate); + lastEvent = t.info; + } + + return { aggregate, lastEvent }; + }), + ); + } +} + +// ----------------------------------------------------------------------- + +type EventConstructor = { type: string; schema: Schema }; + +// Create an efficient schema given a list of event classes +// +// To be used when joining schemas for the Hydrator +function makeSchema( + ts: T, +): Schema> { + type Ty = s.Infer; + + const decoder: Decoder = d + .object({ type: d.string }) + .then(({ type: ty }) => { + const c: undefined | EventConstructor = ts.find((t) => t.type === ty); + + if (c === undefined) return d.fail(`Unknown event type: ${ty}`); + + return c.schema.decoder as Decoder; + }); + + const encoder: Encoder = new Encoder((v: Ty) => { + const ty = ts.find((t) => t.type === v.type); + if (ty === undefined) { + throw new Error(`Unable to encode unknown event type: ${v.type}`); + } - saveEvent(event: Event): Promise; + return ty.schema.encoder.run(v); + }); - doesEventAlreadyExist(eventId: string): Promise; + return new Schema(decoder, encoder); } diff --git a/src/lib/eventSourcing/projection.ts b/src/lib/eventSourcing/projection.ts new file mode 100644 index 0000000..76e8426 --- /dev/null +++ b/src/lib/eventSourcing/projection.ts @@ -0,0 +1,26 @@ +export { accept }; + +import { Maybe, Nothing, Just } from '@/lib/Maybe'; +import { Decoder } from '@/lib/json/decoder'; +import * as d from '@/lib/json/decoder'; +import * as s from '@/lib/json/schema'; +import { Schema } from '@/lib/json/schema'; + +type EventConstructor = { type: string; schema: Schema }; + +// Given some event classes, creates a decoder for those classes. +// Makes sure to error if decoding those class object fail, but +// succeeds if the encoded event was of another class. +// +// To be used in decoding events for projections and reactions. +function accept( + ts: T, +): Decoder>> { + type Ty = s.Infer; + return d.object({ type: d.string }).then(({ type: ty }) => { + const c = ts.find((t) => t.type === ty); + return c + ? (c.schema.decoder.map(Just) as Decoder>) + : d.succeed(Nothing()); + }); +} diff --git a/src/lib/json/decoder.ts b/src/lib/json/decoder.ts index aca6e8d..fd375c3 100644 --- a/src/lib/json/decoder.ts +++ b/src/lib/json/decoder.ts @@ -26,7 +26,7 @@ export { type FromJSON, type Infer, - type Decoder, // export only the abstract type, not constructors. + Decoder, type DecoderDef, type DecodeResult, decode, @@ -49,7 +49,9 @@ export { triple, always, fail, + failure, optional, + succeed, }; import { Result, Success, Failure, traverse } from '@/lib/Result'; @@ -71,8 +73,8 @@ class Decoder { this.run = run; } - then(f: (v: T) => DecodeResult): Decoder { - return new Decoder((v) => this.run(v).then(f)); + then(f: (v: T) => Decoder): Decoder { + return new Decoder((u) => this.run(u).then((v) => f(v).run(u))); } map(f: (v: T) => W): Decoder { @@ -91,39 +93,45 @@ function showPath([path, error]: [Path, string]): string { type DecodeResult = Result<[Path, string], T>; type Path = List; -const fail = (msg: string): DecodeResult => Failure([List.empty(), msg]); +const failure = (msg: string): DecodeResult => + Failure([List.empty(), msg]); + +const fail = (msg: string): Decoder => + new Decoder((_) => Failure([List.empty(), msg])); const always = (v: T): Decoder => new Decoder((_) => Success(v)); +const succeed = always; + const any: Decoder = new Decoder((v) => Success(v)); const string: Decoder = new Decoder((v) => typeof v === 'string' ? Success(v) - : fail('expected string but found ' + typeof v), + : failure('expected string but found ' + typeof v), ); const number: Decoder = new Decoder((v) => typeof v === 'number' ? Success(v) - : fail('expected number but found ' + typeof v), + : failure('expected number but found ' + typeof v), ); const stringNumber: Decoder = string.then((s) => { const v = parseInt(s, 10); - return isNaN(v) ? fail('not a valid number: ' + s) : Success(v); + return isNaN(v) ? fail('not a valid number: ' + s) : succeed(v); }); const boolean: Decoder = new Decoder((v) => typeof v === 'boolean' ? Success(v) - : fail('expected boolean but found ' + typeof v), + : failure('expected boolean but found ' + typeof v), ); const array = (decodeValue: Decoder): Decoder> => new Decoder((input) => { if (!Array.isArray(input)) { - return fail('expected array but found ' + typeof input); + return failure('expected array but found ' + typeof input); } return traverse(List.from(input), decodeValue.run).map((list) => @@ -139,7 +147,7 @@ type DecoderDef = { const object = (decoders: DecoderDef): Decoder => new Decoder((input) => { if (typeof input !== 'object' || input === null) { - return fail('expected object but found ' + typeof input); + return failure('expected object but found ' + typeof input); } const obj = input as { [P in keyof A]: unknown }; @@ -168,7 +176,7 @@ type ObjectMap = { [x: string]: A }; const objectMap = (decoder: Decoder): Decoder> => new Decoder((input) => { if (typeof input !== 'object' || input === null) { - return fail('expected object but found ' + typeof input); + return failure('expected object but found ' + typeof input); } const result = {} as ObjectMap; @@ -197,10 +205,10 @@ const pair = ( ): Decoder<[L, R]> => new Decoder((input) => { if (!Array.isArray(input)) { - return fail('expected array but found ' + typeof input); + return failure('expected array but found ' + typeof input); } if (input.length !== 2) { - return fail( + return failure( 'expected array with 2 elements but it found ' + input.length, ); } @@ -218,10 +226,10 @@ const triple = ( ): Decoder<[A, B, C]> => new Decoder((input) => { if (!Array.isArray(input)) { - return fail('expected array but found ' + typeof input); + return failure('expected array but found ' + typeof input); } if (input.length !== 3) { - return fail( + return failure( 'expected array with 3 elements but it found ' + input.length, ); } @@ -236,7 +244,7 @@ const triple = ( const oneOf = (decoders: Array>): Decoder => new Decoder((input) => { - let decoded: DecodeResult = fail('no decoders'); + let decoded: DecodeResult = failure('no decoders'); const errors: Array<[Path, string]> = []; @@ -248,11 +256,10 @@ const oneOf = (decoders: Array>): Decoder => errors.push(decoded.error); } - const failure = Failure<[Path, string], V>([ + return Failure<[Path, string], V>([ List.empty(), errors.map(showPath).join('\n'), ]); - return failure; }); const maybe = (decoder: Decoder): Decoder> => @@ -262,19 +269,19 @@ const nullable = (decoder: Decoder): Decoder> => oneOf([nullP, decoder]); const nullP: Decoder = new Decoder((v) => - v === null ? Success(null) : fail('expected null but found ' + typeof v), + v === null ? Success(null) : failure('expected null but found ' + typeof v), ); const undefinedP: Decoder = new Decoder((v) => v === undefined ? Success(undefined) - : fail('expected `undefined` ' + typeof v), + : failure('expected `undefined` ' + typeof v), ); // Useful for parsing tag names in discriminated unions. const stringLiteral = (str: T): Decoder => new Decoder((v) => - v === str ? Success(v as T) : fail(`expected '${str}' but found '${v}'`), + v === str ? Success(v as T) : failure(`expected '${str}' but found '${v}'`), ); // An object field that may be absent. @@ -283,8 +290,8 @@ const optional = (decoder: Decoder): Decoder> => // Define a recursive decoder function rec(f: (p: Decoder) => Decoder): Decoder { - const base: Decoder = new Decoder((_) => - fail('A recursive decoder cannot immediately call itself.'), + const base: Decoder = fail( + 'A recursive decoder cannot immediately call itself.', ); const top = f(base); // @ts-expect-error will complain that 'run' is readonly. But we are doing this on purpose here. diff --git a/src/lib/json/schema.ts b/src/lib/json/schema.ts index 7714521..90b9d12 100644 --- a/src/lib/json/schema.ts +++ b/src/lib/json/schema.ts @@ -34,7 +34,6 @@ import * as D from '@/lib/json/decoder'; import { Encoder, EncoderDef } from '@/lib/json/encoder'; import { Json } from '@/lib/json/types'; import * as E from '@/lib/json/encoder'; -import { List } from '@/lib/List'; import { Maybe, Nullable } from '@/lib/Maybe'; // Infer the type from a schema definition @@ -54,11 +53,8 @@ class Schema { return new Schema(this.decoder.map(p), this.encoder.rmap(s)); } - then(p: (v: A) => Result, s: (v: W) => A): Schema { - return new Schema( - this.decoder.then((v) => p(v).mapFailure((e) => [List.empty(), e])), - this.encoder.rmap(s), - ); + then(p: (v: A) => Decoder, s: (v: W) => A): Schema { + return new Schema(this.decoder.then(p), this.encoder.rmap(s)); } } diff --git a/src/lib/mongo.ts b/src/lib/mongo.ts new file mode 100644 index 0000000..e2c3f97 --- /dev/null +++ b/src/lib/mongo.ts @@ -0,0 +1,156 @@ +export { Mongo, MongoTransaction }; + +import { + ClientSession, + Filter, + FindOptions, + Document, + ReplaceOptions, + InsertOneOptions, + CountOptions, + Db, + ReadConcern, + WriteConcern, + ReadPreference, + TransactionOptions, + OptionalUnlessRequiredId, + WithId, + MongoClientOptions, + MongoClient, +} from 'mongodb'; +import { Future } from '@/lib/Future'; + +class MongoTransaction { + public closed: boolean = false; + constructor( + public readonly session: ClientSession, + public readonly database: Db, + ) {} + + async commit() { + if (this.closed) { + throw new Error('Committing a closed transaction'); + } + + try { + await this.session.commitTransaction(); + this.closed = true; + } catch (error) { + this.closed = true; + throw new Error(`Failed to commit transaction: ${error}`); + } + } + + async abort() { + if (this.closed) { + throw new Error('Aborting a closed transaction'); + } + + try { + await this.session.abortTransaction(); + } catch (error) { + console.error('Failed to abort MongoDB transaction', error as Error); + } + this.closed = true; + } + + async find( + collectionName: string, + filter: Filter, + options?: FindOptions, + ): Promise[]> { + this.checkOpen(); + return this.database + .collection(collectionName) + .find(filter, { ...options, session: this.session }) + .toArray(); + } + + async replaceOne( + collectionName: string, + filter: Filter, + replacement: T, + options?: ReplaceOptions, + ): Promise { + this.checkOpen(); + return this.database + .collection(collectionName) + .replaceOne(filter, replacement, { + ...options, + session: this.session, + }); + } + + async insertOne( + collectionName: string, + document: T & OptionalUnlessRequiredId, + options?: InsertOneOptions, + ): Promise { + this.checkOpen(); + await this.database + .collection(collectionName) + .insertOne(document, { ...options, session: this.session }); + } + + async countDocuments( + collectionName: string, + filter: Filter, + options?: CountOptions, + ): Promise { + this.checkOpen(); + return this.database + .collection(collectionName) + .countDocuments(filter, { ...options, session: this.session }); + } + + private checkOpen() { + if (this.closed) { + throw new Error('Session must be active to read or write to MongoDB!'); + } + } +} + +const transactionOptions: TransactionOptions = { + readConcern: new ReadConcern('snapshot'), + writeConcern: new WriteConcern('majority'), + readPreference: ReadPreference.primary, +}; + +class Mongo { + client: MongoClient; + + constructor( + public values: { + user: string; + password: string; + host: string; + port: number; + database: string; + settings: MongoClientOptions; + }, + ) { + const connectionString = + `mongodb://${values.user}:${values.password}@${values.host}` + + `:${values.port.toString()}/${values.database}` + + '?serverSelectionTimeoutMS=10000&connectTimeoutMS=10000&authSource=admin'; + this.client = new MongoClient(connectionString, values.settings); + } + + // Execute an action with a transaction that will be automatically committed at the end. + withTransaction( + f: (t: MongoTransaction) => Future, + ): Future { + const session = this.client.startSession(); + + session.startTransaction(transactionOptions); + const database = this.client.db(this.values.database); + const transaction = new MongoTransaction(session, database); + return f(transaction).finally( + Future.create((_, res) => { + if (!transaction.closed) transaction.abort(); + session.endSession(); + return res(); + }), + ); + } +} diff --git a/src/lib/postgres.ts b/src/lib/postgres.ts index ac973c0..4f1ec0b 100644 --- a/src/lib/postgres.ts +++ b/src/lib/postgres.ts @@ -6,6 +6,7 @@ export { }; import { Pool, PoolConfig, PoolClient, QueryConfig, QueryResult } from 'pg'; +import { Future } from '@/lib/Future'; class PostgresTransaction { public closed: boolean = false; @@ -16,6 +17,7 @@ class PostgresTransaction { if (this.closed) { throw new Error('Committing a closed transaction'); } + try { await this.connection.query('COMMIT'); this.closed = true; @@ -23,6 +25,8 @@ class PostgresTransaction { this.closed = true; throw new Error(`Failed to commit transaction: ${error}`); } + + await this.release(); } async abort() { @@ -37,6 +41,14 @@ class PostgresTransaction { } this.closed = true; + await this.release(); + } + + async release() { + if (!this.closed) { + throw new Error('Releasing an active transaction'); + } + try { this.connection.release(); } catch (error) { @@ -96,7 +108,7 @@ class Postgres { } // Execute an action with a transaction that will be automatically committed at the end. - async withTransaction( + async withTransactionP( f: (t: PostgresTransaction) => Promise, ): Promise { const connection = await this.pool.connect(); @@ -105,4 +117,24 @@ class Postgres { if (!transaction.closed) await transaction.commit(); return result; } + + // Execute an action with a transaction that will be automatically committed at the end. + withTransaction( + onConnectionError: (e: Error) => E, + f: (t: PostgresTransaction) => Future, + ): Future { + return Future.attemptP(() => this.pool.connect()) + .mapRej(onConnectionError) + .chain((connection) => { + const transaction = new PostgresTransaction(connection); + return f(transaction).chain((res) => { + if (!transaction.closed) { + Future.attemptP(transaction.commit) + .mapRej(onConnectionError) + .map(() => res); + } + return Future.resolve(res); + }); + }); + } } diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..aeb4845 --- /dev/null +++ b/src/lib/time.ts @@ -0,0 +1,180 @@ +// Sane utilities for dealing with time. +export { DateOnly, TimeOfDay, POSIX }; + +import { DateTime } from 'luxon'; +import * as s from '@/lib/json/schema'; +import * as d from '@/lib/json/decoder'; + +// POSIX time is the nominal time since 1970-01-01 00:00 UTC. +// Like DateTime, but without timezone confusion. +class POSIX { + static fromDate(d: Date): POSIX { + return new POSIX(d.valueOf()); + } + + static now(): POSIX { + return new POSIX(Date.now()); + } + + // The number of milliseconds for this date since midnight at the beginning + // of January 1, 1970, UTC. + value: number; + constructor(n: number) { + this.value = n; + } + + toDate(): Date { + return new Date(this.value); + } + + greaterThan(other: POSIX) { + return this.value > other.value; + } + + compare(other: POSIX): number { + return this.value > other.value ? 1 : this.value < other.value ? -1 : 0; + } + + static fromUTCDateAndTime(date: DateOnly, time: TimeOfDay): POSIX { + const s = `${date.pretty()}T${time.pretty()}Z`; + const luxonDate = DateTime.fromISO(s, { zone: 'UTC' }); + return POSIX.fromDate(luxonDate.toJSDate()); + } + + toUTCDateAndTime(): { date: DateOnly; time: TimeOfDay } { + const dt = DateTime.fromMillis(this.value, { zone: 'UTC' }); + const date = new DateOnly(dt.year, dt.month, dt.day); + const time = TimeOfDay.fromParts({ + hours: dt.hour, + minutes: dt.minute, + seconds: dt.second, + }); + return { date, time }; + } + + toLocalDateAndTime(): { date: DateOnly; time: TimeOfDay } { + const dt = DateTime.fromMillis(this.value, { zone: 'UTC' }).toLocal(); + const date = new DateOnly(dt.year, dt.month, dt.day); + const time = TimeOfDay.fromParts({ + hours: dt.hour, + minutes: dt.minute, + seconds: dt.second, + }); + return { date, time }; + } + + static schema: s.Schema = s.number.dimap( + (n) => new POSIX(n), + (p) => p.value, + ); +} + +// pad to two digits +const padded = (v: number) => v.toString().padStart(2, '0'); + +// Year, month and day. +class DateOnly { + readonly year: number; + readonly month: number; // 1-12 + readonly day: number; // 1-30ish + + constructor(year: number, month: number, day: number) { + this.year = year; + this.month = month; + this.day = day; + } + + static today(): DateOnly { + return DateOnly.fromDate(new Date()); + } + + static fromDate(date: Date): DateOnly { + return new DateOnly( + date.getFullYear(), + date.getMonth() + 1, + date.getDate(), + ); + } + + pretty() { + return `${this.year}-${padded(this.month)}-${padded(this.day)}`; + } + + static schema: s.Schema = s.string.then( + (str) => { + const parts = str.split('-'); + if (parts.length !== 3) { + return d.fail('Invalid Date'); + } + const year = parseInt(parts[0] as string, 10); + const month = parseInt(parts[1] as string, 10); + const day = parseInt(parts[2] as string, 10); + + if (isNaN(year) || isNaN(month) || isNaN(day)) { + return d.fail('Invalid Date'); + } + + return d.succeed(new DateOnly(year, month, day)); + }, + (date) => date.pretty(), + ); + + greaterThan(other: DateOnly) { + return this.compare(other) == 1; + } + + compare(other: DateOnly): number { + return this.year > other.year + ? 1 + : this.year < other.year + ? -1 + : this.month > other.month + ? 1 + : this.month < other.month + ? -1 + : this.day > other.day + ? 1 + : this.day < other.day + ? -1 + : 0; + } + + addMonths(months: number): DateOnly { + const luxonDate = DateTime.fromObject({ + year: this.year, + month: this.month, + day: this.day, + }); + const newLuxonDate = luxonDate.plus({ months }); + + return new DateOnly( + newLuxonDate.year, + newLuxonDate.month, + newLuxonDate.day, + ); + } +} + +class TimeOfDay { + constructor(readonly seconds: number) {} + + static fromParts({ + hours, + minutes, + seconds, + }: { + hours: number; + minutes: number; + seconds: number; + }): TimeOfDay { + return new TimeOfDay(hours * 60 * 60 + minutes * 60 + seconds); + } + + // HH:MM:SS + pretty() { + const hours = padded(Math.floor(this.seconds / (60 * 60))); + const minutes = padded(Math.floor(this.seconds / 60) % 60); + const seconds = padded(this.seconds % 60); + return `${hours}:${minutes}:${seconds}`; + } +}