@znight/messaging 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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -2
  3. package/dist/async-CFh_bvex.mjs +216 -0
  4. package/dist/async-CFh_bvex.mjs.map +1 -0
  5. package/dist/codec-Dku-NXXU.mjs +83 -0
  6. package/dist/codec-Dku-NXXU.mjs.map +1 -0
  7. package/dist/compiler.d.mts +75 -0
  8. package/dist/compiler.mjs +1023 -0
  9. package/dist/compiler.mjs.map +1 -0
  10. package/dist/driver-ZxLqy_Ap.d.mts +216 -0
  11. package/dist/index.d.mts +459 -0
  12. package/dist/index.mjs +870 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/kafka.d.mts +51 -0
  15. package/dist/kafka.mjs +517 -0
  16. package/dist/kafka.mjs.map +1 -0
  17. package/dist/memory.d.mts +124 -0
  18. package/dist/memory.mjs +429 -0
  19. package/dist/memory.mjs.map +1 -0
  20. package/dist/rabbitmq.d.mts +65 -0
  21. package/dist/rabbitmq.mjs +686 -0
  22. package/dist/rabbitmq.mjs.map +1 -0
  23. package/dist/shared-BJBpLS8V.mjs +52 -0
  24. package/dist/shared-BJBpLS8V.mjs.map +1 -0
  25. package/dist/sqs.d.mts +38 -0
  26. package/dist/sqs.mjs +421 -0
  27. package/dist/sqs.mjs.map +1 -0
  28. package/package.json +123 -4
  29. package/src/broker.ts +156 -0
  30. package/src/codec.ts +98 -0
  31. package/src/compiler/analyze.ts +800 -0
  32. package/src/compiler/constants.ts +73 -0
  33. package/src/compiler/data.ts +75 -0
  34. package/src/compiler/generate.ts +131 -0
  35. package/src/compiler/index.ts +52 -0
  36. package/src/compiler/patterns.ts +54 -0
  37. package/src/compiler/retry.ts +136 -0
  38. package/src/compiler/validate.ts +57 -0
  39. package/src/compiler/values.ts +76 -0
  40. package/src/consumers.ts +260 -0
  41. package/src/decorators.ts +131 -0
  42. package/src/driver.ts +142 -0
  43. package/src/drivers/kafka.ts +765 -0
  44. package/src/drivers/memory.ts +609 -0
  45. package/src/drivers/rabbitmq.ts +944 -0
  46. package/src/drivers/shared.ts +81 -0
  47. package/src/drivers/sqs.ts +583 -0
  48. package/src/errors.ts +95 -0
  49. package/src/headers.ts +70 -0
  50. package/src/hooks.ts +2 -0
  51. package/src/index.ts +89 -0
  52. package/src/internal/async.ts +115 -0
  53. package/src/message.ts +96 -0
  54. package/src/metadata.ts +65 -0
  55. package/src/plugin.ts +236 -0
  56. package/src/retry.ts +129 -0
  57. package/src/runtime.ts +424 -0
  58. package/src/standard-schema.ts +68 -0
  59. package/src/tokens.ts +14 -0
  60. package/src/validation.ts +61 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Constants shared by the compiler extension modules. This module must stay free of runtime imports:
