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

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 (71) hide show
  1. package/README.md +285 -8
  2. package/agent-meta/manifest.json +9 -2
  3. package/agent-meta/skills/queue/SKILL.md +241 -12
  4. package/agent-meta/skills/scheduled-jobs/SKILL.md +151 -0
  5. package/build/bridge.d.ts +30 -0
  6. package/build/bridge.d.ts.map +1 -0
  7. package/build/bridge.js +108 -0
  8. package/build/bridge.js.map +1 -0
  9. package/build/config.d.ts +50 -0
  10. package/build/config.d.ts.map +1 -0
  11. package/build/config.js +141 -0
  12. package/build/config.js.map +1 -0
  13. package/build/consts.d.ts +33 -0
  14. package/build/consts.d.ts.map +1 -0
  15. package/build/consts.js +35 -0
  16. package/build/consts.js.map +1 -0
  17. package/build/errors.d.ts +68 -0
  18. package/build/errors.d.ts.map +1 -0
  19. package/build/errors.js +103 -0
  20. package/build/errors.js.map +1 -0
  21. package/build/index.d.ts +10 -0
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +10 -1
  24. package/build/index.js.map +1 -1
  25. package/build/protocol.d.ts +10 -0
  26. package/build/protocol.d.ts.map +1 -0
  27. package/build/protocol.js +56 -0
  28. package/build/protocol.js.map +1 -0
  29. package/build/route.d.ts +9 -0
  30. package/build/route.d.ts.map +1 -0
  31. package/build/route.js +31 -0
  32. package/build/route.js.map +1 -0
  33. package/build/transport.d.ts +15 -0
  34. package/build/transport.d.ts.map +1 -0
  35. package/build/transport.js +59 -0
  36. package/build/transport.js.map +1 -0
  37. package/build/types.d.ts +309 -0
  38. package/build/types.d.ts.map +1 -0
  39. package/build/types.js +2 -0
  40. package/build/types.js.map +1 -0
  41. package/build/types.local.d.ts +3 -0
  42. package/build/types.local.d.ts.map +1 -0
  43. package/build/types.local.js +2 -0
  44. package/build/types.local.js.map +1 -0
  45. package/build/utils.d.ts +4 -0
  46. package/build/utils.d.ts.map +1 -0
  47. package/build/utils.js +4 -0
  48. package/build/utils.js.map +1 -0
  49. package/build/worker.d.ts +27 -0
  50. package/build/worker.d.ts.map +1 -0
  51. package/build/worker.js +59 -0
  52. package/build/worker.js.map +1 -0
  53. package/package.json +13 -3
  54. package/src/bridge.ts +126 -0
  55. package/src/config.ts +171 -0
  56. package/src/consts.ts +38 -0
  57. package/src/errors.ts +121 -0
  58. package/src/index.ts +10 -0
  59. package/src/protocol.ts +74 -0
  60. package/src/route.ts +40 -0
  61. package/src/transport.ts +77 -0
  62. package/src/types.local.ts +3 -0
  63. package/src/types.ts +323 -0
  64. package/src/utils.ts +5 -0
  65. package/src/worker.ts +73 -0
  66. package/tests/bridge.spec.ts +64 -0
  67. package/tests/config.spec.ts +66 -0
  68. package/tests/protocol.spec.ts +58 -0
  69. package/tests/schedule.spec.ts +100 -0
  70. package/tsconfig.json +1 -0
  71. package/build/.gitkeep +0 -0
