@c9up/bay 0.1.13 → 0.2.1

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 (100) hide show
  1. package/README.md +122 -16
  2. package/dist/BayProvider.d.ts +51 -11
  3. package/dist/BayProvider.d.ts.map +1 -1
  4. package/dist/BayProvider.js +36 -13
  5. package/dist/BayProvider.js.map +1 -1
  6. package/dist/Job.d.ts +99 -0
  7. package/dist/Job.d.ts.map +1 -0
  8. package/dist/Job.js +78 -0
  9. package/dist/Job.js.map +1 -0
  10. package/dist/QueueManager.d.ts +140 -21
  11. package/dist/QueueManager.d.ts.map +1 -1
  12. package/dist/QueueManager.js +247 -53
  13. package/dist/QueueManager.js.map +1 -1
  14. package/dist/adapters.d.ts +68 -0
  15. package/dist/adapters.d.ts.map +1 -0
  16. package/dist/adapters.js +56 -0
  17. package/dist/adapters.js.map +1 -0
  18. package/dist/augmentations.d.ts +28 -0
  19. package/dist/augmentations.d.ts.map +1 -0
  20. package/dist/augmentations.js +17 -0
  21. package/dist/augmentations.js.map +1 -0
  22. package/dist/configure.d.ts +1 -0
  23. package/dist/configure.d.ts.map +1 -1
  24. package/dist/configure.js +24 -7
  25. package/dist/configure.js.map +1 -1
  26. package/dist/console/contract.d.ts +60 -0
  27. package/dist/console/contract.d.ts.map +1 -0
  28. package/dist/console/contract.js +36 -0
  29. package/dist/console/contract.js.map +1 -0
  30. package/dist/console/index.d.ts +29 -0
  31. package/dist/console/index.d.ts.map +1 -0
  32. package/dist/console/index.js +45 -0
  33. package/dist/console/index.js.map +1 -0
  34. package/dist/console/makeJob.d.ts +32 -0
  35. package/dist/console/makeJob.d.ts.map +1 -0
  36. package/dist/console/makeJob.js +118 -0
  37. package/dist/console/makeJob.js.map +1 -0
  38. package/dist/console/queueWork.d.ts +18 -0
  39. package/dist/console/queueWork.d.ts.map +1 -0
  40. package/dist/console/queueWork.js +58 -0
  41. package/dist/console/queueWork.js.map +1 -0
  42. package/dist/drivers/MemoryDriver.d.ts +14 -8
  43. package/dist/drivers/MemoryDriver.d.ts.map +1 -1
  44. package/dist/drivers/MemoryDriver.js +61 -7
  45. package/dist/drivers/MemoryDriver.js.map +1 -1
  46. package/dist/drivers/RedisDriver.d.ts +60 -8
  47. package/dist/drivers/RedisDriver.d.ts.map +1 -1
  48. package/dist/drivers/RedisDriver.js +214 -30
  49. package/dist/drivers/RedisDriver.js.map +1 -1
  50. package/dist/index.d.ts +10 -6
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +8 -4
  53. package/dist/index.js.map +1 -1
  54. package/dist/jobs.d.ts +55 -0
  55. package/dist/jobs.d.ts.map +1 -0
  56. package/dist/jobs.js +135 -0
  57. package/dist/jobs.js.map +1 -0
  58. package/dist/quasar.d.ts +6 -9
  59. package/dist/quasar.d.ts.map +1 -1
  60. package/dist/quasar.js +27 -55
  61. package/dist/quasar.js.map +1 -1
  62. package/dist/testing/FakeQueue.d.ts +15 -9
  63. package/dist/testing/FakeQueue.d.ts.map +1 -1
  64. package/dist/testing/FakeQueue.js +13 -3
  65. package/dist/testing/FakeQueue.js.map +1 -1
  66. package/dist/vendor/nodeEnv.d.ts +5 -0
  67. package/dist/vendor/nodeEnv.d.ts.map +1 -0
  68. package/dist/{nodeEnv.js → vendor/nodeEnv.js} +13 -7
  69. package/dist/vendor/nodeEnv.js.map +1 -0
  70. package/dist/vendor/quasarConnection.d.ts +35 -0
  71. package/dist/vendor/quasarConnection.d.ts.map +1 -0
  72. package/dist/vendor/quasarConnection.js +62 -0
  73. package/dist/vendor/quasarConnection.js.map +1 -0
  74. package/package.json +5 -3
  75. package/src/BayProvider.ts +92 -26
  76. package/src/Job.ts +137 -0
  77. package/src/QueueManager.ts +411 -56
  78. package/src/adapters.ts +75 -0
  79. package/src/augmentations.ts +31 -0
  80. package/src/configure.ts +25 -7
  81. package/src/console/contract.ts +94 -0
  82. package/src/console/index.ts +68 -0
  83. package/src/console/makeJob.ts +139 -0
  84. package/src/console/queueWork.ts +70 -0
  85. package/src/drivers/MemoryDriver.ts +66 -14
  86. package/src/drivers/RedisDriver.ts +303 -43
  87. package/src/index.ts +35 -6
  88. package/src/jobs.ts +144 -0
  89. package/src/quasar.ts +28 -72
  90. package/src/testing/FakeQueue.ts +25 -15
  91. package/src/{nodeEnv.ts → vendor/nodeEnv.ts} +14 -7
  92. package/src/vendor/quasarConnection.ts +129 -0
  93. package/dist/nodeEnv.d.ts +0 -16
  94. package/dist/nodeEnv.d.ts.map +0 -1
  95. package/dist/nodeEnv.js.map +0 -1
  96. package/dist/stores.d.ts +0 -41
  97. package/dist/stores.d.ts.map +0 -1
  98. package/dist/stores.js +0 -46
  99. package/dist/stores.js.map +0 -1
  100. package/src/stores.ts +0 -59
