@owlmeans/queue 0.1.18-rc.40 → 0.1.18-rc.41

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 (89) hide show
  1. package/README.md +17 -16
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/queue/SKILL.md +26 -24
  4. package/agent-meta/skills/scheduled-jobs/SKILL.md +1 -1
  5. package/build/bridge/types.d.ts +25 -0
  6. package/build/bridge/types.d.ts.map +1 -0
  7. package/build/bridge/types.js +2 -0
  8. package/build/bridge/types.js.map +1 -0
  9. package/build/bridge.d.ts +5 -28
  10. package/build/bridge.d.ts.map +1 -1
  11. package/build/bridge.js +33 -104
  12. package/build/bridge.js.map +1 -1
  13. package/build/config.d.ts +6 -22
  14. package/build/config.d.ts.map +1 -1
  15. package/build/config.js +10 -92
  16. package/build/config.js.map +1 -1
  17. package/build/entrypoint-jobs/types.d.ts +29 -0
  18. package/build/entrypoint-jobs/types.d.ts.map +1 -0
  19. package/build/entrypoint-jobs/types.js +2 -0
  20. package/build/entrypoint-jobs/types.js.map +1 -0
  21. package/build/entrypoint-jobs.d.ts +5 -0
  22. package/build/entrypoint-jobs.d.ts.map +1 -0
  23. package/build/entrypoint-jobs.js +84 -0
  24. package/build/entrypoint-jobs.js.map +1 -0
  25. package/build/index.d.ts +8 -0
  26. package/build/index.d.ts.map +1 -1
  27. package/build/index.js +3 -0
  28. package/build/index.js.map +1 -1
  29. package/build/protocol/types.d.ts +12 -0
  30. package/build/protocol/types.d.ts.map +1 -0
  31. package/build/protocol/types.js +2 -0
  32. package/build/protocol/types.js.map +1 -0
  33. package/build/protocol.d.ts +6 -3
  34. package/build/protocol.d.ts.map +1 -1
  35. package/build/protocol.js +57 -50
  36. package/build/protocol.js.map +1 -1
  37. package/build/queue-config/types.d.ts +29 -0
  38. package/build/queue-config/types.d.ts.map +1 -0
  39. package/build/queue-config/types.js +2 -0
  40. package/build/queue-config/types.js.map +1 -0
  41. package/build/queue-config.d.ts +9 -0
  42. package/build/queue-config.d.ts.map +1 -0
  43. package/build/queue-config.js +83 -0
  44. package/build/queue-config.js.map +1 -0
  45. package/build/queue-route/types.d.ts +14 -0
  46. package/build/queue-route/types.d.ts.map +1 -0
  47. package/build/queue-route/types.js +2 -0
  48. package/build/queue-route/types.js.map +1 -0
  49. package/build/queue-route.d.ts +4 -0
  50. package/build/queue-route.d.ts.map +1 -0
  51. package/build/queue-route.js +24 -0
  52. package/build/queue-route.js.map +1 -0
  53. package/build/route.d.ts +2 -2
  54. package/build/route.d.ts.map +1 -1
  55. package/build/route.js +5 -20
  56. package/build/route.js.map +1 -1
  57. package/build/transport.d.ts.map +1 -1
  58. package/build/transport.js +2 -2
  59. package/build/transport.js.map +1 -1
  60. package/build/types.d.ts +3 -0
  61. package/build/types.d.ts.map +1 -1
  62. package/build/types.local.d.ts +2 -1
  63. package/build/types.local.d.ts.map +1 -1
  64. package/build/worker.d.ts +0 -17
  65. package/build/worker.d.ts.map +1 -1
  66. package/build/worker.js +1 -36
  67. package/build/worker.js.map +1 -1
  68. package/package.json +8 -8
  69. package/src/bridge/types.ts +25 -0
  70. package/src/bridge.ts +38 -118
  71. package/src/config.ts +14 -105
  72. package/src/entrypoint-jobs/types.ts +29 -0
  73. package/src/entrypoint-jobs.ts +105 -0
  74. package/src/index.ts +8 -0
  75. package/src/protocol/types.ts +16 -0
  76. package/src/protocol.ts +74 -53
  77. package/src/queue-config/types.ts +29 -0
  78. package/src/queue-config.ts +102 -0
  79. package/src/queue-route/types.ts +14 -0
  80. package/src/queue-route.ts +33 -0
  81. package/src/route.ts +6 -23
  82. package/src/transport.ts +3 -3
  83. package/src/types.ts +3 -0
  84. package/src/worker.ts +2 -48
  85. package/tests/bridge.spec.ts +8 -8
  86. package/tests/config.spec.ts +11 -10
  87. package/tests/protocol.spec.ts +6 -6
  88. package/tests/schedule.spec.ts +13 -14
  89. package/src/types.local.ts +0 -3
