@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.
- package/README.md +122 -16
- package/dist/BayProvider.d.ts +51 -11
- package/dist/BayProvider.d.ts.map +1 -1
- package/dist/BayProvider.js +36 -13
- package/dist/BayProvider.js.map +1 -1
- package/dist/Job.d.ts +99 -0
- package/dist/Job.d.ts.map +1 -0
- package/dist/Job.js +78 -0
- package/dist/Job.js.map +1 -0
- package/dist/QueueManager.d.ts +140 -21
- package/dist/QueueManager.d.ts.map +1 -1
- package/dist/QueueManager.js +247 -53
- package/dist/QueueManager.js.map +1 -1
- package/dist/adapters.d.ts +68 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +56 -0
- package/dist/adapters.js.map +1 -0
- package/dist/augmentations.d.ts +28 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +17 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/configure.d.ts +1 -0
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +24 -7
- package/dist/configure.js.map +1 -1
- package/dist/console/contract.d.ts +60 -0
- package/dist/console/contract.d.ts.map +1 -0
- package/dist/console/contract.js +36 -0
- package/dist/console/contract.js.map +1 -0
- package/dist/console/index.d.ts +29 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +45 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/makeJob.d.ts +32 -0
- package/dist/console/makeJob.d.ts.map +1 -0
- package/dist/console/makeJob.js +118 -0
- package/dist/console/makeJob.js.map +1 -0
- package/dist/console/queueWork.d.ts +18 -0
- package/dist/console/queueWork.d.ts.map +1 -0
- package/dist/console/queueWork.js +58 -0
- package/dist/console/queueWork.js.map +1 -0
- package/dist/drivers/MemoryDriver.d.ts +14 -8
- package/dist/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/drivers/MemoryDriver.js +61 -7
- package/dist/drivers/MemoryDriver.js.map +1 -1
- package/dist/drivers/RedisDriver.d.ts +60 -8
- package/dist/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/drivers/RedisDriver.js +214 -30
- package/dist/drivers/RedisDriver.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +55 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +135 -0
- package/dist/jobs.js.map +1 -0
- package/dist/quasar.d.ts +6 -9
- package/dist/quasar.d.ts.map +1 -1
- package/dist/quasar.js +27 -55
- package/dist/quasar.js.map +1 -1
- package/dist/testing/FakeQueue.d.ts +15 -9
- package/dist/testing/FakeQueue.d.ts.map +1 -1
- package/dist/testing/FakeQueue.js +13 -3
- package/dist/testing/FakeQueue.js.map +1 -1
- package/dist/vendor/nodeEnv.d.ts +5 -0
- package/dist/vendor/nodeEnv.d.ts.map +1 -0
- package/dist/{nodeEnv.js → vendor/nodeEnv.js} +13 -7
- package/dist/vendor/nodeEnv.js.map +1 -0
- package/dist/vendor/quasarConnection.d.ts +35 -0
- package/dist/vendor/quasarConnection.d.ts.map +1 -0
- package/dist/vendor/quasarConnection.js +62 -0
- package/dist/vendor/quasarConnection.js.map +1 -0
- package/package.json +5 -3
- package/src/BayProvider.ts +92 -26
- package/src/Job.ts +137 -0
- package/src/QueueManager.ts +411 -56
- package/src/adapters.ts +75 -0
- package/src/augmentations.ts +31 -0
- package/src/configure.ts +25 -7
- package/src/console/contract.ts +94 -0
- package/src/console/index.ts +68 -0
- package/src/console/makeJob.ts +139 -0
- package/src/console/queueWork.ts +70 -0
- package/src/drivers/MemoryDriver.ts +66 -14
- package/src/drivers/RedisDriver.ts +303 -43
- package/src/index.ts +35 -6
- package/src/jobs.ts +144 -0
- package/src/quasar.ts +28 -72
- package/src/testing/FakeQueue.ts +25 -15
- package/src/{nodeEnv.ts → vendor/nodeEnv.ts} +14 -7
- package/src/vendor/quasarConnection.ts +129 -0
- package/dist/nodeEnv.d.ts +0 -16
- package/dist/nodeEnv.d.ts.map +0 -1
- package/dist/nodeEnv.js.map +0 -1
- package/dist/stores.d.ts +0 -41
- package/dist/stores.d.ts.map +0 -1
- package/dist/stores.js +0 -46
- package/dist/stores.js.map +0 -1
- 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
|
|
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
|
}
|
package/src/BayProvider.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
34
|
-
* from `
|
|
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,
|
|
67
|
+
stores?: Record<string, AdapterFactory>;
|
|
37
68
|
/**
|
|
38
|
-
* The single-
|
|
39
|
-
* `"memory"` was ever accepted. Prefer `default` + `
|
|
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
|
-
|
|
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 (
|
|
82
|
-
const selected =
|
|
131
|
+
if (adapters && name !== undefined) {
|
|
132
|
+
const selected = adapters[name];
|
|
83
133
|
if (!selected) {
|
|
84
|
-
const known = Object.keys(
|
|
134
|
+
const known = Object.keys(adapters);
|
|
85
135
|
throw new Error(
|
|
86
|
-
`[bay] config.queue names the
|
|
136
|
+
`[bay] config.queue names the adapter '${name}', which is not in \`${key}\`. ` +
|
|
87
137
|
(known.length > 0
|
|
88
138
|
? `Declared: ${known.join(", ")}.`
|
|
89
|
-
:
|
|
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 (
|
|
145
|
+
if (adapters && name === undefined) {
|
|
96
146
|
throw new Error(
|
|
97
|
-
|
|
98
|
-
`Set default to one of: ${Object.keys(
|
|
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 \`
|
|
106
|
-
"
|
|
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
|
-
|
|
121
|
-
|
|
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
|
+
}
|