@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 ADDED
@@ -0,0 +1,53 @@
1
+ # @elysiumjs/moirai
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#19](https://github.com/na2axl/elysiumjs/pull/19) [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562) Thanks [@na2axl](https://github.com/na2axl)! - Domain errors as classes with a code, returned as values.
8
+
9
+ - `DomainError` is an Elysia `HTTPError`. Each error is a class made with `DomainError.for(code, status)`, which keeps its code and status as literal types, with `errors`, `retryAfterSeconds` (served as `retryAfter` and the Retry-After header) and extensions.
10
+ - Ready-made errors: `BadRequestError`, `ValidationFailedError`, `UnauthenticatedError`, `ForbiddenError`, `NotFoundError` and `ConflictError`.
11
+ - `ProblemCatalogue`, extended through declaration merging, lists the codes of an application; once it has keys, `DomainError.for()` takes only those and `ELYSIUM_PROBLEM_CODES`.
12
+ - Every problem carries `code`, `requestId` (from `problems.requestId`, a valid `x-request-id` header, or a random UUID) and `instance`. Its `type` is the kebab-case slug of the code, under `problems.typeBase`.
13
+ - Errors as values: a handler, a flow step, `UnitOfWork.run()` or a `@Transactional()` method that returns a DomainError rolls back and sends or returns it like a thrown one. `Commands.execute()` and `Queries.ask()` return the DomainError that refused the call, and a returned error joins the route's response type for typed clients.
14
+
15
+ - [#21](https://github.com/na2axl/elysiumjs/pull/21) [`0035580`](https://github.com/na2axl/elysiumjs/commit/00355809c338a68e39d67335fb820ee098f36f89) Thanks [@na2axl](https://github.com/na2axl)! - Writes are actions and reads are queries. An action extends `Action` and is marked with `@Action.register()`; a query extends `Query` and is marked with `@Query.register()`. `Moirai.execute()` runs an action and `Moirai.ask()` runs a query, and their outcomes are `ActionOutcome` and `QueryOutcome`. Outbox handlers are marked with `@Outbox.handle(kind)`.
16
+
17
+ The options, retry policy and lock are `ActionOptions`, `ActionRetry`, `ActionLock` (`MemoryActionLock`, `MOIRAI_ACTION_LOCK`), and a refused `authorize` is a `CallForbiddenError`. Idempotency records name their `action`, and the `idempotency_keys` table of `moiraiTables()` has an `action` column.
18
+
19
+ - [#23](https://github.com/na2axl/elysiumjs/pull/23) [`a680a17`](https://github.com/na2axl/elysiumjs/commit/a680a177e2d82a6e85d04a9491ffc8fc05fb0010) Thanks [@na2axl](https://github.com/na2axl)! - Domain events. `Event.publish(event, payload)` publishes an event in the open unit of work, and each `@Event.on(event, { consumer, retry })` handler gets it once that unit commits: through its own outbox row, so that it retries and dies on its own, and once, through an inbox record named by its consumer. `EventCatalogue` types the names and payloads, and `EventMeta` gives handlers the id, name, publication date and attempt.
20
+
21
+ `registerEffect()` takes the attempts of a row, and the memory outbox and inbox stores drop what a unit of work that rolls back wrote.
22
+
23
+ - [#24](https://github.com/na2axl/elysiumjs/pull/24) [`ccc3f5b`](https://github.com/na2axl/elysiumjs/commit/ccc3f5bb904d1bf52d8c72ebf7ea8d10f617a8c4) Thanks [@na2axl](https://github.com/na2axl)! - Module stores: `Moirai.stores({ outbox, inbox, ... })` lists stores and options as private services of a module, such as the outbox and inbox tables of a domain. The actions and event handlers of the module, and the code that runs in it, use them instead of the ones of `MoiraiModule`; what a module leaves out comes from `MoiraiModule`. An action runs in the module that registers it, an `@Event.on` handler records its events in the inbox of its module, a `@Once()` job in the inbox of the module that registers the job, and the relay after a commit relays the outbox the rows went to. `Outbox.relay({ module })` and `Outbox.sweep({ module })` relay the outbox of one module; `Outbox.sweep()` relays the outbox of `MoiraiModule` and of every module with its own.
24
+
25
+ Breaking: an invalid action or query input is a 422 `VALIDATION_FAILED` problem (was 400), and a missing idempotency key is a 400 `IDEMPOTENCY_KEY_REQUIRED` problem (was 428).
26
+
27
+ - [#20](https://github.com/na2axl/elysiumjs/pull/20) [`1738567`](https://github.com/na2axl/elysiumjs/commit/17385674c14991f0ddb27eb6ee967afbcebcc307) Thanks [@na2axl](https://github.com/na2axl)! - Scheduled fan-out, dead letters and one consumer per queue.
28
+
29
+ - `@Scheduler.job(cron, { args })` dispatches a job on a cron with fixed arguments.
30
+ - A class that extends `Scheduler` and is marked with `@Scheduler.register({ cron, job })` dispatches one job per case its `fanOut(tick)` returns, with arguments typed against the job and a dispatch id made of the scheduler, the case key and the tick. Schedulers are services, so they inject what they read.
31
+ - `ScheduleRunner` runs the schedules on Bun and Node.js (the `heracles:schedule` command), and `Scheduler.jobsFor()` / `Scheduler.dispatch()` serve the cron triggers of Workers.
32
+ - `@Once()` of `@elysiumjs/moirai/heracles` runs a job once per dispatch id, in a unit of work with an inbox record.
33
+ - `HeraclesWorkersModule.with({ deadLetterQueue, onDead })` keeps the jobs that failed their last retry: sent to a dead letter queue with their failure, and handed to a hook.
34
+ - Cron triggers dispatch at the trigger's time.
35
+ - A named queue of a Worker has one consumer: two throw `DuplicateQueueConsumerError` when the Worker is created.
36
+
37
+ - [#17](https://github.com/na2axl/elysiumjs/pull/17) [`03e062d`](https://github.com/na2axl/elysiumjs/commit/03e062d9f5d8ec761c63f05574ce96a1084fe3ea) Thanks [@na2axl](https://github.com/na2axl)! - Commands, outbox, authentication and multi-step flows, on Bun, Node.js and Cloudflare Workers.
38
+
39
+ Added:
40
+
41
+ - `@elysiumjs/moirai`: commands and queries declared with `@Moirai.command()` and `@Moirai.query()`, run with `Commands.execute` and `Queries.ask`. Inputs are validated (400 problem), `authorize` refuses with a 403 problem, commands run in a unit of work and retry transient failures, idempotent commands replay their response for a repeated key (428 without a key, 422 for a key reused with another input), and `serializeBy` runs calls with the same key one after the other. `Outbox.enqueue` writes effects that relay once the unit of work commits, to `@Moirai.effect()` handlers, with a backoff and dead rows; `Outbox.sweep` relays what is due; `Inbox.once` processes a message once per consumer. `@elysiumjs/moirai/heracles` adds `job()`, an effect that dispatches a Heracles job. Memory stores for tests.
42
+ - `@elysiumjs/moirai-drizzle`: Postgres stores for moirai through Drizzle ORM: `moiraiTables(schema?)`, `DrizzleIdempotencyStore`, `DrizzleOutboxStore` (claims with `FOR UPDATE SKIP LOCKED`), `DrizzleInboxStore` and `PgAdvisoryLock`.
43
+ - `@elysiumjs/themis`: password hashing (argon2id on Bun, PBKDF2 everywhere, scrypt on `@elysiumjs/themis/scrypt`), JWT through jose, API keys, signed URLs, sessions (memory, Redis, Workers KV, or an encrypted cookie), `@Auth()` with session, JWT and API key guards (401 problem), picked by name through `AuthMiddleware.guards()` of core middleware, CSRF protection, TOTP and backup codes, OAuth 2.0 and OpenID Connect clients with PKCE through oauth4webapi, and passkeys through SimpleWebAuthn.
44
+ - `@elysiumjs/ariadne`: multi-step flows as controllers: `@Flow.controller()` adds the start and state routes, each `@Flow.step()` method is a route that takes the `@Http.*()` parameter decorators, and `@Flow.state()` / `@Flow.context()` inject the flow. Replay of a repeated step id, refused steps that keep their state, back steps, guest and user owners, a flow that ends when `next` gives no step, and a memory store set by `AriadneModule.with({ store })`.
45
+ - `@elysiumjs/ariadne-redis`: `RedisFlowStore`, whose claims and commits are atomic Lua scripts run with `EVAL`, which Upstash runs too.
46
+ - `@elysiumjs/ariadne-workers`: `FlowStoreObject`, a Durable Object flow store, and `durableFlowStore(binding)`.
47
+
48
+ ### Patch Changes
49
+
50
+ - Updated dependencies [[`6bc48fe`](https://github.com/na2axl/elysiumjs/commit/6bc48febd51f7090c582e73f349eaeff7261241c), [`e9c3d98`](https://github.com/na2axl/elysiumjs/commit/e9c3d98dd2714b69100215f7c8a80046da463148), [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82), [`ccc3f5b`](https://github.com/na2axl/elysiumjs/commit/ccc3f5bb904d1bf52d8c72ebf7ea8d10f617a8c4), [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562), [`87299fd`](https://github.com/na2axl/elysiumjs/commit/87299fd9da82d1a408ced1891c0c3d42991444e5), [`bee66bd`](https://github.com/na2axl/elysiumjs/commit/bee66bdc874b8f23fea356f7d3adf7555a48ca64), [`0057b67`](https://github.com/na2axl/elysiumjs/commit/0057b67df0e3abecab8a9accff6b55a6a64c21df), [`1738567`](https://github.com/na2axl/elysiumjs/commit/17385674c14991f0ddb27eb6ee967afbcebcc307), [`632d77b`](https://github.com/na2axl/elysiumjs/commit/632d77b561fc43ade605fb69b06ac1b9b9a2ffc4), [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d), [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4), [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88), [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576), [`98a16aa`](https://github.com/na2axl/elysiumjs/commit/98a16aae889d46f7a9fb5573188f4cbe3629dd3b)]:
51
+ - @elysiumjs/core@0.11.0
52
+ - @elysiumjs/mnemosyne@0.4.0
53
+ - @elysiumjs/heracles@0.8.0
package/README.md CHANGED
@@ -1,3 +1,172 @@
1
- # Temporary Holding Version
1
+ # @elysiumjs/moirai
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Actions and queries for [Elysium.js](../core) apps. An action is validated, runs in a unit of work, retries transient failures and can be idempotent. Its side effects go to an outbox that commits with it, and consumers use an inbox to process each message once.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @elysiumjs/moirai @elysiumjs/mnemosyne
9
+ ```
10
+
11
+ ## Actions and queries
12
+
13
+ An action is a write: a class that extends `Action`, marked with `@Action.register()`. A query is a read: a class that extends `Query`, marked with `@Query.register()`. `Moirai.execute()` runs an action and `Moirai.ask()` runs a query.
14
+
15
+ ```ts
16
+ import { Service, t } from '@elysiumjs/core';
17
+ import { Action, Moirai, Outbox, Query } from '@elysiumjs/moirai';
18
+
19
+ @Action.register({
20
+ input: t.Object({ walletId: t.String(), amount: t.Integer({ minimum: 1 }) }),
21
+ idempotent: true,
22
+ serializeBy: (input) => `wallet:${input.walletId}`
23
+ })
24
+ class TopUp extends Action {
25
+ constructor(@Service.inject() private readonly wallets: WalletRepository) {
26
+ super();
27
+ }
28
+
29
+ public async handle(input: { walletId: string; amount: number }) {
30
+ const wallet = await this.wallets.credit(input.walletId, input.amount);
31
+ await Outbox.enqueue({ kind: 'wallet.credited', payload: { walletId: wallet.id } });
32
+ return { balance: wallet.balance };
33
+ }
34
+ }
35
+
36
+ @Query.register({ input: t.Object({ walletId: t.String() }) })
37
+ class WalletBalance extends Query {
38
+ public handle(input: { walletId: string }) {
39
+ /* ... */
40
+ }
41
+ }
42
+
43
+ // `Moirai` runs them. In a controller, which returns the outcome: a result, or the DomainError that refused the call.
44
+ return Moirai.execute(TopUp, body, { idempotencyKey: headers['idempotency-key'] });
45
+ return Moirai.ask(WalletBalance, { walletId });
46
+ ```
47
+
48
+ - A refusal is a value. `Moirai.execute()` and `Moirai.ask()` return the DomainError that refused the call, and their return type lists it next to the result. A controller that returns it sends it as the problem of its route, and typed clients see it under its status. Check one with `isDomainError(outcome)`.
49
+ - An input that does not match the schema is refused with a 422 `VALIDATION_FAILED` problem, with one entry per mistake.
50
+ - When `authorize(input)` returns `false`, the call is refused with a 403 `FORBIDDEN` problem.
51
+ - An action runs in a unit of work, or joins the one already open. A transient failure retries in a new unit of work, up to 3 attempts. Transient failures are errors with `transient: true`, and Postgres serialization failures and deadlocks. `retry: { attempts, isTransient }` sets them per action, such as to retry a returned optimistic-concurrency error; `retry: false` never retries. A transient DomainError that outlasts the attempts is returned.
52
+ - A handler refuses a call by returning a `DomainError`, or throwing one. The unit of work then rolls back, nothing is stored for idempotency, and the error is returned to the caller. An action that joined an open unit of work hands it up: the unit rolls back when its opener returns it too.
53
+ - An idempotent action needs a key, or it is refused with a 400 `IDEMPOTENCY_KEY_REQUIRED` problem. A repeat with the same key and input replays the stored response, as JSON; with another input, it is refused with a 422 `IDEMPOTENCY_KEY_REUSED` problem.
54
+ - A key belongs to its owner: the current user by default (`currentUserId()` of core, else `anonymous`), or what `scope(input)` of the action or `Moirai.execute(..., { scope })` returns, such as a tenant and a user. Another owner presenting the same key runs the action on its own.
55
+ - `serializeBy` makes calls that share a key run one after the other, so a double tap waits for the first call and replays its response.
56
+
57
+ ## Outbox and inbox
58
+
59
+ ```ts
60
+ class Mailer {
61
+ @Outbox.handle('wallet.credited')
62
+ public async notify(payload: { walletId: string }) {
63
+ /* send the email */
64
+ }
65
+ }
66
+
67
+ // From a cron trigger, for rows that failed or were delayed:
68
+ await Outbox.sweep();
69
+
70
+ // In a consumer, inside its unit of work:
71
+ await Inbox.once({ messageId: message.id, consumer: 'billing' }, () => apply(message));
72
+ ```
73
+
74
+ Effects relay once the unit of work commits. A row that fails is retried with a backoff from 5 s up to 1 h, and marked dead after 8 attempts (`onDead` is called then). Delivery is at least once, so a handler must tolerate a repeat.
75
+
76
+ `@elysiumjs/moirai/heracles` adds `job(JobClass, args)`, an effect that dispatches a Heracles job once the action commits:
77
+
78
+ ```ts
79
+ import { job } from '@elysiumjs/moirai/heracles';
80
+
81
+ await Outbox.enqueue(job(SendReceiptJob, [order.id]));
82
+ ```
83
+
84
+ `@Once()` runs a Heracles job once per dispatch id: its `execute()` runs in a unit of work with an inbox record of the id. It fits the jobs of a `Scheduler`, whose dispatch id names the scheduler, the case and the tick, so a repeated delivery or a tick handled twice does nothing.
85
+
86
+ Effect handlers register when their module is imported, `@elysiumjs/moirai/heracles` included. A process that relays the outbox, such as a cron sweeper, imports the modules of the effects it relays.
87
+
88
+ ## Events
89
+
90
+ An event says what happened. It is published in the unit of work that made it happen, and each handler gets it once that unit commits, never when it rolls back:
91
+
92
+ ```ts
93
+ import type { EventMeta } from '@elysiumjs/moirai';
94
+
95
+ import { Event } from '@elysiumjs/moirai';
96
+
97
+ declare module '@elysiumjs/moirai' {
98
+ interface EventCatalogue {
99
+ 'wallet.credited': { walletId: string; amount: number };
100
+ }
101
+ }
102
+
103
+ // In an action:
104
+ await Event.publish('wallet.credited', { walletId: wallet.id, amount: input.amount });
105
+
106
+ class Receipts {
107
+ @Event.on('wallet.credited', { consumer: 'billing.send-receipt', retry: { attempts: 5 } })
108
+ public async send(payload: { walletId: string; amount: number }, meta: EventMeta) {
109
+ /* ... */
110
+ }
111
+ }
112
+ ```
113
+
114
+ - `EventCatalogue` types the names and the payloads of `Event.publish()` and `@Event.on()`. While it is empty, any name and payload are accepted.
115
+ - Each handler gets its own delivery through the outbox, so it retries and dies on its own (`retry: { attempts }`, `retry: false` for one attempt, else `maxRelayAttempts`). Delivery is at least once.
116
+ - Each handler records the events it handled in the inbox, in the unit of work of its own writes, so a repeated delivery does nothing. `consumer` names that record and defaults to `Class.method`: keep it when the method is renamed. Two handlers with one consumer throw `DuplicateEventConsumerError`.
117
+ - `meta` gives the id of the event, its name, when it was published and the attempt.
118
+ - Events need the `outbox` and `inbox` stores. Handlers register when their module is imported, so the process that relays imports them.
119
+
120
+ ## Stores
121
+
122
+ ```ts
123
+ import { MoiraiModule } from '@elysiumjs/moirai';
124
+ import {
125
+ DrizzleIdempotencyStore,
126
+ DrizzleInboxStore,
127
+ DrizzleOutboxStore,
128
+ moiraiTables,
129
+ PgAdvisoryLock
130
+ } from '@elysiumjs/moirai-drizzle';
131
+
132
+ export const billing = moiraiTables('billing');
133
+
134
+ MoiraiModule.with({
135
+ idempotency: () => new DrizzleIdempotencyStore({ table: billing.idempotencyKeys }),
136
+ outbox: () => new DrizzleOutboxStore({ table: billing.outbox }),
137
+ inbox: () => new DrizzleInboxStore({ table: billing.inbox }),
138
+ lock: () => new PgAdvisoryLock(),
139
+ // On Cloudflare Workers, answer before the relay that follows a commit:
140
+ defer: (work) => Workers.context().waitUntil(work)
141
+ });
142
+ ```
143
+
144
+ The memory stores (`MemoryIdempotencyStore`, `MemoryOutboxStore`, `MemoryInboxStore` and `MemoryActionLock`) are meant for tests and single-process apps. The memory outbox and inbox drop what a unit of work that rolls back wrote, like a database table.
145
+
146
+ ### Module stores
147
+
148
+ A module can have stores of its own, such as the outbox and inbox tables of a domain. `Moirai.stores()` lists them as private services of the module; what a module leaves out comes from `MoiraiModule`, stores and options alike.
149
+
150
+ ```ts
151
+ const billing = moiraiTables('billing');
152
+
153
+ @Module.register({
154
+ controllers: [InvoiceController],
155
+ services: [
156
+ Charge,
157
+ Receipts,
158
+ ...Moirai.stores({
159
+ outbox: () => new DrizzleOutboxStore({ table: billing.outbox }),
160
+ inbox: () => new DrizzleInboxStore({ table: billing.inbox }),
161
+ relayBatch: 20
162
+ })
163
+ ]
164
+ })
165
+ class BillingModule extends Module {}
166
+ ```
167
+
168
+ - An action runs in the module that registers it, whoever calls it: its idempotency store, its lock, its options, and the outbox of its effects and events are the ones of that module.
169
+ - An event goes through the outbox of the module that publishes it. An `@Event.on` handler runs in the module that registers its class: the events it handled are recorded in the inbox of that module, and its own effects and events go to the outbox of that module. A handler whose class no module registers runs in the module of the outbox the event came from.
170
+ - A `@Once()` job records its dispatch ids in the inbox of the module that registers the job.
171
+ - The relay that follows a commit relays the outbox the rows went to. `Outbox.relay({ module })` and `Outbox.sweep({ module })` relay the outbox of one module; `Outbox.sweep()` relays the outbox of `MoiraiModule` and the one of every module that has its own, so one cron trigger covers them all.
172
+ - Like any service, module stores are visible to the modules that import the module when it exports them.
@@ -0,0 +1,211 @@
1
+ import type { ServiceDefinition } from '@elysiumjs/core';
2
+ import type { Static, TSchema } from 'typebox';
3
+ import type { MoiraiStores } from './moirai.module';
4
+ import { DomainError } from '@elysiumjs/core';
5
+ import { CallForbiddenError, IdempotencyKeyRequiredError, IdempotencyKeyReusedError, InvalidInputError } from './errors';
6
+ /**
7
+ * What an action or a query handler receives besides its input.
8
+ * @author Axel Nana <axel.nana@workbud.com>
9
+ */
10
+ export type HandlerContext = {
11
+ /** The name of the action or the query. */
12
+ name: string;
13
+ /** The idempotency key of the call, if any. */
14
+ idempotencyKey?: string;
15
+ /** The attempt, from 1, when the action retries after a transient error. */
16
+ attempt: number;
17
+ };
18
+ /**
19
+ * What `handle()` gives: the result, or a DomainError that refuses the call.
20
+ * @author Axel Nana <axel.nana@workbud.com>
21
+ */
22
+ export type HandlerResult<TResult> = Promise<TResult | DomainError> | TResult | DomainError;
23
+ /**
24
+ * An action class.
25
+ * @author Axel Nana <axel.nana@workbud.com>
26
+ */
27
+ export type ActionClass<TInput = any, TResult = any> = new (...args: any[]) => Action<TInput, TResult>;
28
+ /**
29
+ * A query class.
30
+ * @author Axel Nana <axel.nana@workbud.com>
31
+ */
32
+ export type QueryClass<TInput = any, TResult = any> = new (...args: any[]) => Query<TInput, TResult>;
33
+ type HandlerClass = ActionClass | QueryClass;
34
+ type InputOf<C extends HandlerClass> = Parameters<InstanceType<C>['handle']>[0];
35
+ type OutcomeOf<C extends HandlerClass> = Awaited<ReturnType<InstanceType<C>['handle']>>;
36
+ /**
37
+ * What `Moirai.execute()` gives: the result, or the DomainError that refused the call, from the
38
+ * handler or from moirai itself.
39
+ * @author Axel Nana <axel.nana@workbud.com>
40
+ */
41
+ export type ActionOutcome<C extends ActionClass> = OutcomeOf<C> | InvalidInputError | CallForbiddenError | IdempotencyKeyRequiredError | IdempotencyKeyReusedError;
42
+ /**
43
+ * What `Moirai.ask()` gives: the result, or the DomainError that refused the call.
44
+ * @author Axel Nana <axel.nana@workbud.com>
45
+ */
46
+ export type QueryOutcome<C extends QueryClass> = OutcomeOf<C> | InvalidInputError | CallForbiddenError;
47
+ /**
48
+ * How an action retries a transient failure.
49
+ * @author Axel Nana <axel.nana@workbud.com>
50
+ */
51
+ export type ActionRetry = {
52
+ /** The attempts, the first included. Defaults to `maxAttempts` of the module. */
53
+ attempts?: number;
54
+ /**
55
+ * Whether a failure, thrown or a returned DomainError, is transient. Defaults to `isTransient`
56
+ * of the module.
57
+ */
58
+ isTransient?: (error: unknown) => boolean;
59
+ };
60
+ /**
61
+ * The options of an action.
62
+ * @author Axel Nana <axel.nana@workbud.com>
63
+ */
64
+ export type ActionOptions<S extends TSchema = TSchema> = {
65
+ /** The name of the action, in idempotency records and problems. Defaults to the class name. */
66
+ name?: string;
67
+ /** The schema of the input. */
68
+ input: S;
69
+ /**
70
+ * Whether a call needs an idempotency key. A repeat with the same key and input replays the
71
+ * stored response; with another input, it is refused with a 422 problem. Defaults to `false`.
72
+ */
73
+ idempotent?: boolean;
74
+ /**
75
+ * Who owns the idempotency keys of the action, so that the keys of two owners never meet,
76
+ * such as a tenant and a user. Defaults to the id of the current user (`currentUserId()`),
77
+ * else `anonymous`. `Moirai.execute()` may pass its own.
78
+ */
79
+ scope?: (input: Static<S>) => string;
80
+ /** Whether the action runs in a unit of work, or joins the open one. Defaults to `true`. */
81
+ transactional?: boolean;
82
+ /** The database connection of the unit of work. Defaults to `default`. */
83
+ connection?: string;
84
+ /**
85
+ * Serializes the calls that give the same key, so that a double tap waits for the first call
86
+ * and replays it instead of racing it. Needs a `lock` store.
87
+ */
88
+ serializeBy?: (input: Static<S>) => string;
89
+ /**
90
+ * Whether a transient failure retries in a new unit of work. Defaults to `true`, with the
91
+ * `maxAttempts` and `isTransient` of the module. An object sets them for this action, such as
92
+ * an `isTransient` that also retries a returned `ConcurrencyConflict` error. A transient
93
+ * DomainError that outlasts the attempts is returned like any refusal.
94
+ */
95
+ retry?: boolean | ActionRetry;
96
+ /** Decides whether the call may run, such as with the abilities of the current user. */
97
+ authorize?: (input: Static<S>) => boolean | Promise<boolean>;
98
+ };
99
+ /**
100
+ * The options of a query.
101
+ * @author Axel Nana <axel.nana@workbud.com>
102
+ */
103
+ export type QueryOptions<S extends TSchema = TSchema> = {
104
+ /** The name of the query. Defaults to the class name. */
105
+ name?: string;
106
+ /** The schema of the input. */
107
+ input: S;
108
+ /** Decides whether the call may run. */
109
+ authorize?: (input: Static<S>) => boolean | Promise<boolean>;
110
+ };
111
+ /**
112
+ * An action: a write, validated, run in a unit of work, retried on transient failures, and
113
+ * idempotent when asked. Mark the class with `@Action.register()` and run it with
114
+ * {@link Moirai.execute}.
115
+ * @author Axel Nana <axel.nana@workbud.com>
116
+ * @example
117
+ * ```ts
118
+ * @Action.register({ input: t.Object({ amount: t.Integer({ minimum: 1 }) }), idempotent: true })
119
+ * class TopUp extends Action<{ amount: number }> {
120
+ * constructor(@Service.inject() private readonly wallets: WalletRepository) {
121
+ * super();
122
+ * }
123
+ *
124
+ * public async handle(input: { amount: number }) {
125
+ * const wallet = await this.wallets.credit(input.amount);
126
+ * await Outbox.enqueue({ kind: 'wallet.credited', payload: { id: wallet.id } });
127
+ * return { balance: wallet.balance };
128
+ * }
129
+ * }
130
+ * ```
131
+ */
132
+ export declare abstract class Action<TInput = any, TResult = any> {
133
+ /**
134
+ * Marks a class as an action.
135
+ * @param props The options of the action.
136
+ */
137
+ static register<S extends TSchema>(props: ActionOptions<S>): (target: ActionClass) => void;
138
+ /**
139
+ * Runs the action with its validated input. Returning or throwing a DomainError refuses the
140
+ * call and rolls its unit of work back.
141
+ */
142
+ abstract handle(input: TInput, context: HandlerContext): HandlerResult<TResult>;
143
+ }
144
+ /**
145
+ * A query: a read, validated and run without a transaction. Mark the class with
146
+ * `@Query.register()` and run it with {@link Moirai.ask}.
147
+ * @author Axel Nana <axel.nana@workbud.com>
148
+ */
149
+ export declare abstract class Query<TInput = any, TResult = any> {
150
+ /**
151
+ * Marks a class as a query.
152
+ * @param props The options of the query.
153
+ */
154
+ static register<S extends TSchema>(props: QueryOptions<S>): (target: QueryClass) => void;
155
+ /** Runs the query with its validated input. Returning or throwing a DomainError refuses it. */
156
+ abstract handle(input: TInput, context: HandlerContext): HandlerResult<TResult>;
157
+ }
158
+ export declare namespace Moirai {
159
+ /**
160
+ * Runs an action: validates the input, checks `authorize`, then runs the handler in a unit of
161
+ * work (or joins the open one), retrying transient failures, and replaying the stored response
162
+ * of an idempotent action called again with the same key.
163
+ *
164
+ * A refusal is a value: the DomainError the handler returns or throws, or one of moirai's own
165
+ * (an invalid input, a refused `authorize`, a missing or reused idempotency key). The unit of
166
+ * work rolls back, nothing is stored for idempotency, and the error is returned, so a handler
167
+ * that returns it sends it as the problem of its route.
168
+ * @author Axel Nana <axel.nana@workbud.com>
169
+ * @param action The action class.
170
+ * @param input The raw input.
171
+ * @param call The idempotency key of the call.
172
+ */
173
+ const execute: <C extends ActionClass>(action: C, input: InputOf<C>, call?: {
174
+ idempotencyKey?: string;
175
+ scope?: string;
176
+ }) => Promise<ActionOutcome<C>>;
177
+ /**
178
+ * The stores and options of a module, as private services to list in its `services`. The
179
+ * actions and event handlers of the module, and the code that runs in it, use them instead of
180
+ * the ones of `MoiraiModule`; a store left out is the one of `MoiraiModule`, and an option left
181
+ * out is the one of `MoiraiModule`. Like any service, they are visible to the modules that
182
+ * import the module when it exports them.
183
+ * @author Axel Nana <axel.nana@workbud.com>
184
+ * @param stores The stores, each an instance or a function that builds it once, and options.
185
+ * @example
186
+ * ```ts
187
+ * @Module.register({
188
+ * controllers: [WalletController],
189
+ * services: [
190
+ * WalletRepository,
191
+ * ...Moirai.stores({
192
+ * outbox: () => new DrizzleOutboxStore({ table: billing.outbox }),
193
+ * inbox: () => new DrizzleInboxStore({ table: billing.inbox })
194
+ * })
195
+ * ]
196
+ * })
197
+ * class BillingModule extends Module {}
198
+ * ```
199
+ */
200
+ const stores: (stores: MoiraiStores) => ServiceDefinition[];
201
+ /**
202
+ * Runs a query: validates the input, checks `authorize`, and runs the handler. A refusal is a
203
+ * value: the DomainError the handler returns or throws, an invalid input, or a refused
204
+ * `authorize`.
205
+ * @author Axel Nana <axel.nana@workbud.com>
206
+ * @param query The query class.
207
+ * @param input The raw input.
208
+ */
209
+ const ask: <C extends QueryClass>(query: C, input: InputOf<C>) => Promise<QueryOutcome<C>>;
210
+ }
211
+ export {};
@@ -0,0 +1,58 @@
1
+ import type { ActionLock, IdempotencyStore, InboxStore, OutboxStore } from './stores';
2
+ /** The service names of the moirai stores and options. */
3
+ export declare const MOIRAI_IDEMPOTENCY_STORE = "moirai.idempotency";
4
+ export declare const MOIRAI_OUTBOX_STORE = "moirai.outbox";
5
+ export declare const MOIRAI_INBOX_STORE = "moirai.inbox";
6
+ export declare const MOIRAI_ACTION_LOCK = "moirai.lock";
7
+ export declare const MOIRAI_OPTIONS = "moirai.options";
8
+ /** The service name of the options a module overrides with `Moirai.stores()`. */
9
+ export declare const MOIRAI_MODULE_OPTIONS = "moirai.module-options";
10
+ /**
11
+ * How moirai runs actions and relays the outbox.
12
+ * @author Axel Nana <axel.nana@workbud.com>
13
+ */
14
+ export type MoiraiOptions = {
15
+ /** The attempts of an action that fails with a transient error. Defaults to 3. */
16
+ maxAttempts: number;
17
+ /**
18
+ * Whether an error is transient, so that the action retries in a new unit of work. Defaults to
19
+ * errors with `transient: true` and the Postgres serialization failure and deadlock codes.
20
+ */
21
+ isTransient: (error: unknown) => boolean;
22
+ /** Whether the effects an action enqueues relay right after it commits. Defaults to `true`. */
23
+ relayOnCommit: boolean;
24
+ /**
25
+ * Runs the relay that follows a commit. Defaults to awaiting it before the response. On
26
+ * Cloudflare Workers, pass `(work) => Workers.context().waitUntil(work)` to answer first.
27
+ */
28
+ defer?: (work: Promise<unknown>) => void;
29
+ /** The attempts of an outbox row before it is dead. Defaults to 8. */
30
+ maxRelayAttempts: number;
31
+ /** The rows a relay claims at once. Defaults to 50. */
32
+ relayBatch: number;
33
+ /**
34
+ * How long a relay holds the rows it claimed, in seconds. A row its relay did not mark in that
35
+ * time is due again. Defaults to 300.
36
+ */
37
+ relayLease: number;
38
+ /** Called when an outbox row is dead, such as to raise an alert. */
39
+ onDead?: (row: import('./stores').OutboxRow, error: Error) => void;
40
+ };
41
+ /** The default transient check: `transient: true`, or a Postgres serialization failure or deadlock. */
42
+ export declare const isTransientError: (error: unknown) => boolean;
43
+ export declare const DEFAULT_OPTIONS: MoiraiOptions;
44
+ /**
45
+ * The options of the current module: the defaults, then the options of `MoiraiModule`, then the
46
+ * ones the module overrides with `Moirai.stores()`.
47
+ */
48
+ export declare const options: () => MoiraiOptions;
49
+ export declare const idempotencyStore: () => IdempotencyStore;
50
+ export declare const outboxStore: () => OutboxStore;
51
+ export declare const inboxStore: () => InboxStore;
52
+ export declare const actionLock: () => ActionLock;
53
+ /**
54
+ * Runs work in the context of the module that declares a service, such as the module of an
55
+ * action or of an event handler, so that it uses that module's stores. Without such a module, the
56
+ * work runs in the current context.
57
+ */
58
+ export declare const inOwnerModule: <T>(service: object, work: () => T) => T;