@@ -0,0 +1,35 @@
1
+ export interface QuasarConnectionRequest {
2
+ /** The package asking. It prefixes every message this can raise. */
3
+ pkg: string;
4
+ /**
5
+ * How this package builds an error.
6
+ *
7
+ * Not optional, and not `new Error` by default: a package with its own
8
+ * error type carries codes (`E_<PKG>_<REASON>`) that a caller catches on,
9
+ * and a shared helper throwing a bare Error would silently drop them. The
10
+ * two reasons are distinguished so the codes can be too.
11
+ */
12
+ raise: (reason: "quasar-missing" | "incomplete-connection", message: string, cause?: unknown) => Error;
13
+ /** Connection name, or `undefined` for quasar's default. */
14
+ name?: string;
15
+ /** The commands the caller will actually issue. */
16
+ required: readonly string[];
17
+ /** What the caller does with them, for the message that names a gap. */
18
+ what: string;
19
+ /**
20
+ * A second way out, when this package has one.
21
+ *
22
+ * Several of them accept a client object directly, so "pass a client
23
+ * instead of a connection name" is actionable — and it was in each
24
+ * package's own message before this was shared. Dropping it to unify the
25
+ * wording would have made the error strictly less useful.
26
+ */
27
+ alternative?: string;
28
+ }
29
+ /**
30
+ * The named connection, checked for the commands the caller needs BEFORE it is
31
+ * handed over — a connection missing one would otherwise fail on the first
32
+ * command, far from the line that asked for it.
33
+ */
34
+ export declare function quasarConnection<T>(request: QuasarConnectionRequest): Promise<T>;
35
+ //# sourceMappingURL=quasarConnection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quasarConnection.d.ts","sourceRoot":"","sources":["../../src/vendor/quasarConnection.ts"],"names":[],"mappings":"AAiDA,MAAM,WAAW,uBAAuB;IACvC,oEAAoE;IACpE,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;OAOG;IACH,KAAK,EAAE,CACN,MAAM,EAAE,gBAAgB,GAAG,uBAAuB,EAClD,OAAO,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,OAAO,KACX,KAAK,CAAC;IACX,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EACvC,OAAO,EAAE,uBAAuB,GAC9B,OAAO,CAAC,CAAC,CAAC,CAuCZ"}
@@ -0,0 +1,62 @@
1
+ // Generated from scripts/vendor/quasarConnection.ts — do not edit.
2
+ //
3
+ // This package is published and built from its own repository, so the file
4
+ // has to exist here rather than be imported. `pnpm vendor:sync` rewrites it,
5
+ // and `pnpm vendor:check` fails if this copy has drifted from the original.
6
+ /**
7
+ * Read a member without assuming how the object answers.
8
+ *
9
+ * Two shapes have to work, and they disagree about `in`. Quasar's accessor is
10
+ * a Proxy over an empty null-prototype object with only a `get` trap, so
11
+ * `"connection" in manager` is FALSE while reading it returns the function —
12
+ * gating on `in` rejected the real manager. And a module namespace under a
13
+ * test double RAISES for an export it does not have, rather than answering
14
+ * undefined, so a bare read fails on the mock. Reading through a catch is what
15
+ * satisfies both.
16
+ */
17
+ function readMember(value, name) {
18
+ if (typeof value !== "object" || value === null)
19
+ return undefined;
20
+ try {
21
+ return Reflect.get(value, name);
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ function isConnectionSource(value) {
28
+ return typeof readMember(value, "connection") === "function";
29
+ }
30
+ /**
31
+ * The named connection, checked for the commands the caller needs BEFORE it is
32
+ * handed over — a connection missing one would otherwise fail on the first
33
+ * command, far from the line that asked for it.
34
+ */
35
+ export async function quasarConnection(request) {
36
+ const { pkg, name, required, what, raise, alternative } = request;
37
+ const specifier = "@c9up/quasar/services/main";
38
+ let loaded;
39
+ try {
40
+ loaded = await import(/* @vite-ignore */ specifier);
41
+ }
42
+ catch (cause) {
43
+ throw raise("quasar-missing", `[${pkg}] ${what} asks for the quasar connection "${name ?? "default"}", but @c9up/quasar is not installed.\n` +
44
+ ` pnpm add @c9up/quasar${alternative ? `, ${alternative}` : ""}`, cause);
45
+ }
46
+ const manager = isConnectionSource(loaded)
47
+ ? loaded
48
+ : Reflect.get(Object(loaded), "default");
49
+ if (!isConnectionSource(manager)) {
50
+ throw raise("quasar-missing", `[${pkg}] @c9up/quasar/services/main did not expose a connection() manager`);
51
+ }
52
+ const connection = manager.connection(name);
53
+ const missing = required.filter((command) => typeof readMember(connection, command) !== "function");
54
+ if (missing.length > 0) {
55
+ throw raise("incomplete-connection", `[${pkg}] the quasar connection${name ? ` '${name}'` : ""} is missing ${missing.join(", ")}, which ${what} issues`);
56
+ }
57
+ // Load-bearing: every member of `required` has just been checked to be a
58
+ // function on this object. The caller names the type it needs because only
59
+ // the caller knows which commands it asked for.
60
+ return connection;
61
+ }
62
+ //# sourceMappingURL=quasarConnection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quasarConnection.js","sourceRoot":"","sources":["../../src/vendor/quasarConnection.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,4EAA4E;AAqB5E;;;;;;;;;;GAUG;AACH,SAAS,UAAU,CAAC,KAAc,EAAE,IAAY;IAC/C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAClE,IAAI,CAAC;QACJ,OAAO,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IACjC,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,SAAS,CAAC;IAClB,CAAC;AACF,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAc;IACzC,OAAO,OAAO,UAAU,CAAC,KAAK,EAAE,YAAY,CAAC,KAAK,UAAU,CAAC;AAC9D,CAAC;AAmCD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACrC,OAAgC;IAEhC,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC;IAClE,MAAM,SAAS,GAAG,4BAA4B,CAAC;IAC/C,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,SAAS,CAAC,CAAC;IACrD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,MAAM,KAAK,CACV,gBAAgB,EAChB,IAAI,GAAG,KAAK,IAAI,oCAAoC,IAAI,IAAI,SAAS,yCAAyC;YAC7G,0BAA0B,WAAW,CAAC,CAAC,CAAC,KAAK,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAClE,KAAK,CACL,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC;QACzC,CAAC,CAAC,MAAM;QACR,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC;IAC1C,IAAI,CAAC,kBAAkB,CAAC,OAAO,CAAC,EAAE,CAAC;QAClC,MAAM,KAAK,CACV,gBAAgB,EAChB,IAAI,GAAG,oEAAoE,CAC3E,CAAC;IACH,CAAC;IAED,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAC9B,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,UAAU,CAAC,UAAU,EAAE,OAAO,CAAC,KAAK,UAAU,CAClE,CAAC;IACF,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,MAAM,KAAK,CACV,uBAAuB,EACvB,IAAI,GAAG,0BAA0B,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,eAAe,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,IAAI,SAAS,CAClH,CAAC;IACH,CAAC;IACD,yEAAyE;IACzE,2EAA2E;IAC3E,gDAAgD;IAChD,OAAO,UAAe,CAAC;AACxB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/bay",
3
- "version": "0.1.13",
3
+ "version": "0.2.1",
4
4
  "description": "Queue — pluggable job-queue contract with memory + Redis drivers for the Ream framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,8 +36,10 @@
36
36
  "@biomejs/biome": "^2.4.10",
37
37
  "@c9up/quasar": "^0.1.0",
38
38
  "@types/node": "^22.19.15",
39
+ "@vitest/coverage-v8": "^4.1.2",
39
40
  "typescript": "^6.0.2",
40
- "vitest": "^4.1.2"
41
+ "vitest": "^4.1.2",
42
+ "@c9up/ream": "^0.2.0"
41
43
  },
42
44
  "engines": {
43
45
  "node": ">=22.0.0"
@@ -66,7 +68,7 @@
66
68
  "scripts": {
67
69
  "build": "tsc -p tsconfig.build.json",
68
70
  "test": "vitest run",
69
- "lint": "biome check src/",
71
+ "lint": "biome check src/ tests/",
70
72
  "test:coverage": "vitest run --coverage",
71
73
  "typecheck": "tsc --noEmit"
72
74
  }
@@ -1,8 +1,15 @@
1
+ import type { AdapterFactory } from "./adapters.js";
2
+ import "./augmentations.js";
1
3
  import { MemoryDriver } from "./drivers/MemoryDriver.js";
4
+ import {
5
+ DEFAULT_JOBS_DIR,
6
+ directoryOf,
7
+ discoverJobs,
8
+ setJobsDir,
9
+ } from "./jobs.js";
2
10
  import type { QueueDriver } from "./QueueManager.js";
3
- import { QueueManager } from "./QueueManager.js";
11
+ import { QueueManager, type WorkerOptions } from "./QueueManager.js";
4
12
  import { clearQueue, getQueue, setQueue } from "./services/main.js";
5
- import type { QueueStoreFactory } from "./stores.js";
6
13
 
7
14
  /**
8
15
  * Slim, duck-typed host context — bay stays publishable without
@@ -10,37 +17,76 @@ import type { QueueStoreFactory } from "./stores.js";
10
17
  * `singleton(token, factory)` + `resolve(token)` and a config store
11
18
  * with `get(key)` satisfies the contract.
12
19
  */
13
- interface BayContainer {
20
+ /**
21
+ * The slice of a host container bay uses. Exported because `BayAppContext` is:
22
+ * a host — or a test — cannot describe one without being able to name its
23
+ * members, and the two that could not were reached for with `as any` instead.
24
+ */
25
+ export interface BayContainer {
14
26
  singleton(token: unknown, factory: () => unknown): void;
15
27
  resolve<T = unknown>(token: unknown): Promise<T>;
16
28
  }
17
- interface BayConfigStore {
29
+ export interface BayConfigStore {
18
30
  get<T = unknown>(key: string): T | undefined;
19
31
  }
20
32
  export interface BayAppContext {
21
33
  container: BayContainer;
22
34
  config: BayConfigStore;
35
+ /**
36
+ * Resolve a path against the application root (AdonisJS `app.makePath`).
37
+ *
38
+ * `app/jobs` means "under the application root", not "under whatever
39
+ * directory the process started in". Without this, discovery resolved
40
+ * against `process.cwd()`, so a worker launched from anywhere else found
41
+ * nothing — and found it silently.
42
+ *
43
+ * Optional, because bay is agnostic: a host with no notion of an
44
+ * application root leaves discovery cwd-relative, as before.
45
+ */
46
+ makePath?(...segments: string[]): string;
23
47
  }
24
48
 
25
49
  export interface BayProviderConfig {
26
50
  /**
27
- * Which named store to use — a key of {@link stores}. Read from the
51
+ * Which named adapter to use — a key of {@link adapters}. Read from the
28
52
  * environment in the generated config, so a deployment picks its queue
29
53
  * backend without editing a file.
30
54
  */
31
55
  default?: string;
32
56
  /**
33
- * The queue stores this application can use, by name. Each is a factory
34
- * from `stores.*`, built only when it is the one selected.
57
+ * The queue adapters this application can use, by name. Each is a factory
58
+ * from `drivers.*`, built only when it is the one selected.
59
+ */
60
+ adapters?: Record<string, AdapterFactory>;
61
+ /**
62
+ * The name this key had before it matched upstream's. Read when `adapters`
63
+ * is absent, so a config written against the older spelling keeps selecting
64
+ * the backend it named — silently falling back to an in-process queue is the
65
+ * one outcome `buildDriver` exists to prevent.
35
66
  */
36
- stores?: Record<string, QueueStoreFactory>;
67
+ stores?: Record<string, AdapterFactory>;
37
68
  /**
38
- * The single-store form, kept for configs written against it: only
39
- * `"memory"` was ever accepted. Prefer `default` + `stores`, which is how a
40
- * pluggable backend is configured everywhere else and what lets the
69
+ * The single-adapter form, kept for configs written against it: only
70
+ * `"memory"` was ever accepted. Prefer `default` + `adapters`, which is how
71
+ * a pluggable backend is configured everywhere else and what lets the
41
72
  * environment choose.
42
73
  */
43
74
  driver?: "memory";
75
+ /**
76
+ * Defaults for the worker `queue.work()` starts — upstream's `worker` block,
77
+ * by the names it gives them. An argument to `work()` still wins.
78
+ */
79
+ worker?: WorkerOptions;
80
+ /**
81
+ * Where the application's job classes live. Default `['app/jobs']`.
82
+ *
83
+ * Every module under them is imported at boot and a default export that is
84
+ * a job class is registered under its own name — which is what lets a
85
+ * worker process resolve a record queued by an HTTP one. Without it the
86
+ * registration list is a directory kept in step by hand, and the job
87
+ * nobody added to it fails as "no handler registered".
88
+ */
89
+ locations?: readonly string[];
44
90
  }
45
91
 
46
92
  /**
@@ -75,35 +121,39 @@ export interface BayProviderConfig {
75
121
  * queue would only find out when a restart dropped every pending job.
76
122
  */
77
123
  function buildDriver(config: BayProviderConfig | undefined): QueueDriver {
78
- const stores = config?.stores;
124
+ // `adapters` first, `stores` when it is absent: the key was renamed to the
125
+ // one upstream reads, and a config that still says `stores` must keep
126
+ // selecting its backend rather than quietly landing on memory.
127
+ const adapters = config?.adapters ?? config?.stores;
128
+ const key = config?.adapters ? "adapters" : "stores";
79
129
  const name = config?.default;
80
130
 
81
- if (stores && name !== undefined) {
82
- const selected = stores[name];
131
+ if (adapters && name !== undefined) {
132
+ const selected = adapters[name];
83
133
  if (!selected) {
84
- const known = Object.keys(stores);
134
+ const known = Object.keys(adapters);
85
135
  throw new Error(
86
- `[bay] config.queue names the store '${name}', which is not in \`stores\`. ` +
136
+ `[bay] config.queue names the adapter '${name}', which is not in \`${key}\`. ` +
87
137
  (known.length > 0
88
138
  ? `Declared: ${known.join(", ")}.`
89
- : "`stores` is empty — declare one with stores.memory() or stores.redis()."),
139
+ : `\`${key}\` is empty — declare one with drivers.memory() or drivers.redis().`),
90
140
  );
91
141
  }
92
142
  return selected();
93
143
  }
94
144
 
95
- if (stores && name === undefined) {
145
+ if (adapters && name === undefined) {
96
146
  throw new Error(
97
- "[bay] config.queue declares `stores` but no `default` naming which one to use. " +
98
- `Set default to one of: ${Object.keys(stores).join(", ")}.`,
147
+ `[bay] config.queue declares \`${key}\` but no \`default\` naming which one to use. ` +
148
+ `Set default to one of: ${Object.keys(adapters).join(", ")}.`,
99
149
  );
100
150
  }
101
151
 
102
152
  const driverName = config?.driver ?? "memory";
103
153
  if (driverName !== "memory") {
104
154
  throw new Error(
105
- `[bay] Unsupported driver '${driverName}' — name it under \`stores\` instead: ` +
106
- "stores: { redis: stores.redis({ connection: 'main' }) }.",
155
+ `[bay] Unsupported driver '${driverName}' — name it under \`adapters\` instead: ` +
156
+ "adapters: { redis: drivers.redis({ connection: 'main' }) }.",
107
157
  );
108
158
  }
109
159
  return new MemoryDriver();
@@ -115,11 +165,17 @@ export default class BayProvider {
115
165
  register(): void {
116
166
  this.app.container.singleton(QueueManager, () => {
117
167
  const config = this.app.config.get<BayProviderConfig>("queue");
118
- return new QueueManager(buildDriver(config));
168
+ return new QueueManager(buildDriver(config), config?.worker);
119
169
  });
120
- this.app.container.singleton("queue", () =>
121
- this.app.container.resolve<QueueManager>(QueueManager),
122
- );
170
+ // Namespaced by the package that owns it, the way upstream namespaces
171
+ // `lucid.db`, `auth.manager` and `drive.manager` by theirs. The bare
172
+ // token stays bound beside it: it is what every existing
173
+ // `container.make(...)` asks for, and a token is not worth breaking an
174
+ // application over.
175
+ const queue = (): Promise<QueueManager> =>
176
+ this.app.container.resolve<QueueManager>(QueueManager);
177
+ this.app.container.singleton("bay.queue", queue);
178
+ this.app.container.singleton("queue", queue);
123
179
  }
124
180
 
125
181
  /** The queue THIS provider booted — not whatever the module singleton holds. */
@@ -130,6 +186,16 @@ export default class BayProvider {
130
186
  // `import queue from '@c9up/bay/services/main'` from anywhere.
131
187
  this.#queue = await this.app.container.resolve<QueueManager>(QueueManager);
132
188
  setQueue(this.#queue);
189
+
190
+ const config = this.app.config.get<BayProviderConfig>("queue");
191
+ const locations = config?.locations ?? [DEFAULT_JOBS_DIR];
192
+ // `make:job` writes where discovery reads, so the two cannot drift.
193
+ const first = locations[0];
194
+ if (first !== undefined) setJobsDir(directoryOf(first));
195
+ const resolveLocation = this.app.makePath?.bind(this.app);
196
+ for (const job of await discoverJobs(locations, resolveLocation)) {
197
+ this.#queue.registerJob(job);
198
+ }
133
199
  }
134
200
 
135
201
  /**
package/src/Job.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * A job as a class — what an application writes, and what `dispatch` takes.
3
+ *
4
+ * The queue accepted a registered name and a payload:
5
+ *
6
+ * queue.register('send-email', new SendEmailHandler())
7
+ * await queue.dispatch('send-email', { to: '…' })
8
+ *
9
+ * Two places to keep in step, and nothing tying the payload to the handler that
10
+ * reads it. A class carries its own name, its own options and its payload type:
11
+ *
12
+ * export default class SendEmail extends Job<{ to: string }> {
13
+ * static options: JobOptions = { queue: 'emails', maxRetries: 5 }
14
+ *
15
+ * async execute() {
16
+ * await mail.send(this.payload.to)
17
+ * }
18
+ *
19
+ * async failed(error: Error) {
20
+ * // after the last retry, not after each one
21
+ * }
22
+ * }
23
+ *
24
+ * await queue.dispatch(SendEmail, { to: 'user@example.com' })
25
+ *
26
+ * Registering by name still works, and is what a job whose name is computed at
27
+ * runtime still needs.
28
+ */
29
+
30
+ /** Milliseconds, or a duration the way a config file writes one. */
31
+ export type Duration = number | string;
32
+
33
+ /**
34
+ * What a job class declares about how it should be run.
35
+ *
36
+ * Every field is optional, and the defaults are the manager's: the `default`
37
+ * queue, three attempts, no delay and no timeout.
38
+ */
39
+ export interface JobOptions {
40
+ /** Named queue this job waits in. Default `"default"`. */
41
+ queue?: string;
42
+ /**
43
+ * How many times the handler may run before the job is filed as failed.
44
+ * Default `3`. Counts runs, not retries: `1` means one attempt and no
45
+ * second chance.
46
+ */
47
+ maxRetries?: number;
48
+ /** Hold the job for this long before any worker may take it. */
49
+ delay?: Duration;
50
+ /**
51
+ * How long the handler gets. Past it the attempt is a failure and the job
52
+ * retries or fails like any other.
53
+ *
54
+ * The handler is not killed — nothing in Node can interrupt a running
55
+ * promise — so a job that ignores its timeout goes on burning CPU. What the
56
+ * timeout buys is that the WORKER stops waiting for it, which is what a
57
+ * stuck job otherwise costs: a worker that never picks anything up again.
58
+ */
59
+ timeout?: Duration;
60
+ }
61
+
62
+ /** The queue a job goes to when nothing names one. */
63
+ export const DEFAULT_QUEUE = "default";
64
+
65
+ /**
66
+ * A background job.
67
+ *
68
+ * `execute()` takes no argument: the payload is on the instance, typed by the
69
+ * class's own parameter, so a handler cannot read a field the dispatcher never
70
+ * sent.
71
+ */
72
+ export abstract class Job<Payload = unknown> {
73
+ /** Overridden by a subclass to change queue, retries, delay or timeout. */
74
+ static options: JobOptions = {};
75
+
76
+ /** What `dispatch` was given, as the class declared it. */
77
+ declare readonly payload: Payload;
78
+
79
+ /** Do the work. Throwing is what makes the attempt fail. */
80
+ abstract execute(): Promise<void> | void;
81
+
82
+ /**
83
+ * Called once the last attempt has failed — for the cleanup or the alert,
84
+ * not for the retry. A throw here is reported and swallowed: the job is
85
+ * already failed, and failing to say so must not fail it twice.
86
+ */
87
+ failed?(error: Error): Promise<void> | void;
88
+ }
89
+
90
+ /** A job class, as `dispatch` receives it. */
91
+ export interface JobClass<Payload = unknown> {
92
+ new (): Job<Payload>;
93
+ readonly name: string;
94
+ readonly options?: JobOptions;
95
+ }
96
+
97
+ /** Is this a job class rather than a name or a handler instance? */
98
+ export function isJobClass(value: unknown): value is JobClass {
99
+ return (
100
+ typeof value === "function" &&
101
+ value.prototype instanceof Job &&
102
+ typeof Reflect.get(value, "name") === "string"
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Milliseconds from a number or a duration string.
108
+ *
109
+ * `'10s'`, `'1m'`, `'2h'`, `'500ms'`, `'1d'` — the spellings a config file
110
+ * uses. A number is already milliseconds. Anything else throws, rather than
111
+ * silently becoming `NaN` and then a job that never runs: a typo in `delay`
112
+ * would otherwise park the job forever with nothing to read about it.
113
+ */
114
+ export function toMilliseconds(value: Duration, label: string): number {
115
+ if (typeof value === "number") {
116
+ if (!Number.isFinite(value) || value < 0) {
117
+ throw new Error(`${label} must be a non-negative number of milliseconds`);
118
+ }
119
+ return value;
120
+ }
121
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(value.trim());
122
+ if (!match) {
123
+ throw new Error(
124
+ `${label} must be a number of milliseconds or a duration like '10s', '1m', '2h' — got '${value}'`,
125
+ );
126
+ }
127
+ const amount = Number(match[1]);
128
+ const unit = match[2];
129
+ const scale: Record<string, number> = {
130
+ ms: 1,
131
+ s: 1_000,
132
+ m: 60_000,
133
+ h: 3_600_000,
134
+ d: 86_400_000,
135
+ };
136
+ return amount * (scale[unit ?? "ms"] ?? 1);
137
+ }