package/src/config.ts ADDED
@@ -0,0 +1,171 @@
1
+ import type {
2
+ Config, JobOptions, QueueDeclaration, QueueWorkerOptions, ScheduleDeclaration
3
+ } from './types.js'
4
+ import { ScheduleMisdeclared, UnknownJobName, UnknownQueue } from './errors.js'
5
+
6
+ /**
7
+ * Add a queue to the shared configuration. Every process in the monorepo loads the same
8
+ * declarations — a producer needs them to know a queue exists and what it accepts, a worker needs
9
+ * them to bind. Declaring twice replaces, so a helper that runs per app is safe to call.
10
+ */
11
+ export const declareQueue = <C extends Config>(
12
+ cfg: C, name: string, jobs: string[], opts?: { worker?: QueueWorkerOptions, defaults?: JobOptions }
13
+ ): C => {
14
+ const queue: QueueDeclaration = { name, jobs, ...opts }
15
+ const queues = cfg.queue?.queues ?? []
16
+ const existing = queues.findIndex(declared => declared.name === name)
17
+
18
+ if (existing < 0) {
19
+ queues.push(queue)
20
+ } else {
21
+ queues[existing] = queue
22
+ }
23
+
24
+ cfg.queue = { ...cfg.queue, queues }
25
+
26
+ return cfg
27
+ }
28
+
29
+ /**
30
+ * Name the queues this process consumes. It is what turns an otherwise identical binary into a
31
+ * worker, which is why it lives in the process's own config and never in a declaration.
32
+ */
33
+ export const listenQueues = <C extends Config>(cfg: C, ...names: string[]): C => {
34
+ const listen = new Set([...(cfg.queue?.listen ?? []), ...names])
35
+
36
+ cfg.queue = { ...cfg.queue, listen: [...listen] }
37
+
38
+ return cfg
39
+ }
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
+ /**
127
+ * Add a recurring job to the shared configuration. Declare the queue first — a schedule is checked
128
+ * against it here. Declaring an id twice replaces, like `declareQueue`.
129
+ *
130
+ * Declaring is all this does: the driver creates, updates and removes the broker's schedulers when
131
+ * a worker that listens to the queue starts.
132
+ *
133
+ * @throws {ScheduleMisdeclared} / {UnknownQueue} / {UnknownJobName} — see `assertSchedule`.
134
+ */
135
+ export const declareSchedule = <C extends Config>(cfg: C, schedule: ScheduleDeclaration): C => {
136
+ assertSchedule(cfg, schedule)
137
+
138
+ const schedules = cfg.queue?.schedules ?? []
139
+ const existing = schedules.findIndex(declared => declared.id === schedule.id)
140
+
141
+ if (existing < 0) {
142
+ schedules.push(schedule)
143
+ } else {
144
+ schedules[existing] = schedule
145
+ }
146
+
147
+ cfg.queue = { ...cfg.queue, schedules }
148
+
149
+ return cfg
150
+ }
151
+
152
+ /** The declared schedules, of one queue when it is named. */
153
+ export const schedulesOf = <C extends Config>(cfg: C, queue?: string): ScheduleDeclaration[] =>
154
+ (cfg.queue?.schedules ?? []).filter(schedule => queue == null || schedule.queue === queue)
155
+
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
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,38 @@
1
+
2
+ export const DEFAULT_ALIAS = 'queue'
3
+
4
+ /**
5
+ * The state a job is in, as the broker reports it. `Waiting` covers both a job queued normally
6
+ * and one held by an unfinished parent — the difference matters to the broker, never to a caller
7
+ * asking whether an answer has arrived.
8
+ */
9
+ export enum JobState {
10
+ Waiting = 'waiting',
11
+ Delayed = 'delayed',
12
+ Active = 'active',
13
+ Completed = 'completed',
14
+ Failed = 'failed',
15
+ Unknown = 'unknown',
16
+ }
17
+
18
+ export enum JobEventType {
19
+ Completed = 'completed',
20
+ Failed = 'failed',
21
+ Progress = 'progress',
22
+ }
23
+
24
+ /**
25
+ * How long a caller waits for a reply when neither the route nor the request says. Long enough
26
+ * for work that talks to a model, short enough that a lost job surfaces as an error rather than a
27
+ * hung request.
28
+ */
29
+ export const DEFAULT_JOB_TIMEOUT = 60_000
30
+
31
+ /**
32
+ * Retries default to one attempt: the jobs here are mostly LLM work, and a blind retry re-spends
33
+ * the tokens that just failed. A queue whose jobs are cheap and idempotent raises it per queue.
34
+ */
35
+ export const DEFAULT_ATTEMPTS = 1
36
+
37
+ /** The transport identifier owned by the queue package. */
38
+ export const QUEUE_PROTOCOL = 'queue' as const
package/src/errors.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { ResilientError } from '@owlmeans/error'
2
+
3
+ export class QueueError extends ResilientError {
4
+ public static override typeName = 'QueueError'
5
+
6
+ constructor(message: string = 'error') {
7
+ super(QueueError.typeName, `queue:${message}`)
8
+ }
9
+ }
10
+
11
+ /**
12
+ * The caller stopped waiting. The job is untouched — it may still be queued, running, or already
13
+ * finished; only the wait ended. Read the job back to learn which.
14
+ */
15
+ export class QueueTimeout extends QueueError {
16
+ public static override typeName = `${QueueError.typeName}Timeout`
17
+
18
+ constructor(msg: string) {
19
+ super(`timeout:${msg}`)
20
+ this.type = QueueTimeout.typeName
21
+ }
22
+ }
23
+
24
+ /** No job with that id in this queue — it never existed, or it was removed after completing. */
25
+ export class UnknownJob extends QueueError {
26
+ public static override typeName = `${QueueError.typeName}UnknownJob`
27
+
28
+ constructor(msg: string) {
29
+ super(`unknown-job:${msg}`)
30
+ this.type = UnknownJob.typeName
31
+ }
32
+ }
33
+
34
+ /**
35
+ * A job name the queue does not declare. Declaring every job a queue accepts is what keeps an
36
+ * enqueue from succeeding into a queue where nothing can ever process it.
37
+ */
38
+ export class UnknownJobName extends QueueError {
39
+ public static override typeName = `${QueueError.typeName}UnknownJobName`
40
+
41
+ constructor(msg: string) {
42
+ super(`unknown-job-name:${msg}`)
43
+ this.type = UnknownJobName.typeName
44
+ }
45
+ }
46
+
47
+ /** A queue asked for that no declaration names. */
48
+ export class UnknownQueue extends QueueError {
49
+ public static override typeName = `${QueueError.typeName}UnknownQueue`
50
+
51
+ constructor(msg: string) {
52
+ super(`unknown-queue:${msg}`)
53
+ this.type = UnknownQueue.typeName
54
+ }
55
+ }
56
+
57
+ /**
58
+ * This process was asked to consume a queue it does not listen to. Which queues a process
59
+ * consumes is configuration (`cfg.queue.listen`), so this is a deployment question, not a code
60
+ * one — the same binary is a producer or a worker depending on it.
61
+ */
62
+ export class QueueNotListening extends QueueError {
63
+ public static override typeName = `${QueueError.typeName}NotListening`
64
+
65
+ constructor(msg: string) {
66
+ super(`not-listening:${msg}`)
67
+ this.type = QueueNotListening.typeName
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The job named an entrypoint this process does not serve. Unlike a missing queue this is not a
73
+ * declaration problem: the alias exists, but nothing here bound it, so the job was taken by a
74
+ * worker that cannot run it.
75
+ */
76
+ export class JobNotServed extends QueueError {
77
+ public static override typeName = `${QueueError.typeName}JobNotServed`
78
+
79
+ constructor(msg: string) {
80
+ super(`job-not-served:${msg}`)
81
+ this.type = JobNotServed.typeName
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The signed envelope is older than the configured lifetime. Freshness is judged from when the
87
+ * producer enqueued the job, not from when a worker picked it up, so a long backlog does not
88
+ * invalidate work that was legitimate when it was submitted.
89
+ */
90
+ export class EnvelopeExpired extends QueueError {
91
+ public static override typeName = `${QueueError.typeName}EnvelopeExpired`
92
+
93
+ constructor(msg: string) {
94
+ super(`envelope-expired:${msg}`)
95
+ this.type = EnvelopeExpired.typeName
96
+ }
97
+ }
98
+
99
+ /**
100
+ * A schedule the broker could not carry out as written: no id, both or neither of `every` and
101
+ * `pattern`, an option that only means something for the other one, or a job option the broker
102
+ * owns for a scheduled run. The message names the schedule id and the reason.
103
+ */
104
+ export class ScheduleMisdeclared extends QueueError {
105
+ public static override typeName = `${QueueError.typeName}ScheduleMisdeclared`
106
+
107
+ constructor(msg: string) {
108
+ super(`schedule-misdeclared:${msg}`)
109
+ this.type = ScheduleMisdeclared.typeName
110
+ }
111
+ }
112
+
113
+ ResilientError.registerErrorClass(QueueError)
114
+ ResilientError.registerErrorClass(QueueTimeout)
115
+ ResilientError.registerErrorClass(UnknownJob)
116
+ ResilientError.registerErrorClass(UnknownJobName)
117
+ ResilientError.registerErrorClass(UnknownQueue)
118
+ ResilientError.registerErrorClass(QueueNotListening)
119
+ ResilientError.registerErrorClass(JobNotServed)
120
+ ResilientError.registerErrorClass(EnvelopeExpired)
121
+ ResilientError.registerErrorClass(ScheduleMisdeclared)
package/src/index.ts CHANGED
@@ -0,0 +1,10 @@
1
+ export * from './types.js'
2
+ export * from './consts.js'
3
+ export * from './utils.js'
4
+ export * from './errors.js'
5
+ export * from './config.js'
6
+ export * from './transport.js'
7
+ export * from './bridge.js'
8
+ export * from './worker.js'
9
+ export * from './protocol.js'
10
+ export * from './route.js'
@@ -0,0 +1,74 @@
1
+ import { type EntrypointProtocolDeclaration, type RequestOf, type ResponseOf, isEntrypointProtocol } from '@owlmeans/entrypoint'
2
+ import { ResilientError } from '@owlmeans/error'
3
+ import { queueOf } from './config.js'
4
+ import { UnknownQueue } from './errors.js'
5
+ import { isQueueRoute, queueRouteOptions } from './route.js'
6
+ import type { JobEnvelope, JobOptions, JobRecord, JobReply } from './types.js'
7
+ import type { QueueContext } from './types.local.js'
8
+
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')
15
+ }
16
+ if (!isQueueRoute(declaration.route)) {
17
+ throw new UnknownQueue(`${declaration.alias}: not a queue protocol`)
18
+ }
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}`)
23
+ }
24
+ return { protocol: declaration, queue }
25
+ }
26
+
27
+ /** Enqueue an immutable QUEUE protocol while preserving its exact request and response types. */
28
+ export const enqueueProtocol = async <Protocol extends EntrypointProtocolDeclaration>(
29
+ context: QueueContext,
30
+ declaration: Protocol,
31
+ request: RequestOf<Protocol>,
32
+ 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
+ }
44
+
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. */
54
+ export const waitForProtocol = async <Protocol extends EntrypointProtocolDeclaration>(
55
+ context: QueueContext,
56
+ declaration: Protocol,
57
+ job: string | JobRecord<JobEnvelope, JobReply<ResponseOf<Protocol>>>,
58
+ 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
+ }
package/src/route.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { backend, type RouteDeclaration, type RouteModel, type RouteOptions } from '@owlmeans/route'
2
+ import { UnknownQueue } from './errors.js'
3
+ import { QUEUE_PROTOCOL } from './consts.js'
4
+ import type { QueueRouteOptions } from './types.js'
5
+
6
+ /** Declare a backend route carried by a queue. */
7
+ export const job = (
8
+ opts: QueueRouteOptions,
9
+ secondary?: Omit<QueueRouteOptions, 'queue'>,
10
+ ): Partial<RouteOptions> => {
11
+ const { queue, reply, ...routeOptions } = { ...opts, ...secondary }
12
+ const declaration = backend(routeOptions)
13
+ declaration.protocol = QUEUE_PROTOCOL
14
+ declaration.protocolOptions = { queue, ...(reply == null ? {} : { reply }) }
15
+ return declaration
16
+ }
17
+
18
+ /** Whether a declaration belongs to the queue transport. */
19
+ export const isQueueRoute = (route: RouteDeclaration | RouteModel): boolean =>
20
+ ('route' in route ? route.route : route).protocol === QUEUE_PROTOCOL
21
+
22
+ /** Read and validate queue-owned route options. */
23
+ export const queueRouteOptions = (
24
+ route: RouteDeclaration | RouteModel,
25
+ ): Pick<QueueRouteOptions, 'queue' | 'reply'> => {
26
+ const declaration = 'route' in route ? route.route : route
27
+ if (declaration.protocol !== QUEUE_PROTOCOL
28
+ || declaration.protocolOptions == null
29
+ || typeof declaration.protocolOptions !== 'object') {
30
+ throw new UnknownQueue(`${declaration.alias}: not a queue protocol`)
31
+ }
32
+ const options = declaration.protocolOptions as Record<string, unknown>
33
+ if (typeof options.queue !== 'string' || options.queue.trim() === '') {
34
+ throw new UnknownQueue(`${declaration.alias}: route declares no queue`)
35
+ }
36
+ if (options.reply != null && typeof options.reply !== 'boolean') {
37
+ throw new UnknownQueue(`${declaration.alias}: invalid queue reply option`)
38
+ }
39
+ return { queue: options.queue, reply: options.reply as boolean | undefined }
40
+ }
@@ -0,0 +1,77 @@
1
+ import { assertContext, createService } from '@owlmeans/context'
2
+ import { type AbstractRequest, type AbstractResponse, type EntrypointTransport, EntrypointOutcome, transportAlias, type CommonEntrypoint } from '@owlmeans/entrypoint'
3
+ import { ResilientError } from '@owlmeans/error'
4
+ import type { Config, Context, JobEnvelope, JobReply, QueueAppend } from './types.js'
5
+ import { DEFAULT_JOB_TIMEOUT, QUEUE_PROTOCOL } from './consts.js'
6
+ import { queueRouteOptions } from './route.js'
7
+
8
+ /**
9
+ * The producing half of the bridge: it turns a call on a QUEUE entrypoint into a job, and the
10
+ * job's outcome back into a reply.
11
+ *
12
+ * Registering it is the whole opt-in. `apiHandler` looks up a transport by the route's protocol,
13
+ * so a consumer writes `ep.call(...)` against a declaration it shares with every other kind of
14
+ * entrypoint and never learns which one carried it. That is also why the envelope is built from
15
+ * the request rather than from the entrypoint: the far side rebuilds a request, and anything only
16
+ * HTTP understands would not survive the trip anyway.
17
+ */
18
+ export const makeQueueTransport = (alias: string = transportAlias(QUEUE_PROTOCOL)) => {
19
+ const location = `queue-transport:${alias}`
20
+
21
+ const service = createService<EntrypointTransport>(alias, {
22
+ protocol: QUEUE_PROTOCOL,
23
+
24
+ handle: (async <T>(req: AbstractRequest, res: AbstractResponse<T>) => {
25
+ const context = assertContext<Config, Context<Config>>(service.ctx as Context<Config>, location)
26
+ const entrypoint = context.entrypoint<CommonEntrypoint>(req.alias)
27
+ const route = entrypoint.route.route
28
+
29
+ const { queue, reply: waitsForReply } = queueRouteOptions(route)
30
+
31
+ const jobs = (context as unknown as QueueAppend).jobs<JobEnvelope, JobReply<T>>(queue)
32
+
33
+ const envelope: JobEnvelope = {
34
+ alias: req.alias,
35
+ params: req.params as Record<string, unknown>,
36
+ body: req.body,
37
+ query: req.query as Record<string, unknown>,
38
+ headers: req.headers as Record<string, string | undefined>,
39
+ enqueuedAt: new Date().toISOString(),
40
+ }
41
+
42
+ const job = await jobs.create({ queue, name: req.alias, data: envelope })
43
+
44
+ // `reply: false` says the caller is not waiting for the work, only for the promise that it
45
+ // was taken. `Accepted` plus the job's identity is the whole answer — enough to watch it.
46
+ if (waitsForReply === false) {
47
+ res.resolve({ id: job.id, queue } as T, EntrypointOutcome.Accepted)
48
+
49
+ return
50
+ }
51
+
52
+ const reply = await jobs.wait(job.id as string, {
53
+ timeout: req.timeout ?? route.timeout ?? DEFAULT_JOB_TIMEOUT
54
+ })
55
+
56
+ // A processor reports a domain failure by returning it, so it crosses the hop as the class
57
+ // it was thrown as rather than as a string a caller would have to parse.
58
+ if (reply?.error != null) {
59
+ throw ResilientError.ensure(reply.error as Error | string)
60
+ }
61
+
62
+ res.resolve(reply?.value as T, (reply?.outcome as EntrypointOutcome) ?? EntrypointOutcome.Ok)
63
+ }) as EntrypointTransport['handle']
64
+ })
65
+
66
+ return service
67
+ }
68
+
69
+ export const appendQueueTransport = <C extends Config, T extends Context<C> = Context<C>>(
70
+ context: T, alias?: string
71
+ ): T => {
72
+ const service = makeQueueTransport(alias)
73
+
74
+ context.registerService(service)
75
+
76
+ return context
77
+ }
@@ -0,0 +1,3 @@
1
+ import type { Config, Context, QueueAppend } from './types.js'
2
+
3
+ export type QueueContext = Context<Config> & QueueAppend