3
+ * the extension entry is loaded by the compiler, which must never load the messaging runtime (spec §2).
4
+ */
5
+ import type { ErrorCode } from '@znight/core'
6
+
7
+ /** Name of the extension, also the key of `metadata.extensions.messaging`. */
8
+ export const EXTENSION_NAME = 'messaging'
9
+
10
+ /** Module the generated code imports its helpers and types from. */
11
+ export const RUNTIME_MODULE = '@znight/messaging'
12
+
13
+ /** Canonical identities of the decorators owned by the extension. */
14
+ export const MessagingDecorator: {
15
+ readonly Consumer: '@znight/messaging#Consumer'
16
+ readonly OnMessage: '@znight/messaging#OnMessage'
17
+ readonly Retry: '@znight/messaging#Retry'
18
+ readonly OnError: '@znight/messaging#OnError'
19
+ readonly Message: '@znight/messaging#Message'
20
+ readonly Headers: '@znight/messaging#Headers'
21
+ readonly Ctx: '@znight/messaging#Ctx'
22
+ } = Object.freeze({
23
+ Consumer: '@znight/messaging#Consumer',
24
+ OnMessage: '@znight/messaging#OnMessage',
25
+ Retry: '@znight/messaging#Retry',
26
+ OnError: '@znight/messaging#OnError',
27
+ Message: '@znight/messaging#Message',
28
+ Headers: '@znight/messaging#Headers',
29
+ Ctx: '@znight/messaging#Ctx',
30
+ })
31
+
32
+ /** Identity of the `InboundMessage` interface (`@Ctx()` parameters). */
33
+ export const INBOUND_MESSAGE_IDENTITY = '@znight/messaging#InboundMessage'
34
+
35
+ /** Decorators applied to methods. */
36
+ export const METHOD_DECORATORS: ReadonlySet<string> = new Set([
37
+ MessagingDecorator.OnMessage,
38
+ MessagingDecorator.Retry,
39
+ MessagingDecorator.OnError,
40
+ ])
41
+
42
+ /** Decorators applied to handler parameters. */
43
+ export const PARAMETER_DECORATORS: ReadonlySet<string> = new Set([
44
+ MessagingDecorator.Message,
45
+ MessagingDecorator.Headers,
46
+ MessagingDecorator.Ctx,
47
+ ])
48
+
49
+ /** ZNIGHT005: invalid decorator usage (target, arguments, several decorators...). */
50
+ export const INVALID_DECORATOR: ErrorCode = 'ZNIGHT005'
51
+ /** ZNIGHT011: invalid message consumer (no handler, overlapping types, retry options...). */
52
+ export const INVALID_CONSUMER: ErrorCode = 'ZNIGHT011'
53
+ /** ZNIGHT017: non-primitive `@Message()` payload without schema. */
54
+ export const UNVALIDATED_INPUT: ErrorCode = 'ZNIGHT017'
55
+
56
+ /**
57
+ * Default backoff of `@Retry` (must equal the runtime `DEFAULT_BACKOFF`; checked by the tests, the
58
+ * runtime module cannot be imported here).
59
+ */
60
+ export const DEFAULT_BACKOFF = 'exponential'
61
+ /** Default base delay of `@Retry`, in milliseconds (the runtime `DEFAULT_RETRY_DELAY_MS`). */
62
+ export const DEFAULT_RETRY_DELAY_MS = 1000
63
+ /** Largest `attempts` accepted by the runtime (`normalizeRetry`). */
64
+ export const MAX_RETRY_ATTEMPTS = 10_000
65
+
66
+ /** Scopes a consumer provider may have. */
67
+ export const CONSUMER_SCOPES: readonly string[] = ['singleton', 'request', 'transient']
68
+
69
+ /** Deterministic string comparison (never `localeCompare`). */
70
+ export function compareStrings(a: string, b: string): number {
71
+ if (a === b) return 0
72
+ return a < b ? -1 : 1
73
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Data stored by the extension during analysis (`ctx.store()`), read back by `validate` and
3
+ * `generate`. Plain data only (plus the `ValueRef`s of schemas), in source order.
4
+ */
5
+ import type { Location, StaticValue, ValueRef } from '@znight/compiler'
6
+ import type { Token } from '@znight/core'
7
+
8
+ /** A normalized `@Retry` policy (`MessageHandlerDefinition.retry`). */
9
+ export interface RetryPolicyData {
10
+ readonly attempts: number
11
+ readonly backoff: 'fixed' | 'exponential'
12
+ readonly delay: number
13
+ readonly maxDelay?: number
14
+ }
15
+
16
+ /** How the invoker computes one argument of a handler. */
17
+ export type HandlerParamData =
18
+ /** `@Message()`: `m.payload as never`. */
19
+ | { readonly kind: 'payload' }
20
+ /** `@Message(schema)`: `await validateMessage(schema, m.payload)`. */
21
+ | { readonly kind: 'validated'; readonly schema: ValueRef }
22
+ /** `@Headers()`: `m.headers`. */
23
+ | { readonly kind: 'headers' }
24
+ /** `@Headers(name)`: `messageHeader(m, name)`. */
25
+ | { readonly kind: 'header'; readonly name: string }
26
+ /** `@Ctx()`: `m` (`m as InboundMessage<never>` when the parameter narrows the payload type). */
27
+ | { readonly kind: 'message'; readonly narrow: boolean }
28
+ /** Optional parameter without messaging decorator, or invalid parameter (reported): `undefined`. */
29
+ | { readonly kind: 'undefined' }
30
+
31
+ /** One `@OnMessage` decorator of a method. */
32
+ export interface HandlerData {
33
+ readonly method: string
34
+ /** `@OnMessage(type)`; `undefined` for the default handler. */
35
+ readonly type: string | undefined
36
+ readonly retry: RetryPolicyData | undefined
37
+ readonly params: readonly HandlerParamData[]
38
+ /** Display name of the class declaring the method (messages). */
39
+ readonly declaredBy: string
40
+ /** Location of the `@OnMessage` decorator. */
41
+ readonly location: Location
42
+ }
43
+
44
+ /** A `@Consumer` class. */
45
+ export interface ConsumerData {
46
+ readonly kind: 'consumer'
47
+ /** Provider token of the consumer. */
48
+ readonly token: Token
49
+ readonly identity: string
50
+ readonly displayName: string
51
+ /** Empty when the queue is invalid (a diagnostic was reported). */
52
+ readonly queue: string
53
+ /** `@Consumer` options without `queue` and `scope` (`undefined` when empty). */
54
+ readonly options: Readonly<Record<string, StaticValue>> | undefined
55
+ /** Handlers of the class and its analysable base classes, in declaration order (bases first). */
56
+ readonly handlers: readonly HandlerData[]
57
+ readonly errorHandler: string | undefined
58
+ /** Identities of the class and of its analysable base classes (nearest first). */
59
+ readonly chain: readonly string[]
60
+ /** Location of the `@Consumer` decorator. */
61
+ readonly location: Location
62
+ }
63
+
64
+ /** A class carrying messaging member decorators without `@Consumer` (a base class, or a mistake). */
65
+ export interface MemberClassData {
66
+ readonly kind: 'members'
67
+ readonly identity: string
68
+ readonly displayName: string
69
+ readonly abstract: boolean
70
+ /** Method decorators (`@OnMessage`, `@OnError`, `@Retry`), in source order. */
71
+ readonly decorators: readonly { readonly name: string; readonly location: Location }[]
72
+ }
73
+
74
+ /** Everything the extension stores. */
75
+ export type MessagingData = ConsumerData | MemberClassData
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `generate` of the messaging extension: `metadata.extensions.messaging` (README "Compiler contract",
3
+ * spec §9.2). Consumers are sorted by token; handlers keep their declaration order.
4
+ *
5
+ * ```ts
6
+ * {
7
+ * consumers: [{
8
+ * token: 'src/orders/order.consumer.ts#OrderConsumer',
9
+ * queue: 'orders',
10
+ * handlers: [{
11
+ * method: 'placed',
12
+ * type: 'order.placed',
13
+ * retry: { attempts: 5, backoff: 'exponential', delay: 500, maxDelay: 30000 },
14
+ * invoke: async (c: OrderConsumer, m: InboundMessage) => c.placed(await validateMessage(OrderPlaced, m.payload)),
15
+ * }],
16
+ * errorHandler: 'onError',
17
+ * }],
18
+ * }
19
+ * ```
20
+ */
21
+ import type { GenerateContext, GenInput, GenNode } from '@znight/compiler'
22
+ import type { ConsumerData, HandlerData, HandlerParamData, MessagingData } from './data.ts'
23
+
24
+ import { compareStrings, RUNTIME_MODULE } from './constants.ts'
25
+
26
+ type Context = GenerateContext<MessagingData>
27
+
28
+ /**
29
+ * Imports of the runtime helpers, created on first use only: an import the module does not use would
30
+ * break `noUnusedLocals`.
31
+ */
32
+ interface Helpers {
33
+ inboundMessage(): GenNode
34
+ validateMessage(): GenNode
35
+ messageHeader(): GenNode
36
+ }
37
+
38
+ /** Builds the messaging section; `undefined` (no section) when the application has no consumer. */
39
+ export function generateMessaging(ctx: Context): GenNode | undefined {
40
+ const consumers = ctx.stored
41
+ .filter((data): data is ConsumerData => data.kind === 'consumer')
42
+ .sort((a, b) => compareStrings(a.token, b.token))
43
+ if (consumers.length === 0) return undefined
44
+ const helpers = lazyHelpers(ctx)
45
+ return ctx.obj([['consumers', ctx.arr(consumers.map(consumer => consumerNode(ctx, consumer, helpers)))]])
46
+ }
47
+
48
+ function consumerNode(ctx: Context, consumer: ConsumerData, helpers: Helpers): GenNode {
49
+ let classType: GenNode | undefined
50
+ const consumerType = (): GenNode => {
51
+ classType ??= ctx.importType(consumer.identity)
52
+ return classType
53
+ }
54
+ const entries: [string, GenInput][] = [
55
+ ['token', consumer.token],
56
+ ['queue', consumer.queue],
57
+ ]
58
+ if (consumer.options !== undefined) entries.push(['options', ctx.lit(consumer.options)])
59
+ entries.push([
60
+ 'handlers',
61
+ ctx.arr(consumer.handlers.map(handler => handlerNode(ctx, handler, consumerType, helpers))),
62
+ ])
63
+ if (consumer.errorHandler !== undefined) entries.push(['errorHandler', consumer.errorHandler])
64
+ return ctx.obj(entries)
65
+ }
66
+
67
+ function handlerNode(ctx: Context, handler: HandlerData, consumerType: () => GenNode, helpers: Helpers): GenNode {
68
+ const entries: [string, GenInput][] = [['method', handler.method]]
69
+ if (handler.type !== undefined) entries.push(['type', handler.type])
70
+ if (handler.retry !== undefined) entries.push(['retry', ctx.lit({ ...handler.retry })])
71
+ entries.push(['invoke', invokeNode(ctx, handler, consumerType, helpers)])
72
+ return ctx.obj(entries)
73
+ }
74
+
75
+ /**
76
+ * `(c: Consumer, m: InboundMessage) => c.method(...args)`, `async` only when it awaits a schema
77
+ * validation. `m` is omitted when no argument uses it (`noUnusedParameters`).
78
+ */
79
+ function invokeNode(ctx: Context, handler: HandlerData, consumerType: () => GenNode, helpers: Helpers): GenNode {
80
+ const params = [...handler.params]
81
+ while (params.length > 0 && params[params.length - 1]?.kind === 'undefined') params.pop()
82
+ if (params.length === 0) {
83
+ return ctx.arrow([['c', consumerType()]], c => ctx.call(ctx.member(c, handler.method)))
84
+ }
85
+ return ctx.arrow(
86
+ [
87
+ ['c', consumerType()],
88
+ ['m', helpers.inboundMessage()],
89
+ ],
90
+ (c, m) =>
91
+ ctx.call(
92
+ ctx.member(c, handler.method),
93
+ params.map(param => argumentNode(ctx, param, m as GenNode, helpers)),
94
+ ),
95
+ { async: params.some(param => param.kind === 'validated') },
96
+ )
97
+ }
98
+
99
+ function argumentNode(ctx: Context, param: HandlerParamData, m: GenNode, helpers: Helpers): GenNode {
100
+ switch (param.kind) {
101
+ case 'payload':
102
+ return ctx.code`${ctx.member(m, 'payload')} as never`
103
+ case 'validated':
104
+ return ctx.code`await ${ctx.call(helpers.validateMessage(), [ctx.importValue(param.schema), ctx.member(m, 'payload')])}`
105
+ case 'headers':
106
+ return ctx.member(m, 'headers')
107
+ case 'header':
108
+ return ctx.call(helpers.messageHeader(), [m, param.name])
109
+ case 'message':
110
+ return param.narrow ? ctx.code`${m} as ${helpers.inboundMessage()}<never>` : m
111
+ case 'undefined':
112
+ return ctx.lit(undefined)
113
+ }
114
+ }
115
+
116
+ function lazyHelpers(ctx: Context): Helpers {
117
+ const cache = new Map<string, GenNode>()
118
+ const get = (name: string, type: boolean): GenNode => {
119
+ let node = cache.get(name)
120
+ if (node === undefined) {
121
+ node = ctx.importFrom(RUNTIME_MODULE, name, type ? { type: true } : {})
122
+ cache.set(name, node)
123
+ }
124
+ return node
125
+ }
126
+ return {
127
+ inboundMessage: () => get('InboundMessage', true),
128
+ validateMessage: () => get('validateMessage', false),
129
+ messageHeader: () => get('messageHeader', false),
130
+ }
131
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `@znight/messaging/compiler`: the compiler extension of `@znight/messaging` (spec §5, §6.5, §9.2).
3
+ *
4
+ * Loaded by `@znight/compiler` through the package manifest (`package.json#znight.compiler`). It reads
5
+ * `@Consumer` classes and their `@OnMessage` / `@Retry` / `@OnError` methods and `@Message` /
6
+ * `@Headers` / `@Ctx` parameters, registers consumers as providers, reports invalid consumers at build
7
+ * time and generates `metadata.extensions.messaging`.
8
+ *
9
+ * This entry only `import type`s from `@znight/compiler` and never loads the messaging runtime.
10
+ */
11
+ import type { CompilerExtension, DecoratorSpec } from '@znight/compiler'
12
+ import type { MessagingData } from './data.ts'
13
+
14
+ import { analyzeMessagingClass } from './analyze.ts'
15
+ import { EXTENSION_NAME, MessagingDecorator } from './constants.ts'
16
+ import { generateMessaging } from './generate.ts'
17
+ import { validateMessaging } from './validate.ts'
18
+
19
+ export type {
20
+ ConsumerData,
21
+ HandlerData,
22
+ HandlerParamData,
23
+ MemberClassData,
24
+ MessagingData,
25
+ RetryPolicyData,
26
+ } from './data.ts'
27
+
28
+ /**
29
+ * Decorators owned by the extension. `static` arguments must be statically evaluable (queue names,
30
+ * types, retry options, header names); the `@Message` schema is a `value` (an exported binding).
31
+ */
32
+ const decorators: Readonly<Record<string, DecoratorSpec>> = Object.freeze({
33
+ [MessagingDecorator.Consumer]: { targets: ['class'], params: ['static'] },
34
+ [MessagingDecorator.OnMessage]: { targets: ['method'], params: ['static'] },
35
+ [MessagingDecorator.Retry]: { targets: ['method'], params: ['static'] },
36
+ [MessagingDecorator.OnError]: { targets: ['method'], params: [] },
37
+ [MessagingDecorator.Message]: { targets: ['parameter'], params: ['value'] },
38
+ [MessagingDecorator.Headers]: { targets: ['parameter'], params: ['static'] },
39
+ [MessagingDecorator.Ctx]: { targets: ['parameter'], params: [] },
40
+ } satisfies Record<string, DecoratorSpec>)
41
+
42
+ /** The `@znight/messaging` compiler extension. */
43
+ export const messagingCompilerExtension: CompilerExtension<MessagingData> = Object.freeze({
44
+ apiVersion: 1,
45
+ name: EXTENSION_NAME,
46
+ decorators,
47
+ analyzeClass: analyzeMessagingClass,
48
+ validate: validateMessaging,
49
+ generate: generateMessaging,
50
+ })
51
+
52
+ export default messagingCompilerExtension
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Message type patterns of `@OnMessage(type)`, with the semantics of the runtime handler selection
3
+ * (`consumers.ts`): a type containing `*` is a pattern where `*` matches any sequence of characters;
4
+ * any other type is matched exactly. Exact types are tried first, then patterns in declaration order.
5
+ */
6
+
7
+ /** Whether `type` is a pattern (contains `*`). */
8
+ export function isPattern(type: string): boolean {
9
+ return type.includes('*')
10
+ }
11
+
12
+ /** Whether `type` matches `pattern` (`*` matches any sequence of characters, the runtime semantics). */
13
+ export function matchesPattern(pattern: string, type: string): boolean {
14
+ return covers(pattern, type, false)
15
+ }
16
+
17
+ /**
18
+ * Whether every message type matched by `inner` is also matched by `outer`, so that `inner`, tried
19
+ * after `outer`, can never be selected. The check is sound (never reports a pattern that can be
20
+ * selected): `outer` must match `inner` where each `*` of `inner` can only be consumed by a `*` of
21
+ * `outer`.
22
+ */
23
+ export function patternCovers(outer: string, inner: string): boolean {
24
+ return covers(outer, inner, true)
25
+ }
26
+
27
+ /** Glob matching of `subject` by `pattern`; with `starsAreWildcards`, `*` of `subject` only match `*` of `pattern`. */
28
+ function covers(pattern: string, subject: string, starsAreWildcards: boolean): boolean {
29
+ const p = collapseStars(pattern)
30
+ const s = starsAreWildcards ? collapseStars(subject) : subject
31
+ // previous[j]: p[0, i - 1) matches s[0, j)
32
+ let previous: boolean[] = new Array<boolean>(s.length + 1).fill(false)
33
+ previous[0] = true
34
+ for (let i = 1; i <= p.length; i++) {
35
+ const char = p[i - 1]
36
+ const current: boolean[] = new Array<boolean>(s.length + 1).fill(false)
37
+ current[0] = char === '*' && previous[0] === true
38
+ for (let j = 1; j <= s.length; j++) {
39
+ if (char === '*') {
40
+ current[j] = previous[j] === true || current[j - 1] === true
41
+ } else {
42
+ const subjectChar = s[j - 1]
43
+ current[j] = previous[j - 1] === true && subjectChar === char && !(starsAreWildcards && subjectChar === '*')
44
+ }
45
+ }
46
+ previous = current
47
+ }
48
+ return previous[s.length] === true
49
+ }
50
+
51
+ /** `a**b` → `a*b` (same language). */
52
+ function collapseStars(pattern: string): string {
53
+ return pattern.replace(/\*{2,}/g, '*')
54
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Compile-time validation and normalization of `@Retry` options. Mirrors the runtime
3
+ * `normalizeRetry()` (same rules and defaults) so that invalid policies fail the build instead of the
4
+ * boot; it also rejects unknown options (typos) and warns about options without effect.
5
+ */
6
+ import type { AnalyzeContext, DecoratorInfo, StaticValue } from '@znight/compiler'
7
+ import type { MessagingData, RetryPolicyData } from './data.ts'
8
+
9
+ import {
10
+ DEFAULT_BACKOFF,
11
+ DEFAULT_RETRY_DELAY_MS,
12
+ INVALID_CONSUMER,
13
+ INVALID_DECORATOR,
14
+ MAX_RETRY_ATTEMPTS,
15
+ } from './constants.ts'
16
+ import { closest, describeValue, isRecord, propertyLocation } from './values.ts'
17
+
18
+ const RETRY_OPTIONS: readonly string[] = ['attempts', 'backoff', 'delay', 'maxDelay']
19
+
20
+ /**
21
+ * Validates the argument of a `@Retry` decorator and fills the runtime defaults. Returns `undefined`
22
+ * (after reporting the problems) when the options are invalid.
23
+ *
24
+ * @param where the handler (`OrderConsumer.placed()`), used in messages
25
+ */
26
+ export function normalizeRetryDecorator(
27
+ decorator: DecoratorInfo,
28
+ where: string,
29
+ ctx: AnalyzeContext<MessagingData>,
30
+ ): RetryPolicyData | undefined {
31
+ if (!decorator.called) return undefined // `@Retry` without call: already reported
32
+ const arg = decorator.args[0]
33
+ if (arg === undefined) {
34
+ ctx.report({
35
+ code: INVALID_DECORATOR,
36
+ severity: 'error',
37
+ message: `@Retry on ${where} requires options: @Retry({ attempts: 3 })`,
38
+ location: decorator.location,
39
+ hints: ['attempts counts the first delivery: { attempts: 3 } means 1 delivery + 2 retries'],
40
+ })
41
+ return undefined
42
+ }
43
+ if (!arg.evaluated) return undefined // not statically evaluable: already reported
44
+ const options = arg.value
45
+ if (!isRecord(options)) {
46
+ ctx.report({
47
+ code: INVALID_DECORATOR,
48
+ severity: 'error',
49
+ message: `@Retry on ${where} expects an options object ({ attempts, backoff?, delay?, maxDelay? }), got ${describeValue(options)}`,
50
+ location: arg.location,
51
+ })
52
+ return undefined
53
+ }
54
+
55
+ let valid = true
56
+ const fail = (key: string, message: string, hints?: readonly string[]): void => {
57
+ valid = false
58
+ ctx.report({
59
+ code: INVALID_CONSUMER,
60
+ severity: 'error',
61
+ message: `@Retry on ${where}: ${message}`,
62
+ location: propertyLocation(ctx.project, arg, key),
63
+ ...(hints === undefined ? {} : { hints }),
64
+ })
65
+ }
66
+
67
+ for (const key of Object.keys(options)) {
68
+ if (RETRY_OPTIONS.includes(key)) continue
69
+ const suggestion = closest(key, RETRY_OPTIONS)
70
+ fail(key, `unknown option "${key}"`, [
71
+ suggestion === undefined
72
+ ? `Valid options: ${RETRY_OPTIONS.join(', ')}`
73
+ : `Did you mean "${suggestion}"? Valid options: ${RETRY_OPTIONS.join(', ')}`,
74
+ ])
75
+ }
76
+
77
+ const attempts = options['attempts']
78
+ if (attempts === undefined) {
79
+ fail('attempts', '"attempts" is required', [
80
+ 'attempts counts the first delivery: { attempts: 3 } means 1 delivery + 2 retries',
81
+ ])
82
+ } else if (!isInteger(attempts) || attempts < 1 || attempts > MAX_RETRY_ATTEMPTS) {
83
+ fail(
84
+ 'attempts',
85
+ `"attempts" must be an integer between 1 and ${MAX_RETRY_ATTEMPTS}, got ${describeValue(attempts)}`,
86
+ ['attempts counts the first delivery: { attempts: 3 } means 1 delivery + 2 retries'],
87
+ )
88
+ }
89
+
90
+ const rawBackoff = options['backoff']
91
+ const backoff = rawBackoff === undefined ? DEFAULT_BACKOFF : rawBackoff
92
+ if (backoff !== 'fixed' && backoff !== 'exponential') {
93
+ fail('backoff', `"backoff" must be 'fixed' or 'exponential', got ${describeValue(backoff)}`)
94
+ }
95
+
96
+ const rawDelay = options['delay']
97
+ const delay = rawDelay === undefined ? DEFAULT_RETRY_DELAY_MS : rawDelay
98
+ const delayValid = isDuration(delay)
99
+ if (!delayValid) {
100
+ fail('delay', `"delay" must be a finite number of milliseconds >= 0, got ${describeValue(delay)}`)
101
+ }
102
+
103
+ const maxDelay = options['maxDelay']
104
+ if (maxDelay !== undefined) {
105
+ if (!isDuration(maxDelay) || (delayValid && maxDelay < delay)) {
106
+ fail(
107
+ 'maxDelay',
108
+ `"maxDelay" must be a finite number of milliseconds >= delay${delayValid ? ` (${delay})` : ''}, got ${describeValue(maxDelay)}`,
109
+ )
110
+ } else if (backoff === 'fixed') {
111
+ ctx.report({
112
+ code: INVALID_CONSUMER,
113
+ severity: 'warning',
114
+ message: `@Retry on ${where}: "maxDelay" has no effect with backoff 'fixed' (every retry waits "delay")`,
115
+ location: propertyLocation(ctx.project, arg, 'maxDelay'),
116
+ hints: ["Remove maxDelay, or use backoff: 'exponential'"],
117
+ })
118
+ }
119
+ }
120
+
121
+ if (!valid || !isInteger(attempts) || (backoff !== 'fixed' && backoff !== 'exponential') || !isDuration(delay)) {
122
+ return undefined
123
+ }
124
+ const policy: RetryPolicyData = isDuration(maxDelay)
125
+ ? { attempts, backoff, delay, maxDelay }
126
+ : { attempts, backoff, delay }
127
+ return Object.freeze(policy)
128
+ }
129
+
130
+ function isInteger(value: StaticValue): value is number {
131
+ return typeof value === 'number' && Number.isInteger(value)
132
+ }
133
+
134
+ function isDuration(value: StaticValue): value is number {
135
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
136
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `validate` of the messaging extension: checks that need every consumer of the application.
3
+ */
4
+ import type { ValidateContext } from '@znight/compiler'
5
+ import type { ConsumerData, MemberClassData, MessagingData } from './data.ts'
6
+
7
+ import { compareStrings, INVALID_CONSUMER, INVALID_DECORATOR } from './constants.ts'
8
+
9
+ /** Cross-consumer checks: one consumer per queue, messaging decorators outside of consumers. */
10
+ export function validateMessaging(ctx: ValidateContext<MessagingData>): void {
11
+ const consumers = ctx.stored
12
+ .filter((data): data is ConsumerData => data.kind === 'consumer')
13
+ .sort((a, b) => compareStrings(a.token, b.token))
14
+
15
+ // a queue is consumed by a single @Consumer class (the plugin refuses to boot otherwise)
16
+ const byQueue = new Map<string, ConsumerData>()
17
+ for (const consumer of consumers) {
18
+ if (consumer.queue === '') continue
19
+ const first = byQueue.get(consumer.queue)
20
+ if (first === undefined) {
21
+ byQueue.set(consumer.queue, consumer)
22
+ continue
23
+ }
24
+ ctx.report({
25
+ code: INVALID_CONSUMER,
26
+ severity: 'error',
27
+ message: `${first.displayName} and ${consumer.displayName} both consume "${consumer.queue}"`,
28
+ location: consumer.location,
29
+ hints: [
30
+ 'A queue is consumed by a single @Consumer class: merge the handlers, or route by type with @OnMessage(type)',
31
+ ],
32
+ related: [{ message: `${first.displayName} consumes "${consumer.queue}"`, location: first.location }],
33
+ })
34
+ }
35
+
36
+ // @OnMessage / @OnError / @Retry on a class that is neither a consumer nor one of its base classes
37
+ const bases = new Set(consumers.flatMap(consumer => consumer.chain))
38
+ for (const data of ctx.stored) {
39
+ if (data.kind !== 'members' || data.abstract || bases.has(data.identity)) continue
40
+ reportNotAConsumer(data, ctx)
41
+ }
42
+ }
43
+
44
+ function reportNotAConsumer(data: MemberClassData, ctx: ValidateContext<MessagingData>): void {
45
+ for (const decorator of data.decorators) {
46
+ ctx.report({
47
+ code: INVALID_DECORATOR,
48
+ severity: 'error',
49
+ message: `@${decorator.name} has no effect: ${data.displayName} is not a @Consumer`,
50
+ location: decorator.location,
51
+ hints: [
52
+ `Add @Consumer('queue') to ${data.displayName}, or extend it from a @Consumer class`,
53
+ 'Base classes holding shared handlers should be abstract',
54
+ ],
55
+ })
56
+ }
57
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Helpers on statically evaluated decorator arguments and their source locations.
3
+ */
4
+ import type { ArgInfo, Location, Project, StaticValue } from '@znight/compiler'
5
+
6
+ type Expression = ArgInfo['node']
7
+
8
+ /** An evaluated object literal (not an array, not a reference to an exported binding). */
9
+ export function isRecord(value: StaticValue): value is Readonly<Record<string, StaticValue>> {
10
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
11
+ const prototype: unknown = Object.getPrototypeOf(value)
12
+ return prototype === Object.prototype || prototype === null
13
+ }
14
+
15
+ /** Short description of a value for messages (`"orders"`, `42`, `an array`...). */
16
+ export function describeValue(value: StaticValue): string {
17
+ if (value === undefined) return 'undefined'
18
+ if (value === null) return 'null'
19
+ if (typeof value === 'string') return JSON.stringify(value)
20
+ if (typeof value === 'bigint') return `${value}n`
21
+ if (typeof value !== 'object') return String(value)
22
+ if (Array.isArray(value)) return 'an array'
23
+ return isRecord(value) ? 'an object' : `a reference to '${(value as { readonly text?: string }).text ?? '?'}'`
24
+ }
25
+
26
+ /**
27
+ * Location of the property `key` of an object literal argument (`attempts: 0` in
28
+ * `@Retry({ attempts: 0 })`), or of the whole argument when it is not an inline object literal.
29
+ */
30
+ export function propertyLocation(project: Project, arg: ArgInfo, key: string): Location {
31
+ let node: Expression = arg.node
32
+ while (
33
+ node.type === 'TSAsExpression' ||
34
+ node.type === 'TSSatisfiesExpression' ||
35
+ node.type === 'TSNonNullExpression' ||
36
+ node.type === 'ParenthesizedExpression'
37
+ ) {
38
+ node = node.expression
39
+ }
40
+ if (node.type !== 'ObjectExpression') return arg.location
41
+ for (const property of node.properties) {
42
+ if (property.type !== 'Property' || property.computed) continue
43
+ const { key: name } = property
44
+ const found =
45
+ name.type === 'Identifier' ? name.name : name.type === 'Literal' && typeof name.value === 'string' && name.value
46
+ if (found === key) return project.getFile(arg.location.file)?.location(property) ?? arg.location
47
+ }
48
+ return arg.location
49
+ }
50
+
51
+ /** Closest candidate of `name` among `candidates` (typos: `attempt` → `attempts`), if any. */
52
+ export function closest(name: string, candidates: readonly string[]): string | undefined {
53
+ let best: string | undefined
54
+ let bestDistance = Math.max(2, Math.floor(name.length / 3)) + 1
55
+ for (const candidate of candidates) {
56
+ const distance = editDistance(name.toLowerCase(), candidate.toLowerCase())
57
+ if (distance < bestDistance) {
58
+ best = candidate
59
+ bestDistance = distance
60
+ }
61
+ }
62
+ return best
63
+ }
64
+
65
+ function editDistance(a: string, b: string): number {
66
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index)
67
+ for (let i = 1; i <= a.length; i++) {
68
+ const current = [i]
69
+ for (let j = 1; j <= b.length; j++) {
70
+ const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
71
+ current[j] = Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution)
72
+ }
73
+ previous = current
74
+ }
75
+ return previous[b.length] ?? 0
76
+ }