package/src/bridge.ts CHANGED
@@ -1,126 +1,46 @@
1
- import type { AbstractRequest, CommonEntrypoint, GuardService } from '@owlmeans/entrypoint'
1
+ import type { AbstractRequest } from '@owlmeans/entrypoint'
2
2
  import type { BasicContext } from '@owlmeans/context'
3
- import { EntrypointOutcome, provideResponse } from '@owlmeans/entrypoint'
4
- import type { Auth } from '@owlmeans/auth'
5
- import { AuthorizationError } from '@owlmeans/auth'
6
- import { attachEntity } from '@owlmeans/auth-common'
7
- import { ResilientError } from '@owlmeans/error'
8
- import type { Config, Context, JobContext, JobEnvelope, JobReply, QueueJobMeta } from './types.js'
9
- import { EnvelopeExpired, JobNotServed } from './errors.js'
10
-
11
- /**
12
- * Rebuild the request the producer described. A queued call is still a call: the same guards, the
13
- * same filter, the same handler run against it — only the wire is different.
14
- */
15
- export const requestOf = (
16
- envelope: JobEnvelope, path: string, context?: BasicContext<any>, job?: QueueJobMeta,
17
- ): AbstractRequest => ({
18
- alias: envelope.alias,
19
- params: (envelope.params ?? {}) as AbstractRequest['params'],
20
- body: envelope.body as AbstractRequest['body'],
21
- headers: (envelope.headers ?? {}) as AbstractRequest['headers'],
22
- query: (envelope.query ?? {}) as AbstractRequest['query'],
23
- path,
24
- // Handlers reach their context through `original._ctx`, which the HTTP boundary sets on the raw
25
- // Fastify request. There is no raw request here, so the shape is supplied deliberately — a
26
- // handler must not have to know which transport delivered it.
27
- original: context != null || job != null ? { _ctx: context, _job: job } : undefined,
28
- })
29
-
30
- /** Read broker identity only where an entrypoint truly needs to bind work to an admission claim. */
31
- export const queueJobOf = (request: unknown): QueueJobMeta | null => {
32
- if (request == null || typeof request !== 'object' || !('original' in request)) return null
33
- const original = (request as { original?: unknown }).original
34
- if (original == null || typeof original !== 'object' || !('_job' in original)) return null
35
- return (original as { _job?: QueueJobMeta })._job ?? null
36
- }
37
-
38
- /**
39
- * Freshness is judged from when the job was ENQUEUED, not from now.
40
- *
41
- * A signed envelope proves who produced it, and a replay window keeps a captured one from being
42
- * useful forever. But a job can legitimately sit behind a long backlog, and judging it on pickup
43
- * would reject exactly the work that a busy queue delayed — so the producer's timestamp is what
44
- * the window applies to.
45
- *
46
- * @throws {EnvelopeExpired}
47
- */
48
- export const assertFresh = (envelope: JobEnvelope, ttl?: number): void => {
49
- if (ttl == null || envelope.enqueuedAt == null) {
50
- return
51
- }
52
- const age = (Date.now() - new Date(envelope.enqueuedAt).getTime()) / 1000
53
- if (age > ttl) {
54
- throw new EnvelopeExpired(`${envelope.alias}: ${Math.round(age)}s > ${ttl}s`)
3
+ import type { JobEnvelope, QueueJobMeta } from './types.js'
4
+ import type { QueueBridgeHelper } from './bridge/types.js'
5
+ import { EnvelopeExpired } from './errors.js'
6
+
7
+ export const createQueueBridgeHelper = (): QueueBridgeHelper => {
8
+ const requestOf = (
9
+ envelope: JobEnvelope, path: string, context?: BasicContext<any>, job?: QueueJobMeta,
10
+ ): AbstractRequest => ({
11
+ alias: envelope.alias,
12
+ params: (envelope.params ?? {}) as AbstractRequest['params'],
13
+ body: envelope.body as AbstractRequest['body'],
14
+ headers: (envelope.headers ?? {}) as AbstractRequest['headers'],
15
+ query: (envelope.query ?? {}) as AbstractRequest['query'],
16
+ path,
17
+ // Handlers reach their context through `original._ctx`, which the HTTP boundary sets on the raw
18
+ // Fastify request. There is no raw request here, so the shape is supplied deliberately — a
19
+ // handler must not have to know which transport delivered it.
20
+ original: context != null || job != null ? { _ctx: context, _job: job } : undefined,
21
+ })
22
+
23
+ const queueJobOf = (request: unknown): QueueJobMeta | null => {
24
+ if (request == null || typeof request !== 'object' || !('original' in request)) return null
25
+ const original = (request as { original?: unknown }).original
26
+ if (original == null || typeof original !== 'object' || !('_job' in original)) return null
27
+ return (original as { _job?: QueueJobMeta })._job ?? null
55
28
  }
56
- }
57
-
58
- /**
59
- * Run one queued entrypoint call and describe the outcome.
60
- *
61
- * It never throws for a DOMAIN failure — the error is marshalled into the returned reply so the
62
- * producer can rebuild it as its own class, and so the broker does not count a legitimate refusal
63
- * as a job to retry. Infrastructure failures are left to propagate: those are what retries are for.
64
- */
65
- export const handleJob = async <C extends Config, T extends Context<C>>(
66
- context: T, job: JobContext<JobEnvelope>
67
- ): Promise<JobReply> => {
68
- const envelope = job.data
69
-
70
- try {
71
- assertFresh(envelope, context.cfg.queue?.envelopeTtl)
72
29
 
73
- const entrypoint = context.entrypoint<CommonEntrypoint>(envelope.alias)
74
- if (entrypoint.handle == null) {
75
- throw new JobNotServed(envelope.alias)
30
+ const assertFresh = (envelope: JobEnvelope, ttl?: number): void => {
31
+ if (ttl == null || envelope.enqueuedAt == null) {
32
+ return
76
33
  }
77
-
78
- const request = requestOf(envelope, entrypoint.path(), context, {
79
- id: job.id, name: job.name, queue: job.queue, attempt: job.attempt, touch: job.touch,
80
- })
81
- const response = provideResponse<unknown>()
82
-
83
- const guards = entrypoint.getGuards()
84
- if (guards.length > 0) {
85
- let matched: GuardService | undefined
86
- for (const alias of guards) {
87
- const guard: GuardService = context.service(alias)
88
- if (await guard.match(request, response)) {
89
- matched = guard
90
- break
91
- }
92
- }
93
- if (matched == null) {
94
- throw new AuthorizationError(`queue:${envelope.alias}`)
95
- }
96
-
97
- const auth = provideResponse<Auth>()
98
- if (!await matched.handle<boolean>(request, auth)) {
99
- throw new AuthorizationError(`queue:${envelope.alias}:${matched.alias}`)
100
- }
101
- request.auth = auth.value
102
-
103
- const entity = await attachEntity(context, request)
104
- if (entity != null) {
105
- request.entity = entity
106
- }
34
+ const age = (Date.now() - new Date(envelope.enqueuedAt).getTime()) / 1000
35
+ if (age > ttl) {
36
+ throw new EnvelopeExpired(`${envelope.alias}: ${Math.round(age)}s > ${ttl}s`)
107
37
  }
38
+ }
108
39
 
109
- await entrypoint.handle(request, response)
110
-
111
- if (response.error != null) {
112
- return { error: ResilientError.marshal(response.error).message }
113
- }
40
+ return { requestOf, queueJobOf, assertFresh }
41
+ }
114
42
 
115
- return { value: response.value, outcome: response.outcome ?? EntrypointOutcome.Ok }
116
- } catch (e) {
117
- // A refusal the caller must see as its own class — `ContentRefused`, `AgentLocked` — travels
118
- // in the reply. Only a broker- or connection-level fault should ever reach the retry logic:
119
- // retrying a refusal just spends the work again to be refused identically.
120
- if (e instanceof ResilientError) {
121
- return { error: ResilientError.marshal(e).message }
122
- }
43
+ export const queueBridgeHelper = createQueueBridgeHelper()
123
44
 
124
- throw e
125
- }
126
- }
45
+ /** @deprecated compat:factory-refactor — use `queueBridgeHelper.queueJobOf(…)` */
46
+ export const queueJobOf = (request: unknown): QueueJobMeta | null => queueBridgeHelper.queueJobOf(request)
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  Config, JobOptions, QueueDeclaration, QueueWorkerOptions, ScheduleDeclaration
3
3
  } from './types.js'
4
- import { ScheduleMisdeclared, UnknownJobName, UnknownQueue } from './errors.js'
4
+ import { queueConfigOf } from './queue-config.js'
5
5
 
6
6
  /**
7
7
  * Add a queue to the shared configuration. Every process in the monorepo loads the same
@@ -38,91 +38,6 @@ export const listenQueues = <C extends Config>(cfg: C, ...names: string[]): C =>
38
38
  return cfg
39
39
  }
40
40
 
41
- /**
42
- * @throws {UnknownQueue}
43
- */
44
- export const queueOf = <C extends Config>(cfg: C, name: string): QueueDeclaration => {
45
- const queue = cfg.queue?.queues?.find(declared => declared.name === name)
46
-
47
- if (queue == null) {
48
- throw new UnknownQueue(name)
49
- }
50
-
51
- return queue
52
- }
53
-
54
- export const queueOfJob = <C extends Config>(cfg: C, job: string): QueueDeclaration | undefined =>
55
- cfg.queue?.queues?.find(declared => declared.jobs.includes(job))
56
-
57
- export const isListening = <C extends Config>(cfg: C, name: string): boolean =>
58
- cfg.queue?.listen?.includes(name) === true
59
-
60
- /** A date option the broker can read — a `Date`, epoch milliseconds or a parsable string. */
61
- const isInstant = (value: Date | number | string): boolean =>
62
- Number.isFinite((value instanceof Date ? value : new Date(value)).getTime())
63
-
64
- /**
65
- * Whether the broker can carry a schedule out as written. Nothing is recorded or changed.
66
- *
67
- * @throws {ScheduleMisdeclared} naming the schedule id and the reason.
68
- * @throws {UnknownQueue} when the schedule's queue is not declared.
69
- * @throws {UnknownJobName} when that queue does not accept the schedule's job name — a scheduled
70
- * run of a name nothing declares would fail on every tick instead of once, at declaration.
71
- */
72
- export const assertSchedule = <C extends Config>(cfg: C, schedule: ScheduleDeclaration): void => {
73
- const { id } = schedule
74
- if (typeof id !== 'string' || id.trim() === '') {
75
- throw new ScheduleMisdeclared('(empty):id')
76
- }
77
-
78
- const queue = queueOf(cfg, schedule.queue)
79
- if (!queue.jobs.includes(schedule.name)) {
80
- throw new UnknownJobName(`${schedule.queue}:${schedule.name}`)
81
- }
82
-
83
- const every = schedule.every != null
84
- const pattern = schedule.pattern != null
85
- if (every === pattern) {
86
- throw new ScheduleMisdeclared(`${id}:every-or-pattern`)
87
- }
88
- if (every && !(Number.isSafeInteger(schedule.every) && (schedule.every as number) > 0)) {
89
- throw new ScheduleMisdeclared(`${id}:every`)
90
- }
91
- if (pattern && (typeof schedule.pattern !== 'string' || schedule.pattern.trim() === '')) {
92
- throw new ScheduleMisdeclared(`${id}:pattern`)
93
- }
94
- // Both only mean something to a cron pattern: an interval has no wall clock to read in a time
95
- // zone, and its first run is immediate already.
96
- if (!pattern && schedule.tz != null) {
97
- throw new ScheduleMisdeclared(`${id}:tz`)
98
- }
99
- if (!pattern && schedule.immediately === true) {
100
- throw new ScheduleMisdeclared(`${id}:immediately`)
101
- }
102
- if (schedule.immediately === true && schedule.startDate != null) {
103
- throw new ScheduleMisdeclared(`${id}:immediately-start-date`)
104
- }
105
- if (schedule.limit != null && !(Number.isSafeInteger(schedule.limit) && schedule.limit > 0)) {
106
- throw new ScheduleMisdeclared(`${id}:limit`)
107
- }
108
- if (schedule.startDate != null && !isInstant(schedule.startDate)) {
109
- throw new ScheduleMisdeclared(`${id}:start-date`)
110
- }
111
- if (schedule.endDate != null && !isInstant(schedule.endDate)) {
112
- throw new ScheduleMisdeclared(`${id}:end-date`)
113
- }
114
-
115
- // The type already omits both; a declaration assembled at runtime still reaches this check. The
116
- // broker names every run itself and places it on the schedule, so either would be overridden.
117
- const opts = schedule.opts as JobOptions | undefined
118
- if (opts?.id != null) {
119
- throw new ScheduleMisdeclared(`${id}:opts-id`)
120
- }
121
- if (opts?.delay != null) {
122
- throw new ScheduleMisdeclared(`${id}:opts-delay`)
123
- }
124
- }
125
-
126
41
  /**
127
42
  * Add a recurring job to the shared configuration. Declare the queue first — a schedule is checked
128
43
  * against it here. Declaring an id twice replaces, like `declareQueue`.
@@ -133,7 +48,7 @@ export const assertSchedule = <C extends Config>(cfg: C, schedule: ScheduleDecla
133
48
  * @throws {ScheduleMisdeclared} / {UnknownQueue} / {UnknownJobName} — see `assertSchedule`.
134
49
  */
135
50
  export const declareSchedule = <C extends Config>(cfg: C, schedule: ScheduleDeclaration): C => {
136
- assertSchedule(cfg, schedule)
51
+ queueConfigOf(cfg).assertSchedule(schedule)
137
52
 
138
53
  const schedules = cfg.queue?.schedules ?? []
139
54
  const existing = schedules.findIndex(declared => declared.id === schedule.id)
@@ -149,23 +64,17 @@ export const declareSchedule = <C extends Config>(cfg: C, schedule: ScheduleDecl
149
64
  return cfg
150
65
  }
151
66
 
152
- /** The declared schedules, of one queue when it is named. */
67
+ /** @deprecated compat:factory-refactor — use `queueConfigOf(cfg).queueOfJob(…)` */
68
+ export const queueOfJob = <C extends Config>(cfg: C, job: string): QueueDeclaration | undefined =>
69
+ queueConfigOf(cfg).queueOfJob(job)
70
+
71
+ /** @deprecated compat:factory-refactor — use `queueConfigOf(cfg).isListening(…)` */
72
+ export const isListening = <C extends Config>(cfg: C, name: string): boolean =>
73
+ queueConfigOf(cfg).isListening(name)
74
+
75
+ /** @deprecated compat:factory-refactor — use `queueConfigOf(cfg).schedulesOf(…)` */
153
76
  export const schedulesOf = <C extends Config>(cfg: C, queue?: string): ScheduleDeclaration[] =>
154
- (cfg.queue?.schedules ?? []).filter(schedule => queue == null || schedule.queue === queue)
77
+ queueConfigOf(cfg).schedulesOf(queue)
155
78
 
156
- /**
157
- * Check every declared schedule, including any written into the configuration without
158
- * `declareSchedule`, and refuse two under one id.
159
- *
160
- * @throws {ScheduleMisdeclared} / {UnknownQueue} / {UnknownJobName} for the first one that fails.
161
- */
162
- export const assertSchedules = <C extends Config>(cfg: C): void => {
163
- const seen = new Set<string>()
164
- for (const schedule of schedulesOf(cfg)) {
165
- assertSchedule(cfg, schedule)
166
- if (seen.has(schedule.id)) {
167
- throw new ScheduleMisdeclared(`${schedule.id}:duplicate`)
168
- }
169
- seen.add(schedule.id)
170
- }
171
- }
79
+ /** @deprecated compat:factory-refactor — use `queueConfigOf(cfg).assertSchedules()` */
80
+ export const assertSchedules = <C extends Config>(cfg: C): void => queueConfigOf(cfg).assertSchedules()
@@ -0,0 +1,29 @@
1
+ import type { CommonEntrypoint } from '@owlmeans/entrypoint'
2
+ import type { JobContext, JobEnvelope, JobProcessor, JobReply } from '../types.js'
3
+
4
+ /** The queued entrypoints one context serves as a worker, and how a job of theirs is run. */
5
+ export interface EntrypointJobsHelper {
6
+ /**
7
+ * Every queued entrypoint this process both SERVES and LISTENS to, grouped by queue.
8
+ *
9
+ * Both halves are required and they answer different questions. Serving is about code — the alias
10
+ * was bound here, so a handler exists. Listening is about deployment — this process was
11
+ * configured to consume that queue. A worker that bound queues by what it can serve would make
12
+ * every deployment of the same binary a worker for everything it happens to import.
13
+ */
14
+ served: () => Map<string, CommonEntrypoint[]>
15
+ /**
16
+ * A processor that runs a queued entrypoint call. The driver dispatches by job name, and an
17
+ * entrypoint job's name IS its alias — which is what lets one worker carry both entrypoint jobs
18
+ * and the internal steps an application registers with `process()`.
19
+ */
20
+ processor: () => JobProcessor<JobEnvelope, unknown>
21
+ /**
22
+ * Run one queued entrypoint call and describe the outcome.
23
+ *
24
+ * It never throws for a DOMAIN failure — the error is marshalled into the returned reply so the
25
+ * producer can rebuild it as its own class, and so the broker does not count a legitimate refusal
26
+ * as a job to retry. Infrastructure failures are left to propagate: those are what retries are for.
27
+ */
28
+ handle: (job: JobContext<JobEnvelope>) => Promise<JobReply>
29
+ }
@@ -0,0 +1,105 @@
1
+ import { AppType, memoHelper } from '@owlmeans/context'
2
+ import type { CommonEntrypoint, GuardService } from '@owlmeans/entrypoint'
3
+ import { EntrypointOutcome, provideResponse } from '@owlmeans/entrypoint'
4
+ import type { Auth } from '@owlmeans/auth'
5
+ import { AuthorizationError } from '@owlmeans/auth'
6
+ import { ResilientError } from '@owlmeans/error'
7
+ import type { Config, Context, JobContext, JobEnvelope, JobProcessor, JobReply } from './types.js'
8
+ import type { EntrypointJobsHelper } from './entrypoint-jobs/types.js'
9
+ import { JobNotServed } from './errors.js'
10
+ import { queueBridgeHelper } from './bridge.js'
11
+ import { queueRouteHelper } from './queue-route.js'
12
+ import { makeEntityScope } from '@owlmeans/auth-common'
13
+
14
+ export const makeEntrypointJobsHelper = (context: Context<Config>): EntrypointJobsHelper => {
15
+ const served = (): Map<string, CommonEntrypoint[]> => {
16
+ const listen = context.cfg.queue?.listen ?? []
17
+ const served = new Map<string, CommonEntrypoint[]>()
18
+
19
+ context.entrypoints<CommonEntrypoint>().forEach(entrypoint => {
20
+ const route = entrypoint.route.route
21
+ if (route.type !== AppType.Backend || !queueRouteHelper.isQueueRoute(route)) {
22
+ return
23
+ }
24
+ if (route.service != null && route.service !== context.cfg.service) {
25
+ return
26
+ }
27
+ const { queue } = queueRouteHelper.queueRouteOptions(route)
28
+ if (entrypoint.handle == null || !listen.includes(queue)) {
29
+ return
30
+ }
31
+
32
+ served.set(queue, [...(served.get(queue) ?? []), entrypoint])
33
+ })
34
+
35
+ return served
36
+ }
37
+
38
+ const handle = async (job: JobContext<JobEnvelope>): Promise<JobReply> => {
39
+ const envelope = job.data
40
+
41
+ try {
42
+ queueBridgeHelper.assertFresh(envelope, context.cfg.queue?.envelopeTtl)
43
+
44
+ const entrypoint = context.entrypoint<CommonEntrypoint>(envelope.alias)
45
+ if (entrypoint.handle == null) {
46
+ throw new JobNotServed(envelope.alias)
47
+ }
48
+
49
+ const request = queueBridgeHelper.requestOf(envelope, entrypoint.path(), context, {
50
+ id: job.id, name: job.name, queue: job.queue, attempt: job.attempt, touch: job.touch,
51
+ })
52
+ const response = provideResponse<unknown>()
53
+
54
+ const guards = entrypoint.getGuards()
55
+ if (guards.length > 0) {
56
+ let matched: GuardService | undefined
57
+ for (const alias of guards) {
58
+ const guard: GuardService = context.service(alias)
59
+ if (await guard.match(request, response)) {
60
+ matched = guard
61
+ break
62
+ }
63
+ }
64
+ if (matched == null) {
65
+ throw new AuthorizationError(`queue:${envelope.alias}`)
66
+ }
67
+
68
+ const auth = provideResponse<Auth>()
69
+ if (!await matched.handle<boolean>(request, auth)) {
70
+ throw new AuthorizationError(`queue:${envelope.alias}:${matched.alias}`)
71
+ }
72
+ request.auth = auth.value
73
+
74
+ const entity = await makeEntityScope(request).attachEntity(context)
75
+ if (entity != null) {
76
+ request.entity = entity
77
+ }
78
+ }
79
+
80
+ await entrypoint.handle(request, response)
81
+
82
+ if (response.error != null) {
83
+ return { error: ResilientError.marshal(response.error).message }
84
+ }
85
+
86
+ return { value: response.value, outcome: response.outcome ?? EntrypointOutcome.Ok }
87
+ } catch (e) {
88
+ // A refusal the caller must see as its own class — `ContentRefused`, `AgentLocked` — travels
89
+ // in the reply. Only a broker- or connection-level fault should ever reach the retry logic:
90
+ // retrying a refusal just spends the work again to be refused identically.
91
+ if (e instanceof ResilientError) {
92
+ return { error: ResilientError.marshal(e).message }
93
+ }
94
+
95
+ throw e
96
+ }
97
+ }
98
+
99
+ const processor = (): JobProcessor<JobEnvelope, unknown> =>
100
+ async (job: JobContext<JobEnvelope>) => await handle(job)
101
+
102
+ return { served, processor, handle }
103
+ }
104
+
105
+ export const entrypointJobsOf = memoHelper.oncePer(makeEntrypointJobsHelper)
package/src/index.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  export * from './types.js'
2
+ export type * from './queue-config/types.js'
3
+ export type * from './bridge/types.js'
4
+ export type * from './queue-route/types.js'
5
+ export type * from './protocol/types.js'
6
+ export type * from './entrypoint-jobs/types.js'
2
7
  export * from './consts.js'
3
8
  export * from './utils.js'
4
9
  export * from './errors.js'
5
10
  export * from './config.js'
11
+ export * from './queue-config.js'
6
12
  export * from './transport.js'
7
13
  export * from './bridge.js'
14
+ export * from './entrypoint-jobs.js'
8
15
  export * from './worker.js'
9
16
  export * from './protocol.js'
10
17
  export * from './route.js'
18
+ export * from './queue-route.js'
@@ -0,0 +1,16 @@
1
+ import type { EntrypointProtocolDeclaration, RequestOf, ResponseOf } from '@owlmeans/entrypoint'
2
+ import type { JobEnvelope, JobOptions, JobRecord, JobReply } from '../types.js'
3
+
4
+ /** Enqueueing and awaiting QUEUE protocols in one context, with their exact types kept. */
5
+ export interface QueueProtocolHelper {
6
+ /** Enqueue an immutable QUEUE protocol while preserving its exact request and response types. */
7
+ enqueue: <Protocol extends EntrypointProtocolDeclaration>(
8
+ declaration: Protocol, request: RequestOf<Protocol>, options?: JobOptions,
9
+ ) => Promise<JobRecord<JobEnvelope, JobReply<ResponseOf<Protocol>>>>
10
+ /** Wait for a protocol job and unwrap the typed entrypoint reply. */
11
+ waitFor: <Protocol extends EntrypointProtocolDeclaration>(
12
+ declaration: Protocol,
13
+ job: string | JobRecord<JobEnvelope, JobReply<ResponseOf<Protocol>>>,
14
+ options?: { timeout?: number },
15
+ ) => Promise<ResponseOf<Protocol>>
16
+ }
package/src/protocol.ts CHANGED
@@ -1,74 +1,95 @@
1
1
  import { type EntrypointProtocolDeclaration, type RequestOf, type ResponseOf, isEntrypointProtocol } from '@owlmeans/entrypoint'
2
+ import { memoHelper } from '@owlmeans/context'
2
3
  import { ResilientError } from '@owlmeans/error'
3
- import { queueOf } from './config.js'
4
+ import { queueConfigOf } from './queue-config.js'
4
5
  import { UnknownQueue } from './errors.js'
5
- import { isQueueRoute, queueRouteOptions } from './route.js'
6
+ import { queueRouteHelper } from './queue-route.js'
6
7
  import type { JobEnvelope, JobOptions, JobRecord, JobReply } from './types.js'
7
- import type { QueueContext } from './types.local.js'
8
+ import type { QueueContext } from './types.js'
9
+ import type { QueueProtocolHelper } from './protocol/types.js'
8
10
 
9
- const queueFor = (context: QueueContext, declaration: unknown): {
10
- protocol: EntrypointProtocolDeclaration
11
- queue: string
12
- } => {
13
- if (declaration == null || typeof declaration !== 'object' || !isEntrypointProtocol(declaration)) {
14
- throw new UnknownQueue('protocol declaration required')
11
+ export const makeQueueProtocolHelper = (context: QueueContext): QueueProtocolHelper => {
12
+ const queueFor = (declaration: unknown): {
13
+ protocol: EntrypointProtocolDeclaration
14
+ queue: string
15
+ } => {
16
+ if (declaration == null || typeof declaration !== 'object' || !isEntrypointProtocol(declaration)) {
17
+ throw new UnknownQueue('protocol declaration required')
18
+ }
19
+ if (!queueRouteHelper.isQueueRoute(declaration.route)) {
20
+ throw new UnknownQueue(`${declaration.alias}: not a queue protocol`)
21
+ }
22
+ const { queue } = queueRouteHelper.queueRouteOptions(declaration.route)
23
+ const configured = queueConfigOf(context.cfg).queueOf(queue)
24
+ if (!configured.jobs.includes(declaration.alias)) {
25
+ throw new UnknownQueue(`${queue}:${declaration.alias}`)
26
+ }
27
+ return { protocol: declaration, queue }
15
28
  }
16
- if (!isQueueRoute(declaration.route)) {
17
- throw new UnknownQueue(`${declaration.alias}: not a queue protocol`)
29
+
30
+ const enqueue = async <Protocol extends EntrypointProtocolDeclaration>(
31
+ declaration: Protocol,
32
+ request: RequestOf<Protocol>,
33
+ options?: JobOptions,
34
+ ): Promise<JobRecord<JobEnvelope, JobReply<ResponseOf<Protocol>>>> => {
35
+ const { protocol, queue } = queueFor(declaration)
36
+ const shaped = (request ?? {}) as unknown as Record<string, unknown>
37
+ const envelope: JobEnvelope = {
38
+ alias: protocol.alias,
39
+ params: shaped.params as Record<string, unknown> | undefined,
40
+ body: shaped.body,
41
+ query: shaped.query as Record<string, unknown> | undefined,
42
+ headers: shaped.headers as Record<string, string | undefined> | undefined,
43
+ enqueuedAt: new Date().toISOString(),
44
+ }
45
+
46
+ return await context.jobs<JobEnvelope, JobReply<ResponseOf<Protocol>>>(queue).create({
47
+ queue,
48
+ name: protocol.alias,
49
+ data: envelope,
50
+ opts: options,
51
+ })
18
52
  }
19
- const { queue } = queueRouteOptions(declaration.route)
20
- const configured = queueOf(context.cfg, queue)
21
- if (!configured.jobs.includes(declaration.alias)) {
22
- throw new UnknownQueue(`${queue}:${declaration.alias}`)
53
+
54
+ const waitFor = async <Protocol extends EntrypointProtocolDeclaration>(
55
+ declaration: Protocol,
56
+ job: string | JobRecord<JobEnvelope, JobReply<ResponseOf<Protocol>>>,
57
+ options?: { timeout?: number },
58
+ ): Promise<ResponseOf<Protocol>> => {
59
+ const { protocol, queue } = queueFor(declaration)
60
+ if (typeof job !== 'string' && (job.queue !== queue || job.name !== protocol.alias)) {
61
+ throw new UnknownQueue(`${job.queue}:${job.name}`)
62
+ }
63
+ const id = typeof job === 'string' ? job : job.id
64
+ if (id == null) {
65
+ throw new UnknownQueue(`${queue}:${protocol.alias}: missing job id`)
66
+ }
67
+ const reply = await context.jobs<JobEnvelope, JobReply<ResponseOf<Protocol>>>(queue)
68
+ .wait(id, options)
69
+ if (reply.error != null) {
70
+ throw ResilientError.ensure(reply.error as Error | string)
71
+ }
72
+ return reply.value as ResponseOf<Protocol>
23
73
  }
24
- return { protocol: declaration, queue }
74
+
75
+ return { enqueue, waitFor }
25
76
  }
26
77
 
27
- /** Enqueue an immutable QUEUE protocol while preserving its exact request and response types. */
78
+ export const queueProtocolOf = memoHelper.oncePer(makeQueueProtocolHelper)
79
+
80
+ /** @deprecated compat:factory-refactor — use `queueProtocolOf(ctx).enqueue(…)` */
28
81
  export const enqueueProtocol = async <Protocol extends EntrypointProtocolDeclaration>(
29
82
  context: QueueContext,
30
83
  declaration: Protocol,
31
84
  request: RequestOf<Protocol>,
32
85
  options?: JobOptions,
33
- ): Promise<JobRecord<JobEnvelope, JobReply<ResponseOf<Protocol>>>> => {
34
- const { protocol, queue } = queueFor(context, declaration)
35
- const shaped = (request ?? {}) as unknown as Record<string, unknown>
36
- const envelope: JobEnvelope = {
37
- alias: protocol.alias,
38
- params: shaped.params as Record<string, unknown> | undefined,
39
- body: shaped.body,
40
- query: shaped.query as Record<string, unknown> | undefined,
41
- headers: shaped.headers as Record<string, string | undefined> | undefined,
42
- enqueuedAt: new Date().toISOString(),
43
- }
86
+ ): Promise<JobRecord<JobEnvelope, JobReply<ResponseOf<Protocol>>>> =>
87
+ await queueProtocolOf(context).enqueue(declaration, request, options)
44
88
 
45
- return await context.jobs<JobEnvelope, JobReply<ResponseOf<Protocol>>>(queue).create({
46
- queue,
47
- name: protocol.alias,
48
- data: envelope,
49
- opts: options,
50
- })
51
- }
52
-
53
- /** Wait for a protocol job and unwrap the typed entrypoint reply. */
89
+ /** @deprecated compat:factory-refactor — use `queueProtocolOf(ctx).waitFor(…)` */
54
90
  export const waitForProtocol = async <Protocol extends EntrypointProtocolDeclaration>(
55
91
  context: QueueContext,
56
92
  declaration: Protocol,
57
93
  job: string | JobRecord<JobEnvelope, JobReply<ResponseOf<Protocol>>>,
58
94
  options?: { timeout?: number },
59
- ): Promise<ResponseOf<Protocol>> => {
60
- const { protocol, queue } = queueFor(context, declaration)
61
- if (typeof job !== 'string' && (job.queue !== queue || job.name !== protocol.alias)) {
62
- throw new UnknownQueue(`${job.queue}:${job.name}`)
63
- }
64
- const id = typeof job === 'string' ? job : job.id
65
- if (id == null) {
66
- throw new UnknownQueue(`${queue}:${protocol.alias}: missing job id`)
67
- }
68
- const reply = await context.jobs<JobEnvelope, JobReply<ResponseOf<Protocol>>>(queue)
69
- .wait(id, options)
70
- if (reply.error != null) {
71
- throw ResilientError.ensure(reply.error as Error | string)
72
- }
73
- return reply.value as ResponseOf<Protocol>
74
- }
95
+ ): Promise<ResponseOf<Protocol>> => await queueProtocolOf(context).waitFor(declaration, job, options)
@@ -0,0 +1,29 @@
1
+ import type { QueueDeclaration, ScheduleDeclaration } from '../types.js'
2
+
3
+ /** What one configuration declares about its queues and schedules, read and checked. */
4
+ export interface QueueConfigHelper {
5
+ /**
6
+ * @throws {UnknownQueue}
7
+ */
8
+ queueOf: (name: string) => QueueDeclaration
9
+ queueOfJob: (job: string) => QueueDeclaration | undefined
10
+ isListening: (name: string) => boolean
11
+ /**
12
+ * Whether the broker can carry a schedule out as written. Nothing is recorded or changed.
13
+ *
14
+ * @throws {ScheduleMisdeclared} naming the schedule id and the reason.
15
+ * @throws {UnknownQueue} when the schedule's queue is not declared.
16
+ * @throws {UnknownJobName} when that queue does not accept the schedule's job name — a scheduled
17
+ * run of a name nothing declares would fail on every tick instead of once, at declaration.
18
+ */
19
+ assertSchedule: (schedule: ScheduleDeclaration) => void
20
+ /** The declared schedules, of one queue when it is named. */
21
+ schedulesOf: (queue?: string) => ScheduleDeclaration[]
22
+ /**
23
+ * Check every declared schedule, including any written into the configuration without
24
+ * `declareSchedule`, and refuse two under one id.
25
+ *
26
+ * @throws {ScheduleMisdeclared} / {UnknownQueue} / {UnknownJobName} for the first one that fails.
27
+ */
28
+ assertSchedules: () => void
29
+ }