feat: version 2 beta
This commit is contained in:
271
libraries/projector.ts
Normal file
271
libraries/projector.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { Subscription } from "../types/common.ts";
|
||||
import type {
|
||||
BatchedProjectionHandler,
|
||||
BatchedProjectorListeners,
|
||||
ProjectionFilter,
|
||||
ProjectionHandler,
|
||||
ProjectionStatus,
|
||||
ProjectorListenerFn,
|
||||
ProjectorListeners,
|
||||
ProjectorMessage,
|
||||
} from "../types/projector.ts";
|
||||
import { EventFactory } from "./event-factory.ts";
|
||||
import { Queue } from "./queue.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Filters
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const FILTER_ONCE = Object.freeze<ProjectionFilter>({
|
||||
allowHydratedEvents: false,
|
||||
allowOutdatedEvents: false,
|
||||
});
|
||||
|
||||
const FILTER_CONTINUOUS = Object.freeze<ProjectionFilter>({
|
||||
allowHydratedEvents: true,
|
||||
allowOutdatedEvents: false,
|
||||
});
|
||||
|
||||
const FILTER_ALL = Object.freeze<ProjectionFilter>({
|
||||
allowHydratedEvents: true,
|
||||
allowOutdatedEvents: true,
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projector
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Manages event projections by handling and distributing events to registered listeners.
|
||||
*
|
||||
* The `Projector` class is responsible for processing event records and invoking
|
||||
* projection handlers based on predefined filters. It supports different projection
|
||||
* patterns, including one-time projections, continuous projections, and catch-all projections.
|
||||
* Additionally, it enables batched event processing for optimized handling of multiple events.
|
||||
*
|
||||
* @template TEventRecord - TType of event records processed by this projector.
|
||||
*/
|
||||
export class Projector<TEventFactory extends EventFactory = EventFactory> {
|
||||
#listeners: ProjectorListeners<TEventFactory["$events"][number]["$record"]> = {};
|
||||
#batchedListeners: BatchedProjectorListeners<TEventFactory["$events"][number]["$record"]> = {};
|
||||
#queues: {
|
||||
[stream: string]: Queue<ProjectorMessage<TEventFactory["$events"][number]["$record"]>>;
|
||||
} = {};
|
||||
|
||||
constructor() {
|
||||
this.push = this.push.bind(this);
|
||||
}
|
||||
|
||||
#makeQueue(stream: string) {
|
||||
this.#queues[stream] = new Queue(
|
||||
async ({ record, status }) => {
|
||||
return Promise.all(Array.from(this.#listeners[record.type as string] || []).map((fn) => fn(record, status)));
|
||||
},
|
||||
{
|
||||
onDrained: () => {
|
||||
delete this.#queues[stream];
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async push(record: TEventFactory["$events"][number]["$record"], status: ProjectionStatus): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
if (this.#queues[record.stream] === undefined) {
|
||||
this.#makeQueue(record.stream);
|
||||
}
|
||||
this.#queues[record.stream].push({ record, status }, resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async pushMany(key: string, records: TEventFactory["$events"][number]["$record"][]): Promise<void> {
|
||||
await Promise.all(Array.from(this.#batchedListeners[key] || []).map((fn) => fn(records)));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Handlers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a batched projection handler taking in a list of events inserted under
|
||||
* a specific batched key.
|
||||
*
|
||||
* @param key - Batch key being projected.
|
||||
* @param handler - Handler method to execute when events are projected.
|
||||
*/
|
||||
batch(key: string, handler: BatchedProjectionHandler<TEventFactory["$events"][number]["$record"]>): Subscription {
|
||||
const listeners = (this.#batchedListeners[key] ?? (this.#batchedListeners[key] = new Set())).add(handler);
|
||||
return {
|
||||
unsubscribe() {
|
||||
listeners.delete(handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single run projection handler.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This method tells the projection that an event is only ever processed when
|
||||
* the event is originating directly from the local event store. A useful
|
||||
* pattern for when you want the event handler to submit data to a third
|
||||
* party service such as sending an email or submitting third party orders.
|
||||
*
|
||||
* We disallow `hydrate` and `outdated` as these events represents events
|
||||
* that has already been processed.
|
||||
*
|
||||
* @param type - Event type being projected.
|
||||
* @param handler - Handler method to execute when event is projected.
|
||||
*/
|
||||
once<
|
||||
TType extends TEventFactory["$events"][number]["$record"]["type"],
|
||||
TRecord extends TEventFactory["$events"][number]["$record"] = Extract<TEventFactory["$events"][number]["$record"], { type: TType }>,
|
||||
TSuccessData extends Record<string, any> | void = void,
|
||||
>(
|
||||
type: TType,
|
||||
handler: ProjectionHandler<TRecord, TSuccessData>,
|
||||
effects: TSuccessData extends void
|
||||
? {
|
||||
onError(res: { error: unknown; record: TRecord }): Promise<void>;
|
||||
onSuccess(res: { record: TRecord }): Promise<void>;
|
||||
}
|
||||
: {
|
||||
onError(res: { error: unknown; record: TRecord }): Promise<void>;
|
||||
onSuccess(res: { data: TSuccessData; record: TRecord }): Promise<void>;
|
||||
},
|
||||
): Subscription {
|
||||
return this.#subscribe(type, FILTER_ONCE, handler as any, effects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a continuous projection handler.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This method tells the projection to allow events directly from the event
|
||||
* store as well as events coming through hydration via sync, manual or
|
||||
* automatic stream rehydration operations. This is the default pattern
|
||||
* used for most events. This is where you usually project the latest data
|
||||
* to your read side models and data stores.
|
||||
*
|
||||
* We allow `hydrate` as they serve to keep the read side up to date with
|
||||
* the latest events. We disallow `outdated` as we do not want the latest
|
||||
* data to be overridden by outdated ones.
|
||||
*
|
||||
* NOTE! The nature of this pattern means that outdated events are never
|
||||
* run by this projection. Make sure to handle `outdated` events if you
|
||||
* have processing requirements that needs to know about every unknown
|
||||
* events that has occurred in the event stream.
|
||||
*
|
||||
* @param type - Event type being projected.
|
||||
* @param handler - Handler method to execute when event is projected.
|
||||
*/
|
||||
on<
|
||||
TType extends TEventFactory["$events"][number]["$record"]["type"],
|
||||
TRecord extends TEventFactory["$events"][number]["$record"] = Extract<TEventFactory["$events"][number]["$record"], { type: TType }>,
|
||||
>(type: TType, handler: ProjectionHandler<TRecord>): Subscription {
|
||||
return this.#subscribe(type, FILTER_CONTINUOUS, handler as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a catch all projection handler.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This method is a catch all for events that does not fall under the
|
||||
* stricter definitions of once and on patterns. This is a good place
|
||||
* to deal with data that does not depend on a strict order of events.
|
||||
*
|
||||
* @param type - Event type being projected.
|
||||
* @param handler - Handler method to execute when event is projected.
|
||||
*/
|
||||
all<
|
||||
TType extends TEventFactory["$events"][number]["$record"]["type"],
|
||||
TRecord extends TEventFactory["$events"][number]["$record"] = Extract<TEventFactory["$events"][number]["$record"], { type: TType }>,
|
||||
>(type: TType, handler: ProjectionHandler<TRecord>): Subscription {
|
||||
return this.#subscribe(type, FILTER_ALL, handler as any);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a event subscription against given type with assigned filter and handler.
|
||||
*
|
||||
* @param type - Event type to listen for.
|
||||
* @param filter - Projection filter to validate against.
|
||||
* @param handler - Handler to execute.
|
||||
*/
|
||||
#subscribe(
|
||||
type: string,
|
||||
filter: ProjectionFilter,
|
||||
handler: ProjectionHandler<TEventFactory["$events"][number]["$record"]>,
|
||||
effects?: {
|
||||
onError(res: { error: unknown; record: TEventFactory["$events"][number]["$record"] }): Promise<void>;
|
||||
onSuccess(res: { data?: unknown; record: TEventFactory["$events"][number]["$record"] }): Promise<void>;
|
||||
},
|
||||
): { unsubscribe: () => void } {
|
||||
return {
|
||||
unsubscribe: this.#addEventListener(type, async (record, state) => {
|
||||
if (this.#hasValidState(filter, state)) {
|
||||
await handler(record)
|
||||
.then((data: unknown) => {
|
||||
effects?.onSuccess({ data, record });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (effects !== undefined) {
|
||||
effects.onError({ error, record });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new event listener to handle incoming projection requests.
|
||||
*
|
||||
* @param type - Event type to listen for.
|
||||
* @param fn - Listener fn to execute.
|
||||
*/
|
||||
#addEventListener(type: string, fn: ProjectorListenerFn<TEventFactory["$events"][number]["$record"]>): () => void {
|
||||
const listeners = (this.#listeners[type] ?? (this.#listeners[type] = new Set())).add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the projection filter is compatible with the provided state.
|
||||
*
|
||||
* @param filter - Projection filter to match against.
|
||||
* @param state - Projection state to validate.
|
||||
*/
|
||||
#hasValidState(filter: ProjectionFilter, { hydrated, outdated }: ProjectionStatus) {
|
||||
if (filter.allowHydratedEvents === false && hydrated === true) {
|
||||
return false;
|
||||
}
|
||||
if (filter.allowOutdatedEvents === false && outdated === true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user