@elysiumjs/moirai 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +171 -2
- package/dist/action.d.ts +211 -0
- package/dist/config.d.ts +58 -0
- package/dist/errors.d.ts +209 -0
- package/dist/event.d.ts +97 -0
- package/dist/hash.d.ts +7 -0
- package/dist/heracles.d.ts +43 -0
- package/dist/heracles.js +1 -0
- package/dist/ids.d.ts +7 -0
- package/dist/index-2t45ppfs.js +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1 -0
- package/dist/moirai.module.d.ts +55 -0
- package/dist/outbox.d.ts +100 -0
- package/dist/stores.d.ts +155 -0
- package/package.json +79 -4
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ProblemValidationError } from '@elysiumjs/core';
|
|
2
|
+
import { ElysiumError } from '@elysiumjs/core';
|
|
3
|
+
declare const IdempotencyKeyRequiredError_base: {
|
|
4
|
+
new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
|
|
5
|
+
name: string;
|
|
6
|
+
message: string;
|
|
7
|
+
stack?: string;
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
value?(): import("elysia/types").MaybePromise<unknown>;
|
|
10
|
+
readonly code: "IDEMPOTENCY_KEY_REQUIRED";
|
|
11
|
+
readonly status: 400;
|
|
12
|
+
readonly detail: string | undefined;
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly errors: readonly ProblemValidationError[] | undefined;
|
|
15
|
+
readonly retryAfterSeconds: number | undefined;
|
|
16
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
17
|
+
readonly headers: Record<string, string> | undefined;
|
|
18
|
+
readonly type: string;
|
|
19
|
+
extension(): Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
isError(error: unknown): error is Error;
|
|
22
|
+
isError(value: unknown): value is Error;
|
|
23
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
24
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
25
|
+
prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
|
|
26
|
+
stackTraceLimit: number;
|
|
27
|
+
id<const Type extends string>(code: Type): import("elysia").TaggedHTTPError<Type>;
|
|
28
|
+
id<const Type extends string, const S_1 extends number | keyof import("elysia").StatusMap>(code: Type, status: S_1): import("elysia").TaggedHTTPError<Type, {
|
|
29
|
+
readonly status: S_1 extends keyof import("elysia").StatusMap ? import("elysia").StatusMap[S_1] : S_1;
|
|
30
|
+
}>;
|
|
31
|
+
typeBase?: string;
|
|
32
|
+
for<const C extends import("@elysiumjs/core").ProblemCode, const S extends number>(code: C, status: S): any;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Raised when an idempotent action is called without an idempotency key. Rendered as a 400
|
|
36
|
+
* problem.
|
|
37
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
38
|
+
*/
|
|
39
|
+
export declare class IdempotencyKeyRequiredError extends IdempotencyKeyRequiredError_base {
|
|
40
|
+
readonly action: string;
|
|
41
|
+
constructor(action: string);
|
|
42
|
+
}
|
|
43
|
+
declare const IdempotencyKeyReusedError_base: {
|
|
44
|
+
new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
|
|
45
|
+
name: string;
|
|
46
|
+
message: string;
|
|
47
|
+
stack?: string;
|
|
48
|
+
cause?: unknown;
|
|
49
|
+
value?(): import("elysia/types").MaybePromise<unknown>;
|
|
50
|
+
readonly code: "IDEMPOTENCY_KEY_REUSED";
|
|
51
|
+
readonly status: 422;
|
|
52
|
+
readonly detail: string | undefined;
|
|
53
|
+
readonly title: string;
|
|
54
|
+
readonly errors: readonly ProblemValidationError[] | undefined;
|
|
55
|
+
readonly retryAfterSeconds: number | undefined;
|
|
56
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
57
|
+
readonly headers: Record<string, string> | undefined;
|
|
58
|
+
readonly type: string;
|
|
59
|
+
extension(): Record<string, unknown>;
|
|
60
|
+
};
|
|
61
|
+
isError(error: unknown): error is Error;
|
|
62
|
+
isError(value: unknown): value is Error;
|
|
63
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
64
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
65
|
+
prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
|
|
66
|
+
stackTraceLimit: number;
|
|
67
|
+
id<const Type extends string>(code: Type): import("elysia").TaggedHTTPError<Type>;
|
|
68
|
+
id<const Type extends string, const S_1 extends number | keyof import("elysia").StatusMap>(code: Type, status: S_1): import("elysia").TaggedHTTPError<Type, {
|
|
69
|
+
readonly status: S_1 extends keyof import("elysia").StatusMap ? import("elysia").StatusMap[S_1] : S_1;
|
|
70
|
+
}>;
|
|
71
|
+
typeBase?: string;
|
|
72
|
+
for<const C extends import("@elysiumjs/core").ProblemCode, const S extends number>(code: C, status: S): any;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Raised when an idempotency key comes back with another action or another input. Rendered as a
|
|
76
|
+
* 422 problem.
|
|
77
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
78
|
+
*/
|
|
79
|
+
export declare class IdempotencyKeyReusedError extends IdempotencyKeyReusedError_base {
|
|
80
|
+
readonly key: string;
|
|
81
|
+
constructor(key: string);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Raised by an {@link IdempotencyStore} when another call stored the same key first. The action
|
|
85
|
+
* retries, and replays the response of the call that won.
|
|
86
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
87
|
+
*/
|
|
88
|
+
export declare class IdempotencyConflictError extends ElysiumError {
|
|
89
|
+
readonly key: string;
|
|
90
|
+
/** Conflicts are transient: the action retries them. */
|
|
91
|
+
readonly transient = true;
|
|
92
|
+
constructor(key: string);
|
|
93
|
+
}
|
|
94
|
+
declare const InvalidInputError_base: {
|
|
95
|
+
new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
|
|
96
|
+
name: string;
|
|
97
|
+
message: string;
|
|
98
|
+
stack?: string;
|
|
99
|
+
cause?: unknown;
|
|
100
|
+
value?(): import("elysia/types").MaybePromise<unknown>;
|
|
101
|
+
readonly code: "VALIDATION_FAILED";
|
|
102
|
+
readonly status: 422;
|
|
103
|
+
readonly detail: string | undefined;
|
|
104
|
+
readonly title: string;
|
|
105
|
+
readonly errors: readonly ProblemValidationError[] | undefined;
|
|
106
|
+
readonly retryAfterSeconds: number | undefined;
|
|
107
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
108
|
+
readonly headers: Record<string, string> | undefined;
|
|
109
|
+
readonly type: string;
|
|
110
|
+
extension(): Record<string, unknown>;
|
|
111
|
+
};
|
|
112
|
+
isError(error: unknown): error is Error;
|
|
113
|
+
isError(value: unknown): value is Error;
|
|
114
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
115
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
116
|
+
prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
|
|
117
|
+
stackTraceLimit: number;
|
|
118
|
+
id<const Type extends string>(code: Type): import("elysia").TaggedHTTPError<Type>;
|
|
119
|
+
id<const Type extends string, const S_1 extends number | keyof import("elysia").StatusMap>(code: Type, status: S_1): import("elysia").TaggedHTTPError<Type, {
|
|
120
|
+
readonly status: S_1 extends keyof import("elysia").StatusMap ? import("elysia").StatusMap[S_1] : S_1;
|
|
121
|
+
}>;
|
|
122
|
+
typeBase?: string;
|
|
123
|
+
for<const C extends import("@elysiumjs/core").ProblemCode, const S extends number>(code: C, status: S): any;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Raised when the input of an action or a query does not match its schema. Rendered as a 422
|
|
127
|
+
* problem, with one entry per mistake in `errors`.
|
|
128
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
129
|
+
*/
|
|
130
|
+
export declare class InvalidInputError extends InvalidInputError_base {
|
|
131
|
+
readonly target: string;
|
|
132
|
+
constructor(target: string, errors: readonly ProblemValidationError[]);
|
|
133
|
+
}
|
|
134
|
+
declare const CallForbiddenError_base: {
|
|
135
|
+
new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
|
|
136
|
+
name: string;
|
|
137
|
+
message: string;
|
|
138
|
+
stack?: string;
|
|
139
|
+
cause?: unknown;
|
|
140
|
+
value?(): import("elysia/types").MaybePromise<unknown>;
|
|
141
|
+
readonly code: "FORBIDDEN";
|
|
142
|
+
readonly status: 403;
|
|
143
|
+
readonly detail: string | undefined;
|
|
144
|
+
readonly title: string;
|
|
145
|
+
readonly errors: readonly ProblemValidationError[] | undefined;
|
|
146
|
+
readonly retryAfterSeconds: number | undefined;
|
|
147
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
148
|
+
readonly headers: Record<string, string> | undefined;
|
|
149
|
+
readonly type: string;
|
|
150
|
+
extension(): Record<string, unknown>;
|
|
151
|
+
};
|
|
152
|
+
isError(error: unknown): error is Error;
|
|
153
|
+
isError(value: unknown): value is Error;
|
|
154
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
155
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
156
|
+
prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
|
|
157
|
+
stackTraceLimit: number;
|
|
158
|
+
id<const Type extends string>(code: Type): import("elysia").TaggedHTTPError<Type>;
|
|
159
|
+
id<const Type extends string, const S_1 extends number | keyof import("elysia").StatusMap>(code: Type, status: S_1): import("elysia").TaggedHTTPError<Type, {
|
|
160
|
+
readonly status: S_1 extends keyof import("elysia").StatusMap ? import("elysia").StatusMap[S_1] : S_1;
|
|
161
|
+
}>;
|
|
162
|
+
typeBase?: string;
|
|
163
|
+
for<const C extends import("@elysiumjs/core").ProblemCode, const S extends number>(code: C, status: S): any;
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Raised when the `authorize` hook of an action refuses the call. Rendered as a 403 problem.
|
|
167
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
168
|
+
*/
|
|
169
|
+
export declare class CallForbiddenError extends CallForbiddenError_base {
|
|
170
|
+
readonly action: string;
|
|
171
|
+
constructor(action: string);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Raised when moirai needs a store that neither the current module (`Moirai.stores()`) nor
|
|
175
|
+
* `MoiraiModule.with()` set.
|
|
176
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
177
|
+
*/
|
|
178
|
+
export declare class MissingStoreError extends ElysiumError {
|
|
179
|
+
readonly store: string;
|
|
180
|
+
readonly module?: string | undefined;
|
|
181
|
+
constructor(store: string, module?: string | undefined);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Raised when an outbox row has a kind no `@Outbox.handle()` handles.
|
|
185
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
186
|
+
*/
|
|
187
|
+
export declare class UnknownEffectError extends ElysiumError {
|
|
188
|
+
readonly kind: string;
|
|
189
|
+
constructor(kind: string);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Raised when two event handlers have the same consumer name.
|
|
193
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
194
|
+
*/
|
|
195
|
+
export declare class DuplicateEventConsumerError extends ElysiumError {
|
|
196
|
+
readonly consumer: string;
|
|
197
|
+
constructor(consumer: string);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Raised when an event is delivered to a handler this process does not have. The delivery
|
|
201
|
+
* retries, so a process that imports the handler later can still handle it.
|
|
202
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
203
|
+
*/
|
|
204
|
+
export declare class UnknownEventConsumerError extends ElysiumError {
|
|
205
|
+
readonly consumer: string;
|
|
206
|
+
readonly event: string;
|
|
207
|
+
constructor(consumer: string, event: string);
|
|
208
|
+
}
|
|
209
|
+
export {};
|
package/dist/event.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The events of the application and their payloads. Extend it to type `Event.publish()` and
|
|
3
|
+
* `@Event.on()`; while it is empty, any name and payload are accepted.
|
|
4
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* declare module '@elysiumjs/moirai' {
|
|
8
|
+
* interface EventCatalogue {
|
|
9
|
+
* 'wallet.credited': { walletId: string; amount: number };
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export interface EventCatalogue {
|
|
15
|
+
}
|
|
16
|
+
/** The name of an event: a key of {@link EventCatalogue}, or any string while it is empty. */
|
|
17
|
+
export type EventName = [keyof EventCatalogue] extends [never] ? string : keyof EventCatalogue & string;
|
|
18
|
+
/** The payload of an event: its type in {@link EventCatalogue}, else any. */
|
|
19
|
+
export type EventPayload<E extends string> = E extends keyof EventCatalogue ? EventCatalogue[E] : any;
|
|
20
|
+
/**
|
|
21
|
+
* What a handler receives besides the payload.
|
|
22
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
23
|
+
*/
|
|
24
|
+
export type EventMeta = {
|
|
25
|
+
/** The id of the event, the same for every handler. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** The name of the event. */
|
|
28
|
+
event: string;
|
|
29
|
+
/** When the event was published. */
|
|
30
|
+
publishedAt: Date;
|
|
31
|
+
/** The attempt of this handler, from 1. */
|
|
32
|
+
attempt: number;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Options of {@link Event.on}.
|
|
36
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
37
|
+
*/
|
|
38
|
+
export type EventHandlerOptions = {
|
|
39
|
+
/**
|
|
40
|
+
* The name of the handler, which records the events it handled. Defaults to `Class.method`.
|
|
41
|
+
* Keep it when the method is renamed, so that the handled events are not handled again.
|
|
42
|
+
*/
|
|
43
|
+
consumer?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The attempts of the handler, the first included. Defaults to the `maxRelayAttempts` of the
|
|
46
|
+
* module; `false` gives one attempt. A handler that fails them all is dead (`onDead`).
|
|
47
|
+
*/
|
|
48
|
+
retry?: false | {
|
|
49
|
+
attempts?: number;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
/** The kind of the outbox rows that deliver an event to one handler. */
|
|
53
|
+
export declare const MOIRAI_EVENT_EFFECT = "moirai.event";
|
|
54
|
+
/** @internal Forgets the registered handlers, for tests. */
|
|
55
|
+
export declare const clearEventHandlers: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Domain events: what happened, published in the unit of work that made it happen, and handled
|
|
58
|
+
* once by each handler, after it commits. Each handler gets its own delivery through the outbox,
|
|
59
|
+
* so it retries and fails on its own, and records the events it handled in the inbox, in the
|
|
60
|
+
* unit of work of its own writes. Delivery needs the `outbox` and `inbox` stores of
|
|
61
|
+
* `MoiraiModule`.
|
|
62
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
63
|
+
*/
|
|
64
|
+
export declare namespace Event {
|
|
65
|
+
/**
|
|
66
|
+
* Marks a method as a handler of an event. Static methods are called on the class; instance
|
|
67
|
+
* methods run on the registered service, or on a new instance with its dependencies.
|
|
68
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
69
|
+
* @param event The name of the event.
|
|
70
|
+
* @param options The name of the handler and its attempts.
|
|
71
|
+
* @throws {DuplicateEventConsumerError} When another handler has the same `consumer`.
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* class Receipts {
|
|
75
|
+
* @Event.on('wallet.credited', { consumer: 'billing.send-receipt', retry: { attempts: 5 } })
|
|
76
|
+
* public async send(payload: { walletId: string }, meta: EventMeta) {}
|
|
77
|
+
* }
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
const on: <const E extends EventName>(event: E, options?: EventHandlerOptions) => <T extends (payload: EventPayload<E>, meta: EventMeta) => unknown>(target: object, method: string | symbol, _descriptor: TypedPropertyDescriptor<T>) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Publishes an event in the open unit of work, through the outbox of the current module: each
|
|
83
|
+
* handler of the event gets it once the unit
|
|
84
|
+
* commits, and never when it rolls back.
|
|
85
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
86
|
+
* @param event The name of the event.
|
|
87
|
+
* @param payload The JSON-safe payload.
|
|
88
|
+
* @returns The id of the event.
|
|
89
|
+
*/
|
|
90
|
+
const publish: <const E extends EventName>(event: E, payload: EventPayload<E>) => Promise<string>;
|
|
91
|
+
/**
|
|
92
|
+
* The consumers of an event.
|
|
93
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
94
|
+
* @param event The name of the event.
|
|
95
|
+
*/
|
|
96
|
+
const consumersOf: (event: string) => string[];
|
|
97
|
+
}
|
package/dist/hash.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fingerprint of an action input: the hex SHA-256 of its canonical JSON, so that the same input
|
|
3
|
+
* gives the same hash whatever the order of its keys.
|
|
4
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
5
|
+
* @param input The validated input.
|
|
6
|
+
*/
|
|
7
|
+
export declare const requestHash: (input: unknown) => Promise<string>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { JobClass, JobDispatchOptions } from '@elysiumjs/heracles';
|
|
2
|
+
import type { OutboxEffect } from './stores';
|
|
3
|
+
import { ElysiumError } from '@elysiumjs/core';
|
|
4
|
+
/** The kind of the effects that dispatch a Heracles job. */
|
|
5
|
+
export declare const HERACLES_JOB_EFFECT = "heracles.job";
|
|
6
|
+
/**
|
|
7
|
+
* An outbox effect that dispatches a Heracles job once the action commits, with the retries of
|
|
8
|
+
* the outbox until the job is queued.
|
|
9
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
10
|
+
* @param jobClass The job class, registered in a module's `jobs`.
|
|
11
|
+
* @param args The arguments of the job, JSON-safe.
|
|
12
|
+
* @param options The dispatch options.
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* await Outbox.enqueue(job(SendReceiptJob, [order.id]));
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare const job: <TClass extends JobClass<any>>(jobClass: TClass, args: ConstructorParameters<TClass>, options?: JobDispatchOptions) => OutboxEffect;
|
|
19
|
+
/**
|
|
20
|
+
* Raised when `@Once()` goes on a class without an `execute()` method.
|
|
21
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
22
|
+
*/
|
|
23
|
+
export declare class InvalidOnceJobError extends ElysiumError {
|
|
24
|
+
constructor(name: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Runs a Heracles job once per dispatch id: its `execute()` runs in a unit of work with an inbox
|
|
28
|
+
* record of the dispatch id, so a delivery that comes again, or a tick handled twice, does nothing.
|
|
29
|
+
* Made for the jobs of a `Scheduler`, whose dispatch id names the scheduler, the case and the
|
|
30
|
+
* tick. Without a database module, the record and the work run without a transaction.
|
|
31
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
32
|
+
* The record goes to the inbox of the module that registers the job.
|
|
33
|
+
* @param options The inbox consumer. Defaults to the job name.
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* @Job.register({ queue: 'billing' })
|
|
37
|
+
* @Once()
|
|
38
|
+
* class BillInvoice extends Job { ... }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare const Once: (options?: {
|
|
42
|
+
consumer?: string;
|
|
43
|
+
}) => (target: Function) => void;
|
package/dist/heracles.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{registerEffect2,Inbox2}from"./index-2t45ppfs.js";import{DATABASE_TRANSACTION_SERVICE,ElysiumError,ModuleContext,Service}from"@elysiumjs/core";import{Heracles,JobRegistry}from"@elysiumjs/heracles";import{UnitOfWork}from"@elysiumjs/mnemosyne";var HERACLES_JOB_EFFECT="heracles.job";registerEffect2(HERACLES_JOB_EFFECT,async(payload)=>{let{job,args,options}=payload,entry=JobRegistry.get(job);if(!entry)throw Error(`No Heracles job is registered as ${job}.`);await Heracles.dispatch(entry.jobClass,args,options)});var job=(jobClass,args,options)=>{let name=JobRegistry.all().find((entry)=>entry.jobClass===jobClass)?.name;if(!name)throw Error(`${jobClass.name} is not registered in a module's jobs.`);return{kind:HERACLES_JOB_EFFECT,payload:{job:name,args,options}}};class InvalidOnceJobError extends ElysiumError{constructor(name){super(`@Once() goes on a Heracles job class with an execute() method, which ${name} has not.`)}}var Once=(options={})=>(target)=>{let proto=target.prototype,execute=proto.execute;if(typeof execute!=="function")throw new InvalidOnceJobError(target.name);proto.execute=async function(...args){let entry=JobRegistry.all().find((item)=>item.jobClass===this.constructor),consumer=options.consumer??entry?.name??target.name,work=()=>Inbox2.once({messageId:this.dispatchId,consumer},async()=>{await execute.apply(this,args)}),run=()=>Service.exists(DATABASE_TRANSACTION_SERVICE)?UnitOfWork.run(work):work();await(entry&&entry.module!==ModuleContext.current()?ModuleContext.run(entry.module,run):run())}};export{HERACLES_JOB_EFFECT,InvalidOnceJobError,Once,job};
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A UUID version 7: a millisecond timestamp followed by random bits, so that ids sort by creation
|
|
3
|
+
* time.
|
|
4
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
5
|
+
* @param now The time of the id. Defaults to now.
|
|
6
|
+
*/
|
|
7
|
+
export declare const uuidv7: (now?: number) => string;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{ModuleContext,Service}from"@elysiumjs/core";import{DomainError,ElysiumError}from"@elysiumjs/core";class IdempotencyKeyRequiredError2 extends DomainError.for("IDEMPOTENCY_KEY_REQUIRED",400){action;constructor(action){super(`The ${action} action needs an Idempotency-Key.`,{extensions:{action}});this.action=action}}class IdempotencyKeyReusedError2 extends DomainError.for("IDEMPOTENCY_KEY_REUSED",422){key;constructor(key){super("This Idempotency-Key was already used for another request.",{extensions:{idempotencyKey:key}});this.key=key}}class IdempotencyConflictError2 extends ElysiumError{key;transient=!0;constructor(key){super(`The idempotency key ${key} was stored by another call.`);this.key=key}}class InvalidInputError2 extends DomainError.for("VALIDATION_FAILED",422){target;constructor(target,errors){super(`The input of ${target} is invalid.`,{errors});this.target=target}}class CallForbiddenError2 extends DomainError.for("FORBIDDEN",403){action;constructor(action){super(`You may not run the ${action} action.`);this.action=action}}class MissingStoreError2 extends ElysiumError{store;module;constructor(store,module){super(`Moirai has no ${store} store`+(module?` in module ${module}`:"")+`. Set one with MoiraiModule.with({ ${store}: ... }) in the application, or with Moirai.stores({ ${store}: ... }) in the services of the module.`);this.store=store;this.module=module}}class UnknownEffectError2 extends ElysiumError{kind;constructor(kind){super(`No @Outbox.handle('${kind}') handles the outbox rows of kind "${kind}". Effects register when their module is imported, so the process that relays must import it`+(kind==="heracles.job"?" (here '@elysiumjs/moirai/heracles').":"."));this.kind=kind}}class DuplicateEventConsumerError2 extends ElysiumError{consumer;constructor(consumer){super(`Two event handlers are named "${consumer}". Give one of them another consumer.`);this.consumer=consumer}}class UnknownEventConsumerError2 extends ElysiumError{consumer;event;constructor(consumer,event){super(`No @Event.on('${event}') handler is named "${consumer}" here. Handlers register when their module is imported, so the process that relays must import it.`);this.consumer=consumer;this.event=event}}var MOIRAI_IDEMPOTENCY_STORE2="moirai.idempotency",MOIRAI_OUTBOX_STORE2="moirai.outbox",MOIRAI_INBOX_STORE2="moirai.inbox",MOIRAI_ACTION_LOCK2="moirai.lock",MOIRAI_OPTIONS2="moirai.options",MOIRAI_MODULE_OPTIONS2="moirai.module-options",TRANSIENT_CODES=new Set(["40001","40P01"]),isTransientError2=(error)=>{if(typeof error!=="object"||error===null)return!1;let{transient,code,errno,cause}=error;if(transient===!0)return!0;if(typeof code==="string"&&TRANSIENT_CODES.has(code))return!0;if(typeof errno==="string"&&TRANSIENT_CODES.has(errno))return!0;return cause!==void 0&&cause!==error&&isTransientError2(cause)},DEFAULT_OPTIONS={maxAttempts:3,isTransient:isTransientError2,relayOnCommit:!0,maxRelayAttempts:8,relayBatch:50,relayLease:300},lookup=(name)=>Service.get(name),options=()=>{let base=lookup(MOIRAI_OPTIONS2)??DEFAULT_OPTIONS,own=lookup(MOIRAI_MODULE_OPTIONS2);return own?{...base,...own}:base},store=(name,kind)=>()=>{let found=lookup(name);if(!found)throw new MissingStoreError2(kind,ModuleContext.current()?.id);return found},idempotencyStore=store(MOIRAI_IDEMPOTENCY_STORE2,"idempotency"),outboxStore=store(MOIRAI_OUTBOX_STORE2,"outbox"),inboxStore=store(MOIRAI_INBOX_STORE2,"inbox"),actionLock=store(MOIRAI_ACTION_LOCK2,"lock"),inOwnerModule=(service,work)=>{let owner=Service.ownersOf(service)[0];return owner&&owner!==ModuleContext.current()?ModuleContext.run(owner,work):work()};var uuidv72=(now=Date.now())=>{let bytes=crypto.getRandomValues(new Uint8Array(16)),time=now;for(let i=5;i>=0;i--)bytes[i]=time%256,time=Math.floor(time/256);bytes[6]=bytes[6]&15|112,bytes[8]=bytes[8]&63|128;let hex=[...bytes].map((byte)=>byte.toString(16).padStart(2,"0")).join("");return`${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`};import{DATABASE_TRANSACTION_SERVICE,getLogger,ModuleContext as ModuleContext2,Service as Service2}from"@elysiumjs/core";import{UnitOfWork}from"@elysiumjs/mnemosyne";var EFFECTS=new Map,ATTEMPTS=new Map,effectHandler2=(kind)=>EFFECTS.get(kind),registerEffect2=(kind,handler,policy={})=>{if(EFFECTS.set(kind,handler),policy.attempts)ATTEMPTS.set(kind,policy.attempts);else ATTEMPTS.delete(kind)},BASE_DELAY_MS=5000,MAX_DELAY_MS=3600000,backoffMs2=(attempts)=>Math.min(BASE_DELAY_MS*2**Math.max(0,attempts-1),MAX_DELAY_MS),inModule=(node,work)=>node&&node!==ModuleContext2.current()?ModuleContext2.run(node,work):work(),outboxModule=(module)=>{if(!module)return;let node=Service2.ownersOf(MOIRAI_OUTBOX_STORE2).find((owner)=>owner.moduleClass===module);if(!node)throw new MissingStoreError2("outbox",module.name);return node},asError=(error)=>error instanceof Error?error:Error(String(error)),inUnitOfWork=(work)=>Service2.exists(DATABASE_TRANSACTION_SERVICE)?UnitOfWork.run(work):work(),Outbox2;((Outbox)=>{Outbox.handle=(kind)=>(target,method)=>{EFFECTS.set(kind,(payload,row)=>{let owner=target.constructor;return(Service2.get(owner)??Service2.make(owner))[method](payload,row)})},Outbox.enqueue=async(...effects)=>{if(effects.length===0)return[];let now=new Date,rows=effects.map((effect)=>({id:uuidv72(now.getTime()),kind:effect.kind,payload:structuredClone(effect.payload),createdAt:now,availableAt:effect.availableAt??now,attempts:0,lastError:null,dispatchedAt:null,deadAt:null}));await outboxStore().insert(rows);let config=options(),module=ModuleContext2.current();if(config.relayOnCommit){let ids=rows.filter((row)=>row.availableAt<=now).map((row)=>row.id);if(ids.length>0)await UnitOfWork.afterCommit(()=>{let work=inModule(module,()=>Outbox.relay({ids})).catch((error)=>getLogger().error("The outbox relay after a commit failed",{error}));if(config.defer)config.defer(work);else return work})}return rows.map((row)=>row.id)},Outbox.relay=(relayOptions={})=>inModule(outboxModule(relayOptions.module),()=>relayRows(relayOptions));let relayRows=async(relayOptions)=>{let config=options(),store=outboxStore(),summary={dispatched:0,retried:0,dead:0},now=new Date,rows=await inUnitOfWork(()=>store.claimDue({now,leaseUntil:new Date(now.getTime()+config.relayLease*1000),limit:relayOptions.batch??config.relayBatch,kinds:relayOptions.kinds,ids:relayOptions.ids}));for(let row of rows)try{await inUnitOfWork(async()=>{let handler=effectHandler2(row.kind);if(!handler)throw new UnknownEffectError2(row.kind);await handler(row.payload,row),await store.markDispatched(row.id,new Date)}),summary.dispatched++}catch(cause){let error=asError(cause),attempts=row.attempts+1,failedAt=new Date,maxAttempts=ATTEMPTS.get(row.kind)?.(row)??config.maxRelayAttempts;if(attempts>=maxAttempts)await inUnitOfWork(()=>store.markDead(row.id,{attempts,lastError:error.message,deadAt:failedAt})),config.onDead?.(row,error),getLogger().error("An outbox row is dead",{id:row.id,kind:row.kind,error}),summary.dead++;else{let availableAt=new Date(failedAt.getTime()+backoffMs2(attempts));await inUnitOfWork(()=>store.markFailed(row.id,{attempts,lastError:error.message,availableAt})),summary.retried++}}return summary};Outbox.sweep=async(relayOptions={})=>{let total={dispatched:0,retried:0,dead:0},drain=async()=>{for(;;){let summary=await relayRows(relayOptions);if(total.dispatched+=summary.dispatched,total.retried+=summary.retried,total.dead+=summary.dead,summary.dispatched+summary.retried+summary.dead===0)return}};if(relayOptions.module)return await inModule(outboxModule(relayOptions.module),drain),total;if(Service2.exists(MOIRAI_OUTBOX_STORE2))await ModuleContext2.outside(drain);for(let owner of Service2.ownersOf(MOIRAI_OUTBOX_STORE2))await ModuleContext2.run(owner,drain);return total}})(Outbox2||={});var Inbox2;((Inbox)=>Inbox.once=async(message,work)=>{if(!await inboxStore().record(message.messageId,message.consumer))return!1;return await work(),!0})(Inbox2||={});
|
|
2
|
+
export{IdempotencyKeyRequiredError2,IdempotencyKeyReusedError2,IdempotencyConflictError2,InvalidInputError2,CallForbiddenError2,MissingStoreError2,UnknownEffectError2,DuplicateEventConsumerError2,UnknownEventConsumerError2,MOIRAI_IDEMPOTENCY_STORE2,MOIRAI_OUTBOX_STORE2,MOIRAI_INBOX_STORE2,MOIRAI_ACTION_LOCK2,MOIRAI_OPTIONS2,MOIRAI_MODULE_OPTIONS2,isTransientError2,DEFAULT_OPTIONS,options,idempotencyStore,actionLock,inOwnerModule,uuidv72,effectHandler2,registerEffect2,backoffMs2,Outbox2,Inbox2};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { type ActionClass, type ActionOptions, type ActionOutcome, type ActionRetry, type HandlerContext, type HandlerResult, type QueryClass, type QueryOptions, type QueryOutcome, Action, Moirai, Query } from './action';
|
|
2
|
+
export { isTransientError, MOIRAI_ACTION_LOCK, MOIRAI_IDEMPOTENCY_STORE, MOIRAI_INBOX_STORE, MOIRAI_MODULE_OPTIONS, MOIRAI_OPTIONS, MOIRAI_OUTBOX_STORE, type MoiraiOptions } from './config';
|
|
3
|
+
export { CallForbiddenError, DuplicateEventConsumerError, IdempotencyConflictError, IdempotencyKeyRequiredError, IdempotencyKeyReusedError, InvalidInputError, MissingStoreError, UnknownEventConsumerError, UnknownEffectError } from './errors';
|
|
4
|
+
export { type EventCatalogue, type EventHandlerOptions, type EventMeta, type EventName, type EventPayload, Event, MOIRAI_EVENT_EFFECT } from './event';
|
|
5
|
+
export { requestHash } from './hash';
|
|
6
|
+
export { uuidv7 } from './ids';
|
|
7
|
+
export { MoiraiModule, type MoiraiModuleConfig, type MoiraiStores } from './moirai.module';
|
|
8
|
+
export { type RelayOptions, type RelaySummary, backoffMs, effectHandler, Inbox, Outbox, registerEffect } from './outbox';
|
|
9
|
+
export { type ActionLock, type IdempotencyRecord, type IdempotencyStore, type InboxStore, MemoryActionLock, MemoryIdempotencyStore, MemoryInboxStore, MemoryOutboxStore, type OutboxClaim, type OutboxClaimFilter, type OutboxEffect, type OutboxRow, type OutboxStore } from './stores';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{IdempotencyKeyRequiredError2,IdempotencyKeyReusedError2,IdempotencyConflictError2,InvalidInputError2,CallForbiddenError2,MissingStoreError2,UnknownEffectError2,DuplicateEventConsumerError2,UnknownEventConsumerError2,MOIRAI_IDEMPOTENCY_STORE2,MOIRAI_OUTBOX_STORE2,MOIRAI_INBOX_STORE2,MOIRAI_ACTION_LOCK2,MOIRAI_OPTIONS2,MOIRAI_MODULE_OPTIONS2,isTransientError2,DEFAULT_OPTIONS,options,idempotencyStore,actionLock,inOwnerModule,uuidv72,effectHandler2,registerEffect2,backoffMs2,Outbox2,Inbox2}from"./index-2t45ppfs.js";var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};import{currentUserId,DATABASE_TRANSACTION_SERVICE,DomainError,Metadata,Service}from"@elysiumjs/core";import{UnitOfWork}from"@elysiumjs/mnemosyne";import Value from"typebox/value";var canonical=(value)=>{if(typeof value==="bigint")return value.toString();if(Array.isArray(value))return value.map(canonical);if(value instanceof Date)return value.toISOString();if(value!==null&&typeof value==="object")return Object.fromEntries(Object.keys(value).sort().map((key)=>[key,canonical(value[key])]));return value},requestHash=async(input)=>{let bytes=new TextEncoder().encode(JSON.stringify(canonical(input))??"null"),digest=await crypto.subtle.digest("SHA-256",bytes);return[...new Uint8Array(digest)].map((byte)=>byte.toString(16).padStart(2,"0")).join("")};import{Module}from"@elysiumjs/core";var build=(option)=>typeof option==="function"?option():option,definitions=(config,global)=>{let{idempotency,outbox,inbox,lock,...rest}=config,stores=[[MOIRAI_IDEMPOTENCY_STORE2,idempotency],[MOIRAI_OUTBOX_STORE2,outbox],[MOIRAI_INBOX_STORE2,inbox],[MOIRAI_ACTION_LOCK2,lock]];return[...global?[{name:MOIRAI_OPTIONS2,global,factory:()=>({...DEFAULT_OPTIONS,...rest})}]:Object.keys(rest).length>0?[{name:MOIRAI_MODULE_OPTIONS2,global,factory:()=>({...rest})}]:[],...stores.filter(([,option])=>option!==void 0).map(([name,option])=>({name,global,factory:()=>build(option)}))]},moduleStores=(config)=>definitions(config,!1);class MoiraiModule extends Module.configurable(){}MoiraiModule=__legacyDecorateClassTS([Module.register({services:(config)=>definitions(config,!0)})],MoiraiModule);var ACTION=Symbol.for("elysium:moirai:action"),QUERY=Symbol.for("elysium:moirai:query");class Action{static register(props){return(target)=>{Metadata.define(ACTION,{...props,name:props.name??target.name},target)}}}class Query{static register(props){return(target)=>{Metadata.define(QUERY,{...props,name:props.name??target.name},target)}}}var instanceOf=(target)=>Service.get(target)??Service.make(target),validate=(schema,raw,name)=>{let value=Value.Default(schema,structuredClone(raw)),errors=[...Value.Errors(schema,value)];if(errors.length>0)throw new InvalidInputError2(name,errors.map((error)=>({pointer:`/${error.instancePath?.replace(/^\//,"")??""}`,message:error.message})));return value},unwrap=(result)=>{if(result instanceof DomainError)throw result;return result},settle=async(call)=>{try{return await call()}catch(error){if(error instanceof DomainError)return error;throw error}},Moirai;((Moirai)=>{Moirai.execute=(action,input,call={})=>settle(()=>{let props=Metadata.get(ACTION,action);if(!props)throw TypeError(`${action.name} is not marked with @Action.register().`);return inOwnerModule(action,()=>run(action,props,input,call))}),Moirai.stores=(stores)=>moduleStores(stores);let run=async(action,props,input,call)=>{let{name,idempotent=!1,transactional=!0,retry=!0}=props,config=options(),value=validate(props.input,input,name);if(props.authorize&&!await props.authorize(value))throw new CallForbiddenError2(name);let key=call.idempotencyKey;if(idempotent&&!key)throw new IdempotencyKeyRequiredError2(name);let storedKey=idempotent?[call.scope??props.scope?.(value)??currentUserId()??"anonymous",name,key].map(encodeURIComponent).join(":"):void 0,body=async(attempt)=>{let release=props.serializeBy?await actionLock().lock(props.serializeBy(value)):void 0;try{let handler=instanceOf(action),context={name,idempotencyKey:key,attempt};if(!idempotent)return unwrap(await handler.handle(value,context));let store=idempotencyStore(),hash=await requestHash(value),seen=await store.get(storedKey);if(seen){if(seen.action===name&&seen.requestHash===hash)return seen.response;throw new IdempotencyKeyReusedError2(key)}let response=unwrap(await handler.handle(value,context));return await store.put({key:storedKey,action:name,requestHash:hash,response:response===void 0?null:JSON.parse(JSON.stringify(response)),createdAt:new Date}),response}finally{release?.()}},transaction=transactional&&Service.exists(DATABASE_TRANSACTION_SERVICE);if(transaction&&UnitOfWork.active())return body(1);for(let attempt=1;;attempt++)try{return transaction?await UnitOfWork.run(()=>body(attempt),{connection:props.connection}):await body(attempt)}catch(error){let policy=typeof retry==="object"?retry:{},attempts=retry===!1?1:policy.attempts??config.maxAttempts,transient=policy.isTransient??config.isTransient;if(!(attempt<attempts&&transient(error)))throw error}};Moirai.ask=(query,input)=>settle(async()=>{let props=Metadata.get(QUERY,query);if(!props)throw TypeError(`${query.name} is not marked with @Query.register().`);let value=validate(props.input,input,props.name);if(props.authorize&&!await props.authorize(value))throw new CallForbiddenError2(props.name);return instanceOf(query).handle(value,{name:props.name,attempt:1})})})(Moirai||={});import{Service as Service2}from"@elysiumjs/core";var MOIRAI_EVENT_EFFECT="moirai.event",CONSUMERS=new Map;registerEffect2(MOIRAI_EVENT_EFFECT,async(payload,row)=>{let delivery=payload,handler=CONSUMERS.get(delivery.consumer);if(!handler)throw new UnknownEventConsumerError2(delivery.consumer,delivery.event);await inOwnerModule(handler.owner,()=>Inbox2.once({messageId:delivery.id,consumer:delivery.consumer},async()=>{await handler.run(delivery.payload,{id:delivery.id,event:delivery.event,publishedAt:new Date(delivery.publishedAt),attempt:row.attempts+1})}))},{attempts:(row)=>CONSUMERS.get(row.payload?.consumer??"")?.attempts});var Event;((Event)=>(Event.on=(event,options={})=>(target,method,_descriptor)=>{let isStatic=typeof target==="function",owner=isStatic?target:target.constructor,consumer=options.consumer??`${owner.name}.${String(method)}`;if(CONSUMERS.has(consumer))throw new DuplicateEventConsumerError2(consumer);let{retry}=options;CONSUMERS.set(consumer,{event,owner,attempts:retry===!1?1:retry?.attempts,run:(payload,meta)=>(isStatic?owner:Service2.get(owner)??Service2.make(owner))[method](payload,meta)})},Event.publish=async(event,payload)=>{let publishedAt=new Date,id=uuidv72(publishedAt.getTime()),deliveries=[...CONSUMERS].filter(([,handler])=>handler.event===event).map(([consumer])=>({kind:MOIRAI_EVENT_EFFECT,payload:{id,event,consumer,payload,publishedAt:publishedAt.toISOString()}}));return await Outbox2.enqueue(...deliveries),id},Event.consumersOf=(event)=>[...CONSUMERS].filter(([,handler])=>handler.event===event).map(([consumer])=>consumer)))(Event||={});import{UnitOfWork as UnitOfWork2}from"@elysiumjs/mnemosyne";class MemoryIdempotencyStore{#records=new Map;async get(key){let record=this.#records.get(key);return record?structuredClone(record):void 0}async put(record){if(this.#records.has(record.key))throw new IdempotencyConflictError2(record.key);this.#records.set(record.key,structuredClone(record))}}class MemoryActionLock{#tails=new Map;async lock(key){let previous=this.#tails.get(key)??Promise.resolve(),release,current=new Promise((resolve)=>release=resolve),tail=previous.then(()=>current);return this.#tails.set(key,tail),await previous,()=>{if(release(),this.#tails.get(key)===tail)this.#tails.delete(key)}}}class MemoryOutboxStore{#rows=new Map;get rows(){return[...this.#rows.values()].map((row)=>({...row}))}async insert(rows){for(let row of rows)this.#rows.set(row.id,{...row});UnitOfWork2.afterRollback(()=>{for(let row of rows)this.#rows.delete(row.id)})}async claimDue(claim){let{now,leaseUntil,limit,kinds,ids}=claim;if(kinds?.length===0||ids?.length===0)return[];let due=[...this.#rows.values()].filter((row)=>row.dispatchedAt===null&&row.deadAt===null&&row.availableAt.getTime()<=now.getTime()&&(!kinds||kinds.includes(row.kind))&&(!ids||ids.includes(row.id))).sort((a,b)=>a.availableAt.getTime()-b.availableAt.getTime()).slice(0,limit);for(let row of due)this.#rows.set(row.id,{...row,availableAt:leaseUntil});return due.map((row)=>({...row,availableAt:leaseUntil}))}async markDispatched(id,dispatchedAt){this.#update(id,{dispatchedAt})}async markFailed(id,update){this.#update(id,update)}async markDead(id,update){this.#update(id,update)}#update(id,update){let row=this.#rows.get(id);if(row)this.#rows.set(id,{...row,...update})}}class MemoryInboxStore{#seen=new Set;async record(messageId,consumer){let key=`${consumer}\x00${messageId}`;if(this.#seen.has(key))return!1;return this.#seen.add(key),UnitOfWork2.afterRollback(()=>void this.#seen.delete(key)),!0}}export{Action,CallForbiddenError2 as CallForbiddenError,DuplicateEventConsumerError2 as DuplicateEventConsumerError,Event,IdempotencyConflictError2 as IdempotencyConflictError,IdempotencyKeyRequiredError2 as IdempotencyKeyRequiredError,IdempotencyKeyReusedError2 as IdempotencyKeyReusedError,Inbox2 as Inbox,InvalidInputError2 as InvalidInputError,MOIRAI_ACTION_LOCK2 as MOIRAI_ACTION_LOCK,MOIRAI_EVENT_EFFECT,MOIRAI_IDEMPOTENCY_STORE2 as MOIRAI_IDEMPOTENCY_STORE,MOIRAI_INBOX_STORE2 as MOIRAI_INBOX_STORE,MOIRAI_MODULE_OPTIONS2 as MOIRAI_MODULE_OPTIONS,MOIRAI_OPTIONS2 as MOIRAI_OPTIONS,MOIRAI_OUTBOX_STORE2 as MOIRAI_OUTBOX_STORE,MemoryActionLock,MemoryIdempotencyStore,MemoryInboxStore,MemoryOutboxStore,MissingStoreError2 as MissingStoreError,Moirai,MoiraiModule,Outbox2 as Outbox,Query,UnknownEffectError2 as UnknownEffectError,UnknownEventConsumerError2 as UnknownEventConsumerError,backoffMs2 as backoffMs,effectHandler2 as effectHandler,isTransientError2 as isTransientError,registerEffect2 as registerEffect,requestHash,uuidv72 as uuidv7};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ServiceDefinition } from '@elysiumjs/core';
|
|
2
|
+
import type { MoiraiOptions } from './config';
|
|
3
|
+
import type { ActionLock, IdempotencyStore, InboxStore, OutboxStore } from './stores';
|
|
4
|
+
type StoreOption<T> = T | (() => T);
|
|
5
|
+
/**
|
|
6
|
+
* Stores, each an instance or a function that builds it once, and options.
|
|
7
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
8
|
+
*/
|
|
9
|
+
export type MoiraiStores = Partial<MoiraiOptions> & {
|
|
10
|
+
idempotency?: StoreOption<IdempotencyStore>;
|
|
11
|
+
outbox?: StoreOption<OutboxStore>;
|
|
12
|
+
inbox?: StoreOption<InboxStore>;
|
|
13
|
+
lock?: StoreOption<ActionLock>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The configuration of {@link MoiraiModule}: the stores of the application and the options.
|
|
17
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
18
|
+
*/
|
|
19
|
+
export type MoiraiModuleConfig = MoiraiStores;
|
|
20
|
+
/** @internal The services of `Moirai.stores()`: private to the module that lists them. */
|
|
21
|
+
export declare const moduleStores: (config: MoiraiStores) => ServiceDefinition[];
|
|
22
|
+
declare const MoiraiModule_base: (abstract new () => {
|
|
23
|
+
readonly config: MoiraiStores;
|
|
24
|
+
onInit(): Promise<void> | void;
|
|
25
|
+
onDestroy(): Promise<void> | void;
|
|
26
|
+
onServe(_app: import("elysia").Elysia<any>): Promise<void> | void;
|
|
27
|
+
}) & {
|
|
28
|
+
readonly config: string;
|
|
29
|
+
with(config: Partial<MoiraiStores> | (() => Partial<MoiraiStores> | Promise<Partial<MoiraiStores>>)): import("@elysiumjs/core").ConfiguredModule<MoiraiStores>;
|
|
30
|
+
resolveConfig(input?: Partial<MoiraiStores> | undefined): MoiraiStores;
|
|
31
|
+
register<TConfig_1 = any>(props?: import("@elysiumjs/core").ModuleProps<TConfig_1>): (target: import("@elysiumjs/core").ModuleClass) => void;
|
|
32
|
+
configurable<TConfig extends object>(options?: {
|
|
33
|
+
defaults?: Partial<TConfig>;
|
|
34
|
+
required?: (keyof TConfig)[];
|
|
35
|
+
}): any;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Provides the moirai stores and options of the application as global services. Set the stores
|
|
39
|
+
* the app uses: the memory ones (`MemoryIdempotencyStore`, ...) for tests, the Postgres ones of
|
|
40
|
+
* `@elysiumjs/moirai-drizzle` in production. A module that needs stores of its own, such as the
|
|
41
|
+
* outbox and inbox tables of a domain, lists them with `Moirai.stores()` in its services.
|
|
42
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* MoiraiModule.with({
|
|
46
|
+
* idempotency: () => new DrizzleIdempotencyStore({ table: tables.idempotencyKeys }),
|
|
47
|
+
* outbox: () => new DrizzleOutboxStore({ table: tables.outbox }),
|
|
48
|
+
* inbox: () => new DrizzleInboxStore({ table: tables.inbox }),
|
|
49
|
+
* lock: () => new PgAdvisoryLock()
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export declare class MoiraiModule extends MoiraiModule_base {
|
|
54
|
+
}
|
|
55
|
+
export {};
|
package/dist/outbox.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { OutboxEffect, OutboxRow } from './stores';
|
|
2
|
+
type EffectHandler = (payload: unknown, row: OutboxRow) => unknown;
|
|
3
|
+
/** The handler of a kind of effect, if any. */
|
|
4
|
+
export declare const effectHandler: (kind: string) => EffectHandler | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Registers the handler of a kind of effect with a function, for packages that ship effects.
|
|
7
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
8
|
+
* @param kind The kind of effect.
|
|
9
|
+
* @param handler Gets the payload and the row; throwing retries the row later.
|
|
10
|
+
* @param policy `attempts` gives the attempts of a row, when they differ from `maxRelayAttempts`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const registerEffect: (kind: string, handler: EffectHandler, policy?: {
|
|
13
|
+
attempts?: (row: OutboxRow) => number | undefined;
|
|
14
|
+
}) => void;
|
|
15
|
+
/**
|
|
16
|
+
* The delay before the next attempt of an outbox row: 5 s, 10 s, 20 s, ... up to 1 h.
|
|
17
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
18
|
+
* @param attempts The failed attempts so far.
|
|
19
|
+
*/
|
|
20
|
+
export declare const backoffMs: (attempts: number) => number;
|
|
21
|
+
/**
|
|
22
|
+
* What one relay did.
|
|
23
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
24
|
+
*/
|
|
25
|
+
export type RelaySummary = {
|
|
26
|
+
dispatched: number;
|
|
27
|
+
retried: number;
|
|
28
|
+
dead: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Narrows and sizes one relay.
|
|
32
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
33
|
+
*/
|
|
34
|
+
export type RelayOptions = {
|
|
35
|
+
/** Relay only the rows of these kinds. */
|
|
36
|
+
kinds?: readonly string[];
|
|
37
|
+
/** Relay only these rows, such as the ones an action just committed. */
|
|
38
|
+
ids?: readonly string[];
|
|
39
|
+
/** The rows claimed at once. Defaults to the `relayBatch` option. */
|
|
40
|
+
batch?: number;
|
|
41
|
+
/**
|
|
42
|
+
* The module whose outbox relays: the one it lists with `Moirai.stores()`. Defaults to the
|
|
43
|
+
* outbox of the current module, else the one of `MoiraiModule`.
|
|
44
|
+
*/
|
|
45
|
+
module?: abstract new (...args: any[]) => unknown;
|
|
46
|
+
};
|
|
47
|
+
export declare namespace Outbox {
|
|
48
|
+
/**
|
|
49
|
+
* Marks a service method as the handler of the outbox rows of a kind. The method gets the
|
|
50
|
+
* payload and the row; throwing retries the row later.
|
|
51
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
52
|
+
* @param kind The kind of effect.
|
|
53
|
+
*/
|
|
54
|
+
const handle: (kind: string) => MethodDecorator;
|
|
55
|
+
/**
|
|
56
|
+
* Writes effects to the outbox of the current module, in the open unit of work: they happen if
|
|
57
|
+
* and only if it commits. Once it has, the effects relay (see the `relayOnCommit` option).
|
|
58
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
59
|
+
* @param effects The effects.
|
|
60
|
+
* @returns The ids of the rows, in order.
|
|
61
|
+
*/
|
|
62
|
+
const enqueue: (...effects: OutboxEffect[]) => Promise<string[]>;
|
|
63
|
+
/**
|
|
64
|
+
* Runs the due rows of an outbox through their `@Outbox.handle()` handlers, in the module of
|
|
65
|
+
* that outbox. A row that fails
|
|
66
|
+
* is retried later with an exponential backoff, and is dead after `maxRelayAttempts`. Delivery
|
|
67
|
+
* is at least once, so handlers must tolerate a repeat.
|
|
68
|
+
*
|
|
69
|
+
* The claim, each row (its handler and its mark) and each failure run in units of work of their
|
|
70
|
+
* own, so a handler whose statement fails rolls back alone and its failure is still recorded.
|
|
71
|
+
* Call it outside a unit of work.
|
|
72
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
73
|
+
* @param relayOptions The rows to relay.
|
|
74
|
+
*/
|
|
75
|
+
const relay: (relayOptions?: RelayOptions) => Promise<RelaySummary>;
|
|
76
|
+
/**
|
|
77
|
+
* Relays every due row, batch after batch, such as from a cron trigger. Stops when a batch
|
|
78
|
+
* dispatches nothing. With `module`, it relays the outbox of that module; without it, the
|
|
79
|
+
* outbox of `MoiraiModule` and the one of each module that lists its own with
|
|
80
|
+
* `Moirai.stores()`.
|
|
81
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
82
|
+
* @param relayOptions The kinds to relay, the batch size and the module.
|
|
83
|
+
*/
|
|
84
|
+
const sweep: (relayOptions?: Omit<RelayOptions, 'ids'>) => Promise<RelaySummary>;
|
|
85
|
+
}
|
|
86
|
+
export declare namespace Inbox {
|
|
87
|
+
/**
|
|
88
|
+
* Runs work unless this consumer already processed the message, as recorded in the inbox of the
|
|
89
|
+
* current module. Call it in the consumer's unit of work, so that a failure rolls the record back and the redelivery runs the work again.
|
|
90
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
91
|
+
* @param message The id of the message and the name of the consumer.
|
|
92
|
+
* @param work The work.
|
|
93
|
+
* @returns Whether the work ran.
|
|
94
|
+
*/
|
|
95
|
+
const once: (message: {
|
|
96
|
+
messageId: string;
|
|
97
|
+
consumer: string;
|
|
98
|
+
}, work: () => Promise<void> | void) => Promise<boolean>;
|
|
99
|
+
}
|
|
100
|
+
export {};
|