@owlmeans/queue 0.1.18-rc.3 → 0.1.18-rc.30

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 (61) 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 +149 -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 +9 -0
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +9 -1
  24. package/build/index.js.map +1 -1
  25. package/build/protocol.d.ts +11 -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 +15 -0
  30. package/build/route.d.ts.map +1 -0
  31. package/build/route.js +32 -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 +303 -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/worker.d.ts +27 -0
  42. package/build/worker.d.ts.map +1 -0
  43. package/build/worker.js +59 -0
  44. package/build/worker.js.map +1 -0
  45. package/package.json +12 -2
  46. package/src/bridge.ts +126 -0
  47. package/src/config.ts +171 -0
  48. package/src/consts.ts +39 -0
  49. package/src/errors.ts +121 -0
  50. package/src/index.ts +9 -0
  51. package/src/protocol.ts +78 -0
  52. package/src/route.ts +48 -0
  53. package/src/transport.ts +79 -0
  54. package/src/types.ts +316 -0
  55. package/src/worker.ts +73 -0
  56. package/tests/bridge.spec.ts +64 -0
  57. package/tests/config.spec.ts +66 -0
  58. package/tests/protocol.spec.ts +58 -0
  59. package/tests/schedule.spec.ts +100 -0
  60. package/tsconfig.json +1 -0
  61. package/build/.gitkeep +0 -0
