@elysiumjs/moirai 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # @elysiumjs/moirai
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#28](https://github.com/na2axl/elysiumjs/pull/28) [`1a5599c`](https://github.com/na2axl/elysiumjs/commit/1a5599c5421c98b5ee64b0c5cab87d158041d309) Thanks [@na2axl](https://github.com/na2axl)! - Type actions and queries with their errors, and let them take no input.
8
+
9
+ - `Action<TInput, TResult, TErrors>` and `Query<TInput, TResult, TErrors>` take the DomainErrors their `handle()` returns as a third type parameter (any DomainError by default), and `HandlerResult<TResult, TErrors>` gives `TResult` or one of `TErrors`. `Moirai.execute()` and `Moirai.ask()` return them next to the result.
10
+ - `input` is optional in `@Action.register()` and `@Query.register()`. A handler without an input schema takes no input, and `Moirai.ask(Categories)` or `Moirai.execute(Ping)` call it without one (`Moirai.execute(Ping, undefined, { idempotencyKey })` for an idempotent one). The input of `Moirai.execute()` and `Moirai.ask()` may be left out whenever `handle()` takes none or an optional one.
11
+
12
+ ## 0.1.1
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [[`4cd1ef2`](https://github.com/na2axl/elysiumjs/commit/4cd1ef217826164e69cd641ca99f74c6f5a7ebe7)]:
17
+ - @elysiumjs/mnemosyne@0.5.0
18
+ - @elysiumjs/core@0.11.1
19
+ - @elysiumjs/heracles@0.8.0
20
+
3
21
  ## 0.1.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -13,30 +13,47 @@ bun add @elysiumjs/moirai @elysiumjs/mnemosyne
13
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
14
 
15
15
  ```ts
16
- import { Service, t } from '@elysiumjs/core';
16
+ import type { Static } from '@elysiumjs/core';
17
+
18
+ import { DomainError, Service, t } from '@elysiumjs/core';
17
19
  import { Action, Moirai, Outbox, Query } from '@elysiumjs/moirai';
18
20
 
21
+ const TopUpInput = t.Object({ walletId: t.String(), amount: t.Integer({ minimum: 1 }) });
22
+ export type TopUpInput = Static<typeof TopUpInput>;
23
+ export type TopUpResult = { balance: number };
24
+
25
+ class WalletClosed extends DomainError.for('CONFLICT', 409) {}
26
+
19
27
  @Action.register({
20
- input: t.Object({ walletId: t.String(), amount: t.Integer({ minimum: 1 }) }),
28
+ input: TopUpInput,
21
29
  idempotent: true,
22
30
  serializeBy: (input) => `wallet:${input.walletId}`
23
31
  })
24
- class TopUp extends Action {
32
+ class TopUp extends Action<TopUpInput, TopUpResult, WalletClosed> {
25
33
  constructor(@Service.inject() private readonly wallets: WalletRepository) {
26
34
  super();
27
35
  }
28
36
 
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 };
37
+ public override async handle(input: TopUpInput): Promise<TopUpResult | WalletClosed> {
38
+ const wallet = await this.wallets.find(input.walletId);
39
+ if (wallet.closed) return new WalletClosed(`wallet ${input.walletId} is closed`);
40
+ const credited = await this.wallets.credit(input.walletId, input.amount);
41
+ await Outbox.enqueue({ kind: 'wallet.credited', payload: { walletId: credited.id } });
42
+ return { balance: credited.balance };
33
43
  }
34
44
  }
35
45
 
36
- @Query.register({ input: t.Object({ walletId: t.String() }) })
37
- class WalletBalance extends Query {
38
- public handle(input: { walletId: string }) {
39
- /* ... */
46
+ const WalletBalanceInput = t.Object({ walletId: t.String() });
47
+ export type WalletBalanceInput = Static<typeof WalletBalanceInput>;
48
+
49
+ @Query.register({ input: WalletBalanceInput })
50
+ class WalletBalance extends Query<WalletBalanceInput, { balance: number }> {
51
+ constructor(@Service.inject() private readonly wallets: WalletRepository) {
52
+ super();
53
+ }
54
+
55
+ public override async handle(input: WalletBalanceInput) {
56
+ return { balance: await this.wallets.balance(input.walletId) };
40
57
  }
41
58
  }
42
59
 
@@ -45,6 +62,7 @@ return Moirai.execute(TopUp, body, { idempotencyKey: headers['idempotency-key']
45
62
  return Moirai.ask(WalletBalance, { walletId });
46
63
  ```
47
64
 
65
+ - `Action<TInput, TResult, TErrors>` and `Query<TInput, TResult, TErrors>` type `handle()`: it takes `TInput` and gives `TResult` or one of `TErrors`, the DomainErrors it returns (any DomainError by default). `Moirai.execute()` and `Moirai.ask()` take their input type and return types from `handle()`.
48
66
  - 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
67
  - An input that does not match the schema is refused with a 422 `VALIDATION_FAILED` problem, with one entry per mistake.
50
68
  - When `authorize(input)` returns `false`, the call is refused with a 403 `FORBIDDEN` problem.
package/dist/action.d.ts CHANGED
@@ -16,22 +16,43 @@ export type HandlerContext = {
16
16
  attempt: number;
17
17
  };
18
18
  /**
19
- * What `handle()` gives: the result, or a DomainError that refuses the call.
19
+ * What `handle()` gives: the result, or one of the DomainErrors that refuse the call.
20
20
  * @author Axel Nana <axel.nana@workbud.com>
21
21
  */
22
- export type HandlerResult<TResult> = Promise<TResult | DomainError> | TResult | DomainError;
22
+ export type HandlerResult<TResult, TErrors extends DomainError = DomainError> = Promise<TResult | TErrors> | TResult | TErrors;
23
23
  /**
24
24
  * An action class.
25
25
  * @author Axel Nana <axel.nana@workbud.com>
26
26
  */
27
- export type ActionClass<TInput = any, TResult = any> = new (...args: any[]) => Action<TInput, TResult>;
27
+ export type ActionClass<TInput = any, TResult = any, TErrors extends DomainError = DomainError> = new (...args: any[]) => Action<TInput, TResult, TErrors>;
28
28
  /**
29
29
  * A query class.
30
30
  * @author Axel Nana <axel.nana@workbud.com>
31
31
  */
32
- export type QueryClass<TInput = any, TResult = any> = new (...args: any[]) => Query<TInput, TResult>;
32
+ export type QueryClass<TInput = any, TResult = any, TErrors extends DomainError = DomainError> = new (...args: any[]) => Query<TInput, TResult, TErrors>;
33
33
  type HandlerClass = ActionClass | QueryClass;
34
- type InputOf<C extends HandlerClass> = Parameters<InstanceType<C>['handle']>[0];
34
+ type HandleParameters<C extends HandlerClass> = Parameters<InstanceType<C>['handle']>;
35
+ type InputOf<C extends HandlerClass> = HandleParameters<C> extends [] ? undefined : HandleParameters<C>[0];
36
+ /**
37
+ * How an action or a query is called.
38
+ * @author Axel Nana <axel.nana@workbud.com>
39
+ */
40
+ export type ActionCall = {
41
+ idempotencyKey?: string;
42
+ scope?: string;
43
+ };
44
+ /**
45
+ * The arguments of `Moirai.execute()` after the action: its input, which may be left out when
46
+ * `handle()` takes none (or an optional one), and how it is called.
47
+ * @author Axel Nana <axel.nana@workbud.com>
48
+ */
49
+ export type ExecuteArgs<C extends ActionClass> = undefined extends InputOf<C> ? [input?: InputOf<C>, call?: ActionCall] : [input: InputOf<C>, call?: ActionCall];
50
+ /**
51
+ * The arguments of `Moirai.ask()` after the query: its input, which may be left out when
52
+ * `handle()` takes none (or an optional one).
53
+ * @author Axel Nana <axel.nana@workbud.com>
54
+ */
55
+ export type AskArgs<C extends QueryClass> = undefined extends InputOf<C> ? [input?: InputOf<C>] : [input: InputOf<C>];
35
56
  type OutcomeOf<C extends HandlerClass> = Awaited<ReturnType<InstanceType<C>['handle']>>;
36
57
  /**
37
58
  * What `Moirai.execute()` gives: the result, or the DomainError that refused the call, from the
@@ -64,8 +85,8 @@ export type ActionRetry = {
64
85
  export type ActionOptions<S extends TSchema = TSchema> = {
65
86
  /** The name of the action, in idempotency records and problems. Defaults to the class name. */
66
87
  name?: string;
67
- /** The schema of the input. */
68
- input: S;
88
+ /** The schema of the input. An action without one takes no input. */
89
+ input?: S;
69
90
  /**
70
91
  * Whether a call needs an idempotency key. A repeat with the same key and input replays the
71
92
  * stored response; with another input, it is refused with a 422 problem. Defaults to `false`.
@@ -103,8 +124,8 @@ export type ActionOptions<S extends TSchema = TSchema> = {
103
124
  export type QueryOptions<S extends TSchema = TSchema> = {
104
125
  /** The name of the query. Defaults to the class name. */
105
126
  name?: string;
106
- /** The schema of the input. */
107
- input: S;
127
+ /** The schema of the input. A query without one takes no input. */
128
+ input?: S;
108
129
  /** Decides whether the call may run. */
109
130
  authorize?: (input: Static<S>) => boolean | Promise<boolean>;
110
131
  };
@@ -129,7 +150,7 @@ export type QueryOptions<S extends TSchema = TSchema> = {
129
150
  * }
130
151
  * ```
131
152
  */
132
- export declare abstract class Action<TInput = any, TResult = any> {
153
+ export declare abstract class Action<TInput = any, TResult = any, TErrors extends DomainError = DomainError> {
133
154
  /**
134
155
  * Marks a class as an action.
135
156
  * @param props The options of the action.
@@ -139,21 +160,21 @@ export declare abstract class Action<TInput = any, TResult = any> {
139
160
  * Runs the action with its validated input. Returning or throwing a DomainError refuses the
140
161
  * call and rolls its unit of work back.
141
162
  */
142
- abstract handle(input: TInput, context: HandlerContext): HandlerResult<TResult>;
163
+ abstract handle(input: TInput, context: HandlerContext): HandlerResult<TResult, TErrors>;
143
164
  }
144
165
  /**
145
166
  * A query: a read, validated and run without a transaction. Mark the class with
146
167
  * `@Query.register()` and run it with {@link Moirai.ask}.
147
168
  * @author Axel Nana <axel.nana@workbud.com>
148
169
  */
149
- export declare abstract class Query<TInput = any, TResult = any> {
170
+ export declare abstract class Query<TInput = any, TResult = any, TErrors extends DomainError = DomainError> {
150
171
  /**
151
172
  * Marks a class as a query.
152
173
  * @param props The options of the query.
153
174
  */
154
175
  static register<S extends TSchema>(props: QueryOptions<S>): (target: QueryClass) => void;
155
176
  /** Runs the query with its validated input. Returning or throwing a DomainError refuses it. */
156
- abstract handle(input: TInput, context: HandlerContext): HandlerResult<TResult>;
177
+ abstract handle(input: TInput, context: HandlerContext): HandlerResult<TResult, TErrors>;
157
178
  }
158
179
  export declare namespace Moirai {
159
180
  /**
@@ -167,13 +188,10 @@ export declare namespace Moirai {
167
188
  * that returns it sends it as the problem of its route.
168
189
  * @author Axel Nana <axel.nana@workbud.com>
169
190
  * @param action The action class.
170
- * @param input The raw input.
171
- * @param call The idempotency key of the call.
191
+ * @param args The raw input, left out when `handle()` takes none, and the idempotency key and
192
+ * scope of the call.
172
193
  */
173
- const execute: <C extends ActionClass>(action: C, input: InputOf<C>, call?: {
174
- idempotencyKey?: string;
175
- scope?: string;
176
- }) => Promise<ActionOutcome<C>>;
194
+ const execute: <C extends ActionClass>(action: C, ...[input, call]: ExecuteArgs<C>) => Promise<ActionOutcome<C>>;
177
195
  /**
178
196
  * The stores and options of a module, as private services to list in its `services`. The
179
197
  * actions and event handlers of the module, and the code that runs in it, use them instead of
@@ -204,8 +222,8 @@ export declare namespace Moirai {
204
222
  * `authorize`.
205
223
  * @author Axel Nana <axel.nana@workbud.com>
206
224
  * @param query The query class.
207
- * @param input The raw input.
225
+ * @param args The raw input, left out when `handle()` takes none.
208
226
  */
209
- const ask: <C extends QueryClass>(query: C, input: InputOf<C>) => Promise<QueryOutcome<C>>;
227
+ const ask: <C extends QueryClass>(query: C, ...[input]: AskArgs<C>) => Promise<QueryOutcome<C>>;
210
228
  }
211
229
  export {};
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
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';
1
+ export { type ActionCall, type ActionClass, type ActionOptions, type ActionOutcome, type ActionRetry, type AskArgs, type ExecuteArgs, type HandlerContext, type HandlerResult, type QueryClass, type QueryOptions, type QueryOutcome, Action, Moirai, Query } from './action';
2
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
3
  export { CallForbiddenError, DuplicateEventConsumerError, IdempotencyConflictError, IdempotencyKeyRequiredError, IdempotencyKeyReusedError, InvalidInputError, MissingStoreError, UnknownEventConsumerError, UnknownEffectError } from './errors';
4
4
  export { type EventCatalogue, type EventHandlerOptions, type EventMeta, type EventName, type EventPayload, Event, MOIRAI_EVENT_EFFECT } from './event';
package/dist/index.js CHANGED
@@ -1 +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};
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)=>{if(schema===void 0)return;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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elysiumjs/moirai",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Actions, queries, idempotency, outbox, inbox and domain events for the Elysium.js framework.",
6
6
  "private": false,
@@ -62,16 +62,16 @@
62
62
  "typebox": "^1.3.0"
63
63
  },
64
64
  "devDependencies": {
65
- "@elysiumjs/core": "0.11.0",
65
+ "@elysiumjs/core": "0.11.1",
66
66
  "@elysiumjs/heracles": "0.8.0",
67
- "@elysiumjs/mnemosyne": "0.4.0",
67
+ "@elysiumjs/mnemosyne": "0.5.0",
68
68
  "@elysiumjs/redis": "0.1.0",
69
69
  "elysia": "^2.0.0-rc.1"
70
70
  },
71
71
  "peerDependencies": {
72
- "@elysiumjs/core": "^0.11.0",
72
+ "@elysiumjs/core": "^0.11.1",
73
73
  "@elysiumjs/heracles": "^0.8.0",
74
- "@elysiumjs/mnemosyne": "^0.4.0"
74
+ "@elysiumjs/mnemosyne": "^0.5.0"
75
75
  },
76
76
  "peerDependenciesMeta": {
77
77
  "@elysiumjs/heracles": {