@owlmeans/queue 0.1.18-rc.6 → 0.1.18-rc.8

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 (48) hide show
  1. package/README.md +111 -3
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/queue/SKILL.md +125 -12
  4. package/build/bridge.d.ts +27 -0
  5. package/build/bridge.d.ts.map +1 -0
  6. package/build/bridge.js +93 -0
  7. package/build/bridge.js.map +1 -0
  8. package/build/config.d.ts +22 -0
  9. package/build/config.d.ts.map +1 -0
  10. package/build/config.js +41 -0
  11. package/build/config.js.map +1 -0
  12. package/build/consts.d.ts +33 -0
  13. package/build/consts.d.ts.map +1 -0
  14. package/build/consts.js +35 -0
  15. package/build/consts.js.map +1 -0
  16. package/build/errors.d.ts +59 -0
  17. package/build/errors.d.ts.map +1 -0
  18. package/build/errors.js +90 -0
  19. package/build/errors.js.map +1 -0
  20. package/build/index.d.ts +7 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +7 -1
  23. package/build/index.js.map +1 -1
  24. package/build/transport.d.ts +15 -0
  25. package/build/transport.d.ts.map +1 -0
  26. package/build/transport.js +62 -0
  27. package/build/transport.js.map +1 -0
  28. package/build/types.d.ts +240 -0
  29. package/build/types.d.ts.map +1 -0
  30. package/build/types.js +2 -0
  31. package/build/types.js.map +1 -0
  32. package/build/worker.d.ts +27 -0
  33. package/build/worker.d.ts.map +1 -0
  34. package/build/worker.js +58 -0
  35. package/build/worker.js.map +1 -0
  36. package/package.json +10 -1
  37. package/src/bridge.ts +109 -0
  38. package/src/config.ts +56 -0
  39. package/src/consts.ts +39 -0
  40. package/src/errors.ts +106 -0
  41. package/src/index.ts +7 -0
  42. package/src/transport.ts +82 -0
  43. package/src/types.ts +251 -0
  44. package/src/worker.ts +72 -0
  45. package/tests/bridge.spec.ts +64 -0
  46. package/tests/config.spec.ts +66 -0
  47. package/tsconfig.json +1 -0
  48. package/build/.gitkeep +0 -0
package/src/worker.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { Middleware } from '@owlmeans/context'
2
+ import { MiddlewareStage, MiddlewareType, AppType } from '@owlmeans/context'
3
+ import type { CommonEntrypoint } from '@owlmeans/entrypoint'
4
+ import { RouteProtocols } from '@owlmeans/route'
5
+ import type { Config, Context, JobContext, JobEnvelope, JobProcessor, QueueWorkerService } from './types.js'
6
+ import { handleJob } from './bridge.js'
7
+ import { DEFAULT_ALIAS } from './consts.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 elevated 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 || route.protocol !== RouteProtocols.QUEUE) {
26
+ return
27
+ }
28
+ if (route.service != null && route.service !== context.cfg.service) {
29
+ return
30
+ }
31
+ if (entrypoint.handle == null || route.queue == null || !listen.includes(route.queue)) {
32
+ return
33
+ }
34
+
35
+ served.set(route.queue, [...(served.get(route.queue) ?? []), entrypoint])
36
+ })
37
+
38
+ return served
39
+ }
40
+
41
+ /**
42
+ * A processor that runs a queued entrypoint call. The driver dispatches by job name, and an
43
+ * entrypoint job's name IS its alias — which is what lets one worker carry both entrypoint jobs
44
+ * and the internal steps an application registers with `process()`.
45
+ */
46
+ export const entrypointProcessor = <C extends Config, T extends Context<C>>(
47
+ context: T
48
+ ): JobProcessor<JobEnvelope, unknown> =>
49
+ async (job: JobContext<JobEnvelope>) => await handleJob(context, job)
50
+
51
+ /**
52
+ * Start the worker once the context is ready.
53
+ *
54
+ * Ready stage, not Loading: processors are registered while the application wires itself up, and
55
+ * binding the queues before that finished would take jobs this process cannot yet run. A process
56
+ * that listens to nothing registers no worker at all, so a producer pays nothing for this.
57
+ */
58
+ export const queueWorkerMiddleware = (alias: string = DEFAULT_ALIAS): Middleware => ({
59
+ type: MiddlewareType.Context,
60
+ stage: MiddlewareStage.Ready,
61
+ apply: async context => {
62
+ const ctx = context as unknown as Context<Config>
63
+ if ((ctx.cfg.queue?.listen ?? []).length === 0) {
64
+ return
65
+ }
66
+ if (!ctx.hasService(alias)) {
67
+ return
68
+ }
69
+
70
+ await ctx.service<QueueWorkerService>(alias).start()
71
+ }
72
+ })
@@ -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
+ })
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
  }
package/build/.gitkeep DELETED
File without changes