package/src/types.ts ADDED
@@ -0,0 +1,316 @@
1
+ import type { BasicConfig, BasicContext, InitializedService } from '@owlmeans/context'
2
+ import type { PubSubResource, Resource, ResourceRecord } from '@owlmeans/resource'
3
+ import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
4
+ import type { JobEventType, JobState } from './consts.js'
5
+
6
+ /**
7
+ * Everything the monorepo knows about its queues, declared in the shared backend package so that
8
+ * producer and consumer read the same list. A process becomes a worker by naming queues in
9
+ * `listen` — nothing in a declaration decides where it runs.
10
+ */
11
+ export interface QueueConfig {
12
+ queues?: QueueDeclaration[]
13
+ /** The queues THIS process consumes. Absent or empty means producer only. */
14
+ listen?: string[]
15
+ /** Which db service carries the queues. Defaults to the queue service's own alias. */
16
+ db?: string
17
+ defaults?: JobOptions
18
+ /**
19
+ * How long a signed job envelope stays acceptable, in seconds. It bounds both the freshness
20
+ * check and the nonce cache, so a job delayed behind a long backlog is still admitted.
21
+ */
22
+ envelopeTtl?: number
23
+ /**
24
+ * Recurring jobs. Declared beside the queues, in the shared package, because every process that
25
+ * listens to a schedule's queue reconciles the broker against this list when its worker starts —
26
+ * two listeners loading different lists would remove each other's schedules.
27
+ */
28
+ schedules?: ScheduleDeclaration[]
29
+ }
30
+
31
+ /**
32
+ * A job the broker produces on its own, on an interval or a cron pattern.
33
+ *
34
+ * A scheduled run is an ordinary job of a name the queue declares, dispatched to the processor
35
+ * registered for that name — never to a guarded entrypoint, because nobody's credentials travel
36
+ * with it. Only a process that listens to the queue creates the scheduler, and the broker produces
37
+ * each next run when the previous one STARTS processing — a schedule needs a running consumer.
38
+ */
39
+ export interface ScheduleDeclaration {
40
+ /**
41
+ * The schedule's stable identity: the broker keeps one scheduler per id, so changing any other
42
+ * field updates it in place, while renaming the id removes one schedule and creates another.
43
+ */
44
+ id: string
45
+ queue: string
46
+ /** A job name the queue declares. */
47
+ name: string
48
+ /**
49
+ * Milliseconds between runs. Exactly one of `every` and `pattern`. Without `startDate` the first
50
+ * run is produced as soon as the scheduler is created.
51
+ */
52
+ every?: number
53
+ /** A cron pattern (an optional leading seconds field is accepted). Exactly one of the two. */
54
+ pattern?: string
55
+ /** The IANA time zone `pattern` is read in. Pattern only. */
56
+ tz?: string
57
+ startDate?: Date | number | string
58
+ /** Once passed, the schedule is treated as absent and a scheduler left from it is removed. */
59
+ endDate?: Date | number | string
60
+ /** Stop after this many runs. */
61
+ limit?: number
62
+ /**
63
+ * Run once as soon as the scheduler is created or its declaration changes, then follow the
64
+ * pattern. Pattern only, and never together with `startDate`. A restart that finds the
65
+ * declaration unchanged does not run it again.
66
+ */
67
+ immediately?: boolean
68
+ data?: unknown
69
+ /**
70
+ * Options every run is enqueued with, over the queue's defaults. `id` and `delay` belong to the
71
+ * broker for a scheduled run and are refused.
72
+ */
73
+ opts?: Omit<JobOptions, 'id' | 'delay'>
74
+ }
75
+
76
+ export interface QueueDeclaration {
77
+ name: string
78
+ /**
79
+ * The job names this queue accepts. A job the queue does not declare is refused at enqueue
80
+ * time rather than becoming a job nothing can process.
81
+ */
82
+ jobs: string[]
83
+ worker?: QueueWorkerOptions
84
+ defaults?: JobOptions
85
+ }
86
+
87
+ export interface QueueWorkerOptions {
88
+ concurrency?: number
89
+ /**
90
+ * How long a job may hold its lock without reporting progress. A processor that runs longer
91
+ * than this between `touch()` calls is treated as stalled and its job is handed to someone else.
92
+ */
93
+ lockDuration?: number
94
+ stalledInterval?: number
95
+ maxStalledCount?: number
96
+ }
97
+
98
+ export interface JobOptions {
99
+ attempts?: number
100
+ backoff?: { type: 'fixed' | 'exponential', delay: number }
101
+ delay?: number
102
+ priority?: number
103
+ /**
104
+ * A caller-chosen identity for the job. Enqueueing the same id twice while the first is still
105
+ * around is a no-op, which is what makes an admission step safe to retry.
106
+ */
107
+ id?: string
108
+ removeOnComplete?: boolean | number
109
+ removeOnFail?: boolean | number
110
+ }
111
+
112
+ /**
113
+ * A job as a RECORD — the same shape the resource contract reads and writes, so inspecting a
114
+ * queue is `list`/`get`/`count` rather than a broker-specific API.
115
+ */
116
+ export interface JobRecord<D = unknown, R = unknown> extends ResourceRecord {
117
+ queue: string
118
+ name: string
119
+ data: D
120
+ state?: JobState
121
+ result?: R
122
+ error?: string
123
+ attempts?: number
124
+ progress?: unknown
125
+ parentId?: string
126
+ createdAt?: string
127
+ startedAt?: string
128
+ finishedAt?: string
129
+ opts?: JobOptions
130
+ }
131
+
132
+ export interface JobEvent<R = unknown> {
133
+ type: JobEventType
134
+ id: string
135
+ queue: string
136
+ /**
137
+ * Empty when the job is already gone. A broker's completion event carries only an id, so the
138
+ * name is read back from the job — and a queue that removes jobs on completion has nothing left
139
+ * to read. Subscribe for the id and the outcome; do not branch on the name.
140
+ */
141
+ name: string
142
+ result?: R
143
+ error?: string
144
+ progress?: unknown
145
+ }
146
+
147
+ /** A node of a job graph: children run first, siblings run together. */
148
+ export interface FlowSpec<D = unknown> {
149
+ name: string
150
+ queue?: string
151
+ data: D
152
+ opts?: JobOptions
153
+ children?: FlowSpec[]
154
+ }
155
+
156
+ /**
157
+ * One queue, addressed as a resource.
158
+ *
159
+ * `create` enqueues, `get`/`load`/`list`/`count` inspect, `delete`/`take` cancel. What the base
160
+ * contract cannot express — waiting for an answer, submitting a graph, releasing the connection —
161
+ * is added here.
162
+ *
163
+ * Three places where the base `Resource` contract reads differently against a broker, because a
164
+ * job is not a row:
165
+ * - `create` does NOT throw `RecordExists` for a repeated `opts.id`. Re-enqueueing an id that is
166
+ * still around returns the existing job, which is the idempotency that makes an admission step
167
+ * safe to retry.
168
+ * - `WriteOptions.ttl` is refused. A job's lifetime is `removeOnComplete`/`removeOnFail` —
169
+ * explicit deletion once it has finished, not expiry while it waits.
170
+ * - `update`/`save` reach only the job's `data`. Everything else about a job belongs to the
171
+ * broker, and an implementation refuses rather than pretending to write it.
172
+ */
173
+ export interface QueueResource<D = unknown, R = unknown>
174
+ extends Resource<JobRecord<D, R>>, PubSubResource<JobEvent<R>> {
175
+ queue: string
176
+ /**
177
+ * Release the connections this queue holds.
178
+ *
179
+ * Watching for completions means a blocking connection, and a process that never releases it
180
+ * does not exit. Nothing else in the resource contract can express that, so it lives here and a
181
+ * producer calls it on shutdown.
182
+ */
183
+ close: () => Promise<void>
184
+ /**
185
+ * Wait for a job's return value.
186
+ * @throws {QueueTimeout} when the wait elapses — the job itself is left alone.
187
+ * @throws the processor's own error, rebuilt as its original class.
188
+ */
189
+ wait: (id: string, opts?: { timeout?: number }) => Promise<R>
190
+ /** Submit a graph and get its root back. Children complete before their parent starts. */
191
+ flow: (root: FlowSpec<D>) => Promise<JobRecord<D, R>>
192
+ counts: () => Promise<Record<JobState, number>>
193
+ }
194
+
195
+ /**
196
+ * What a processor is handed. `data` is the job's payload; everything else is the means to stay
197
+ * alive and to read what the children reported.
198
+ */
199
+ export interface JobContext<D = unknown> {
200
+ id: string
201
+ name: string
202
+ queue: string
203
+ attempt: number
204
+ data: D
205
+ signal: AbortSignal
206
+ /**
207
+ * The id of the schedule that produced this run (`ScheduleDeclaration.id`). Absent for a job
208
+ * enqueued any other way.
209
+ */
210
+ scheduled?: string
211
+ /**
212
+ * Renew the lock. A processor that runs a long loop MUST call it as it goes: the broker judges
213
+ * liveness by the lock, so silence for longer than `lockDuration` is indistinguishable from a
214
+ * dead worker, and the job is re-run somewhere else while this one is still working on it.
215
+ */
216
+ touch: () => Promise<void>
217
+ progress: (value: unknown) => Promise<void>
218
+ /**
219
+ * What the children returned, keyed by child job id.
220
+ *
221
+ * A broker keys them by its own fully qualified job key; an implementation re-keys to the bare
222
+ * id, so two children of one parent that live in DIFFERENT queues and happen to share an id
223
+ * collide here. Give the children of a cross-queue flow distinct ids.
224
+ */
225
+ children: <T = unknown>() => Promise<Record<string, T>>
226
+ failedChildren: () => Promise<Record<string, string>>
227
+ }
228
+
229
+ export interface JobProcessor<D = unknown, R = unknown> {
230
+ (job: JobContext<D>): Promise<R>
231
+ }
232
+
233
+ /**
234
+ * The consuming half. It is registered only in processes that consume: `start()` binds the
235
+ * queues named in `cfg.listen` and dispatches by job name, to a processor registered here or to
236
+ * an entrypoint served in this process.
237
+ */
238
+ export interface QueueWorkerService extends InitializedService {
239
+ process: <D, R>(queue: string, name: string, processor: JobProcessor<D, R>) => void
240
+ start: () => Promise<void>
241
+ stop: () => Promise<void>
242
+ /** The queues this process actually consumes. Empty in a producer. */
243
+ listening: () => string[]
244
+ /**
245
+ * Bind the application's reactions to job lifecycle events. Registering more than once merges,
246
+ * so an application composed of several parts can each contribute what it cares about.
247
+ *
248
+ * It belongs on the contract rather than on a driver because `onJobDead` is where an
249
+ * application COMPENSATES — releases the lock the admission step took, marks the record failed —
250
+ * and that behaviour must not have to be rewritten when the broker changes.
251
+ */
252
+ hooks: (hooks: QueueHooks) => void
253
+ }
254
+
255
+ /**
256
+ * The envelope a queued entrypoint call travels in. It is the request, minus everything that only
257
+ * means something to HTTP — the transport rebuilds a request from it on the far side.
258
+ */
259
+ export interface JobEnvelope {
260
+ alias: string
261
+ params?: Record<string, unknown>
262
+ body?: unknown
263
+ query?: Record<string, unknown>
264
+ headers?: Record<string, string | undefined>
265
+ /** When the producer enqueued it — freshness is judged here, not at processing time. */
266
+ enqueuedAt?: string
267
+ }
268
+
269
+ export interface QueueJobMeta {
270
+ id: string
271
+ name: string
272
+ queue: string
273
+ attempt: number
274
+ /** Renew the broker lock for protocol handlers whose work can outlive one lock duration. */
275
+ touch: () => Promise<void>
276
+ }
277
+
278
+ /** What a processor returns for an entrypoint job, so the caller can rebuild the reply. */
279
+ export interface JobReply<T = unknown> {
280
+ value?: T
281
+ outcome?: string
282
+ error?: unknown
283
+ }
284
+
285
+ export interface QueueHooks {
286
+ wrapHandler?: (job: JobContext, next: () => Promise<unknown>) => Promise<unknown>
287
+ onJobResult?: (event: JobEvent) => void | Promise<void>
288
+ onJobStalled?: (meta: { id: string, queue: string, name: string }, phase: 'stalled' | 'failed') => void | Promise<void>
289
+ /**
290
+ * The job is finished for good — retries exhausted, or it was dropped. This is where an
291
+ * application compensates: release the lock the admission step took, mark the record failed.
292
+ */
293
+ onJobDead?: (job: JobRecord, reason: string) => void | Promise<void>
294
+ }
295
+
296
+ export interface QueueDriver {
297
+ resource: <D, R>(queue: string) => QueueResource<D, R>
298
+ worker: () => QueueWorkerService
299
+ }
300
+
301
+ export interface Config extends BasicConfig {
302
+ queue?: QueueConfig
303
+ }
304
+
305
+ export interface Context<C extends Config = Config> extends BasicContext<C> {
306
+ }
307
+
308
+ export interface QueueAppend {
309
+ jobs: <D = unknown, R = unknown>(queue?: string) => QueueResource<D, R>
310
+ }
311
+
312
+ export interface QueueTransportRequest extends AbstractRequest {
313
+ }
314
+
315
+ export interface QueueTransportResponse extends AbstractResponse<unknown> {
316
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { Middleware } from '@owlmeans/context'
2
+ import { MiddlewareStage, MiddlewareType, AppType } from '@owlmeans/context'
3
+ import type { CommonEntrypoint } from '@owlmeans/entrypoint'
4
+ import type { Config, Context, JobContext, JobEnvelope, JobProcessor, QueueWorkerService } from './types.js'
5
+ import { handleJob } from './bridge.js'
6
+ import { DEFAULT_ALIAS } from './consts.js'
7
+ import { isQueueRoute, queueRouteOptions } from './route.js'
8
+
9
+ /**
10
+ * Every queued entrypoint this process both SERVES and LISTENS to, grouped by queue.
11
+ *
12
+ * Both halves are required and they answer different questions. Serving is about code — the alias
13
+ * was bound here, so a handler exists. Listening is about deployment — this process was
14
+ * configured to consume that queue. A worker that bound queues by what it can serve would make
15
+ * every deployment of the same binary a worker for everything it happens to import.
16
+ */
17
+ export const servedJobs = <C extends Config, T extends Context<C>>(
18
+ context: T
19
+ ): Map<string, CommonEntrypoint[]> => {
20
+ const listen = context.cfg.queue?.listen ?? []
21
+ const served = new Map<string, CommonEntrypoint[]>()
22
+
23
+ context.entrypoints<CommonEntrypoint>().forEach(entrypoint => {
24
+ const route = entrypoint.route.route
25
+ if (route.type !== AppType.Backend || !isQueueRoute(route)) {
26
+ return
27
+ }
28
+ if (route.service != null && route.service !== context.cfg.service) {
29
+ return
30
+ }
31
+ const { queue } = queueRouteOptions(route)
32
+ if (entrypoint.handle == null || !listen.includes(queue)) {
33
+ return
34
+ }
35
+
36
+ served.set(queue, [...(served.get(queue) ?? []), entrypoint])
37
+ })
38
+
39
+ return served
40
+ }
41
+
42
+ /**
43
+ * A processor that runs a queued entrypoint call. The driver dispatches by job name, and an
44
+ * entrypoint job's name IS its alias — which is what lets one worker carry both entrypoint jobs
45
+ * and the internal steps an application registers with `process()`.
46
+ */
47
+ export const entrypointProcessor = <C extends Config, T extends Context<C>>(
48
+ context: T
49
+ ): JobProcessor<JobEnvelope, unknown> =>
50
+ async (job: JobContext<JobEnvelope>) => await handleJob(context, job)
51
+
52
+ /**
53
+ * Start the worker once the context is ready.
54
+ *
55
+ * Ready stage, not Loading: processors are registered while the application wires itself up, and
56
+ * binding the queues before that finished would take jobs this process cannot yet run. A process
57
+ * that listens to nothing registers no worker at all, so a producer pays nothing for this.
58
+ */
59
+ export const queueWorkerMiddleware = (alias: string = DEFAULT_ALIAS): Middleware => ({
60
+ type: MiddlewareType.Context,
61
+ stage: MiddlewareStage.Ready,
62
+ apply: async context => {
63
+ const ctx = context as unknown as Context<Config>
64
+ if ((ctx.cfg.queue?.listen ?? []).length === 0) {
65
+ return
66
+ }
67
+ if (!ctx.hasService(alias)) {
68
+ return
69
+ }
70
+
71
+ await ctx.service<QueueWorkerService>(alias).start()
72
+ }
73
+ })
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { assertFresh, requestOf } from '../src/bridge.js'
3
+ import { EnvelopeExpired } from '../src/errors.js'
4
+ import type { JobEnvelope } from '../src/types.js'
5
+
6
+ const envelope = (enqueuedAt?: string): JobEnvelope => ({
7
+ alias: 'story:code', params: { id: '7' }, body: { prompt: 'x' }, headers: {}, query: {}, enqueuedAt
8
+ })
9
+
10
+ describe('rebuilding the request', () => {
11
+ test('the envelope becomes an ordinary request', () => {
12
+ const request = requestOf(envelope(), '/story/:id/code')
13
+
14
+ expect(request.alias).toBe('story:code')
15
+ expect(request.params).toEqual({ id: '7' })
16
+ expect(request.body).toEqual({ prompt: 'x' })
17
+ expect(request.path).toBe('/story/:id/code')
18
+ })
19
+
20
+ /**
21
+ * A producer may omit any of the optional parts. The handler on the far side reads them without
22
+ * checking, so they have to arrive as empty containers rather than as undefined.
23
+ */
24
+ test('absent parts arrive as empty, never undefined', () => {
25
+ const request = requestOf({ alias: 'story:code' }, '/x')
26
+
27
+ expect(request.params).toEqual({})
28
+ expect(request.headers).toEqual({})
29
+ expect(request.query).toEqual({})
30
+ })
31
+ })
32
+
33
+ describe('envelope freshness', () => {
34
+ test('a fresh envelope passes', () => {
35
+ expect(() => assertFresh(envelope(new Date().toISOString()), 60)).not.toThrow()
36
+ })
37
+
38
+ test('an envelope older than the window is refused', () => {
39
+ const old = new Date(Date.now() - 120_000).toISOString()
40
+
41
+ expect(() => assertFresh(envelope(old), 60)).toThrow(EnvelopeExpired)
42
+ })
43
+
44
+ /**
45
+ * The window is judged from when the job was ENQUEUED. A job that waited behind a long backlog
46
+ * was legitimate when it was produced, and judging it on pickup would reject exactly the work a
47
+ * busy queue delayed — so a generous window has to keep it.
48
+ */
49
+ test('a long wait in the queue is not what expiry is for', () => {
50
+ const enqueued = new Date(Date.now() - 3600_000).toISOString()
51
+
52
+ expect(() => assertFresh(envelope(enqueued), 86_400)).not.toThrow()
53
+ })
54
+
55
+ test('no configured window means no check', () => {
56
+ const ancient = new Date(0).toISOString()
57
+
58
+ expect(() => assertFresh(envelope(ancient), undefined)).not.toThrow()
59
+ })
60
+
61
+ test('an envelope without a timestamp is not judged', () => {
62
+ expect(() => assertFresh(envelope(undefined), 60)).not.toThrow()
63
+ })
64
+ })
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { declareQueue, isListening, listenQueues, queueOf, queueOfJob } from '../src/config.js'
3
+ import { UnknownQueue } from '../src/errors.js'
4
+ import type { Config } from '../src/types.js'
5
+
6
+ const cfg = (): Config => ({ ready: false, service: 'test', type: 'backend' } as unknown as Config)
7
+
8
+ describe('declaring queues', () => {
9
+ test('a declaration records the queue and the jobs it accepts', () => {
10
+ const config = declareQueue(cfg(), 'work', ['story:code', 'story:gate'])
11
+
12
+ expect(queueOf(config, 'work').jobs).toEqual(['story:code', 'story:gate'])
13
+ })
14
+
15
+ /**
16
+ * Declaring runs from a helper that an application may call once per app it composes, so the
17
+ * second call must not leave two entries under one name — a duplicate would make `queueOf`
18
+ * answer with whichever happened to be first.
19
+ */
20
+ test('re-declaring the same name replaces rather than appends', () => {
21
+ let config = declareQueue(cfg(), 'work', ['story:code'])
22
+ config = declareQueue(config, 'work', ['story:code', 'story:gate'])
23
+
24
+ expect(config.queue?.queues).toHaveLength(1)
25
+ expect(queueOf(config, 'work').jobs).toHaveLength(2)
26
+ })
27
+
28
+ test('asking for a queue nobody declared is an error, not undefined', () => {
29
+ expect(() => queueOf(cfg(), 'missing')).toThrow(UnknownQueue)
30
+ })
31
+
32
+ test('a job name resolves back to the queue that accepts it', () => {
33
+ const config = declareQueue(declareQueue(cfg(), 'work', ['story:code']), 'ops', ['files:get'])
34
+
35
+ expect(queueOfJob(config, 'files:get')?.name).toBe('ops')
36
+ expect(queueOfJob(config, 'nothing:declared')).toBeUndefined()
37
+ })
38
+ })
39
+
40
+ describe('listening', () => {
41
+ /**
42
+ * Declaring a queue must NOT make this process consume it — that separation is what lets one
43
+ * binary deploy as a producer in one place and a worker in another.
44
+ */
45
+ test('declaring a queue does not make the process listen to it', () => {
46
+ const config = declareQueue(cfg(), 'work', ['story:code'])
47
+
48
+ expect(isListening(config, 'work')).toBe(false)
49
+ expect(config.queue?.listen ?? []).toHaveLength(0)
50
+ })
51
+
52
+ test('listening is additive and free of duplicates', () => {
53
+ let config = listenQueues(cfg(), 'work')
54
+ config = listenQueues(config, 'work', 'ops')
55
+
56
+ expect(config.queue?.listen).toEqual(['work', 'ops'])
57
+ expect(isListening(config, 'ops')).toBe(true)
58
+ })
59
+
60
+ test('listening and declaring do not overwrite each other', () => {
61
+ const config = declareQueue(listenQueues(cfg(), 'work'), 'work', ['story:code'])
62
+
63
+ expect(isListening(config, 'work')).toBe(true)
64
+ expect(queueOf(config, 'work').jobs).toEqual(['story:code'])
65
+ })
66
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { contract, protocol, schema } from '@owlmeans/entrypoint'
3
+ import { route } from '@owlmeans/route'
4
+ import type { JSONSchemaType } from 'ajv'
5
+ import { enqueueProtocol, job, waitForProtocol, type JobRecord } from '../src/index.js'
6
+
7
+ interface Input { value: number }
8
+ interface Output { doubled: number }
9
+ const InputSchema = schema<Input>({
10
+ type: 'object', properties: { value: { type: 'number' } }, required: ['value'], additionalProperties: false,
11
+ } as JSONSchemaType<Input>)
12
+ const OutputSchema = schema<Output>({
13
+ type: 'object', properties: { doubled: { type: 'number' } }, required: ['doubled'], additionalProperties: false,
14
+ } as JSONSchemaType<Output>)
15
+ const declaration = protocol(
16
+ route('test:double', '/double', job({ queue: 'test' })),
17
+ contract(InputSchema, OutputSchema),
18
+ )
19
+
20
+ const context = (reply: unknown = { value: { doubled: 6 } }) => {
21
+ const created: Partial<JobRecord>[] = []
22
+ return {
23
+ cfg: { queue: { queues: [{ name: 'test', jobs: [declaration.alias] }] } },
24
+ jobs: () => ({
25
+ create: async (record: Partial<JobRecord>) => {
26
+ created.push(record)
27
+ return { ...record, id: 'job-1' } as JobRecord
28
+ },
29
+ wait: async () => reply,
30
+ }),
31
+ created,
32
+ }
33
+ }
34
+
35
+ describe('protocol-aware queue calls', () => {
36
+ test('derives the broker address and keeps per-call options', async () => {
37
+ const ctx = context()
38
+ const queued = await enqueueProtocol(ctx as never, declaration, { body: { value: 3 } }, {
39
+ delay: 250, attempts: 3, id: 'projection-1',
40
+ })
41
+ expect(queued.queue).toBe('test')
42
+ expect(ctx.created[0]?.name).toBe(declaration.alias)
43
+ expect(ctx.created[0]?.opts).toEqual({ delay: 250, attempts: 3, id: 'projection-1' })
44
+ expect(await waitForProtocol(ctx as never, declaration, queued as never)).toEqual({ doubled: 6 })
45
+ })
46
+
47
+ test('rejects raw aliases and non-queue declarations at runtime', async () => {
48
+ await expect(enqueueProtocol(context() as never, declaration.alias as never, {} as never)).rejects.toThrow()
49
+ const http = protocol(route('test:http', '/http'), contract())
50
+ await expect(enqueueProtocol(context() as never, http, {})).rejects.toThrow()
51
+ })
52
+
53
+ test('rejects a job from another queue or protocol', async () => {
54
+ await expect(waitForProtocol(context() as never, declaration, {
55
+ id: 'x', queue: 'other', name: declaration.alias, data: {},
56
+ } as never)).rejects.toThrow()
57
+ })
58
+ })
@@ -0,0 +1,100 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import {
3
+ assertSchedule, assertSchedules, declareQueue, declareSchedule, queueOf, schedulesOf
4
+ } from '../src/config.js'
5
+ import { ScheduleMisdeclared, UnknownJobName, UnknownQueue } from '../src/errors.js'
6
+ import type { Config, ScheduleDeclaration } from '../src/types.js'
7
+
8
+ const cfg = (): Config => declareQueue(
9
+ declareQueue({ ready: false, service: 'test', type: 'backend' } as unknown as Config,
10
+ 'maintenance', ['reconcile', 'sweep']),
11
+ 'work', ['story:code']
12
+ )
13
+
14
+ const nightly: ScheduleDeclaration = {
15
+ id: 'nightly-reconcile', queue: 'maintenance', name: 'reconcile', pattern: '17 3 * * *', tz: 'UTC',
16
+ }
17
+
18
+ const misdeclared = (schedule: Partial<ScheduleDeclaration>): () => void =>
19
+ () => assertSchedule(cfg(), { ...nightly, ...schedule } as ScheduleDeclaration)
20
+
21
+ describe('declaring schedules', () => {
22
+ test('a declaration is recorded and read back by its queue', () => {
23
+ const config = declareSchedule(cfg(), nightly)
24
+
25
+ expect(schedulesOf(config, 'maintenance')).toEqual([nightly])
26
+ expect(schedulesOf(config, 'work')).toEqual([])
27
+ expect(schedulesOf(config)).toHaveLength(1)
28
+ })
29
+
30
+ test('re-declaring an id replaces rather than appends', () => {
31
+ let config = declareSchedule(cfg(), nightly)
32
+ config = declareSchedule(config, { ...nightly, pattern: '0 4 * * *' })
33
+
34
+ expect(schedulesOf(config)).toHaveLength(1)
35
+ expect(schedulesOf(config)[0].pattern).toBe('0 4 * * *')
36
+ })
37
+
38
+ /**
39
+ * A scheduled run of a name the queue does not accept would fail on every tick. Refusing at
40
+ * declaration turns that into one error at boot, and the refused schedule is not recorded.
41
+ */
42
+ test('a schedule for an undeclared queue or job name is refused and not recorded', () => {
43
+ const config = cfg()
44
+
45
+ expect(() => declareSchedule(config, { ...nightly, queue: 'missing' })).toThrow(UnknownQueue)
46
+ expect(() => declareSchedule(config, { ...nightly, name: 'story:code' })).toThrow(UnknownJobName)
47
+ expect(schedulesOf(config)).toHaveLength(0)
48
+ })
49
+
50
+ test('re-declaring the queue keeps its schedules', () => {
51
+ const config = declareQueue(declareSchedule(cfg(), nightly), 'maintenance', ['reconcile'])
52
+
53
+ expect(queueOf(config, 'maintenance').jobs).toEqual(['reconcile'])
54
+ expect(schedulesOf(config)).toEqual([nightly])
55
+ })
56
+ })
57
+
58
+ describe('asserting a schedule', () => {
59
+ test('exactly one of every and pattern', () => {
60
+ expect(misdeclared({ every: 1_000 })).toThrow(/every-or-pattern/)
61
+ expect(misdeclared({ pattern: undefined, tz: undefined })).toThrow(/every-or-pattern/)
62
+ expect(misdeclared({ pattern: undefined, tz: undefined, every: 0 })).toThrow(/:every$/)
63
+ expect(misdeclared({ pattern: undefined, tz: undefined, every: 1.5 })).toThrow(ScheduleMisdeclared)
64
+ })
65
+
66
+ test('tz and immediately belong to a pattern only', () => {
67
+ expect(misdeclared({ pattern: undefined, every: 1_000 })).toThrow(/:tz$/)
68
+ expect(misdeclared({ pattern: undefined, tz: undefined, every: 1_000, immediately: true }))
69
+ .toThrow(/:immediately$/)
70
+ expect(misdeclared({ immediately: true, startDate: new Date() })).toThrow(/immediately-start-date/)
71
+ expect(misdeclared({ immediately: true })).not.toThrow()
72
+ })
73
+
74
+ test('the broker owns a scheduled run\'s id and delay', () => {
75
+ expect(misdeclared({ opts: { id: 'fixed' } as ScheduleDeclaration['opts'] })).toThrow(/opts-id/)
76
+ expect(misdeclared({ opts: { delay: 10 } as ScheduleDeclaration['opts'] })).toThrow(/opts-delay/)
77
+ expect(misdeclared({ opts: { attempts: 3 } })).not.toThrow()
78
+ })
79
+
80
+ test('an empty id is refused', () => {
81
+ expect(misdeclared({ id: ' ' })).toThrow(ScheduleMisdeclared)
82
+ })
83
+ })
84
+
85
+ describe('asserting every schedule', () => {
86
+ test('two schedules under one id are refused', () => {
87
+ const config = declareSchedule(cfg(), nightly)
88
+ config.queue?.schedules?.push({ ...nightly, pattern: '0 5 * * *' })
89
+
90
+ expect(() => assertSchedules(config)).toThrow(/duplicate/)
91
+ })
92
+
93
+ /** The configuration is plain data; a schedule written into it directly is checked too. */
94
+ test('a schedule written past declareSchedule is still checked', () => {
95
+ const config = declareSchedule(cfg(), nightly)
96
+ config.queue?.schedules?.push({ id: 'orphan', queue: 'maintenance', name: 'gone', every: 1_000 })
97
+
98
+ expect(() => assertSchedules(config)).toThrow(UnknownJobName)
99
+ })
100
+ })
package/tsconfig.json CHANGED
@@ -10,6 +10,7 @@
10
10
  "exclude": [
11
11
  "./dist/**/*",
12
12
  "./build/**/*",
13
+ "./tests/**/*",
13
14
  "./*.ts"
14
15
  ]
15
16
  }