@c9up/bay 0.1.13 → 0.2.0

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 (85) hide show
  1. package/README.md +122 -16
  2. package/dist/BayProvider.d.ts +39 -11
  3. package/dist/BayProvider.d.ts.map +1 -1
  4. package/dist/BayProvider.js +35 -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 +209 -29
  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 +43 -0
  55. package/dist/jobs.d.ts.map +1 -0
  56. package/dist/jobs.js +105 -0
  57. package/dist/jobs.js.map +1 -0
  58. package/dist/quasar.d.ts +1 -1
  59. package/dist/quasar.js +1 -1
  60. package/dist/testing/FakeQueue.d.ts +15 -9
  61. package/dist/testing/FakeQueue.d.ts.map +1 -1
  62. package/dist/testing/FakeQueue.js +13 -3
  63. package/dist/testing/FakeQueue.js.map +1 -1
  64. package/package.json +5 -3
  65. package/src/BayProvider.ts +79 -26
  66. package/src/Job.ts +137 -0
  67. package/src/QueueManager.ts +411 -56
  68. package/src/adapters.ts +75 -0
  69. package/src/augmentations.ts +31 -0
  70. package/src/configure.ts +25 -7
  71. package/src/console/contract.ts +94 -0
  72. package/src/console/index.ts +68 -0
  73. package/src/console/makeJob.ts +139 -0
  74. package/src/console/queueWork.ts +70 -0
  75. package/src/drivers/MemoryDriver.ts +66 -14
  76. package/src/drivers/RedisDriver.ts +298 -42
  77. package/src/index.ts +35 -6
  78. package/src/jobs.ts +111 -0
  79. package/src/quasar.ts +1 -1
  80. package/src/testing/FakeQueue.ts +25 -15
  81. package/dist/stores.d.ts +0 -41
  82. package/dist/stores.d.ts.map +0 -1
  83. package/dist/stores.js +0 -46
  84. package/dist/stores.js.map +0 -1
  85. package/src/stores.ts +0 -59
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Teach ream's `ContainerBindings` what `container.make('queue')` returns.
3
+ *
4
+ * ream declares that interface open on purpose: it registers its own entries
5
+ * and expects each package to contribute the one it owns. Nothing filled this
6
+ * one in, so resolving by the string token answered `unknown` and every call
7
+ * site had to assert a type it could not prove.
8
+ *
9
+ * Loaded from the package barrel and from the provider, so registering bay is
10
+ * enough — an application writes no `declare module` of its own.
11
+ *
12
+ * Type-only, and ream stays an OPTIONAL peer: nothing here reaches a runtime
13
+ * import, and a `declare module` for a specifier that does not resolve is
14
+ * simply inert.
15
+ */
16
+
17
+ // Referenced so the augmentation below resolves the module it augments.
18
+ import type {} from "@c9up/ream/types";
19
+ import type { QueueManager } from "./QueueManager.js";
20
+
21
+ declare module "@c9up/ream/types" {
22
+ interface ContainerBindings {
23
+ /** The job queue, bound by `BayProvider`. */
24
+ "bay.queue": QueueManager;
25
+ /**
26
+ * The same binding under the name it had before the token carried its
27
+ * package. Kept bound so an existing `container.make(...)` resolves.
28
+ */
29
+ queue: QueueManager;
30
+ }
31
+ }
package/src/configure.ts CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  interface Codemods {
11
11
  addProvider(importPath: string): Promise<void>;
12
+ registerCommand(importPath: string): Promise<void>;
12
13
  addEnvVars(vars: Record<string, string>): Promise<void>;
13
14
  writeFile(
14
15
  filePath: string,
@@ -22,24 +23,41 @@ export async function configure(codemods: Codemods): Promise<void> {
22
23
  // without them leaves an application whose config asks the environment for
23
24
  // something nothing ever put there.
24
25
  await codemods.addEnvVars({
25
- QUEUE_STORE: "memory",
26
+ QUEUE_DRIVER: "memory",
26
27
  });
27
28
 
28
29
  await codemods.addProvider("@c9up/bay/provider");
30
+ // `queue:work` and `make:job` are the package's, not the binary's: a
31
+ // project reaches them by listing the module, never by upgrading `ream`.
32
+ await codemods.registerCommand("@c9up/bay/commands");
29
33
  await codemods.writeFile(
30
34
  "config/queue.ts",
31
- `import { defineConfig, stores } from '@c9up/bay'
35
+ `import { defineConfig, drivers } from '@c9up/bay'
32
36
  import env from '#start/env'
33
37
 
34
38
  export default defineConfig({
35
- // Which store to run on. Memory forgets everything on restart, which is
39
+ // Which adapter to run on. Memory forgets everything on restart, which is
36
40
  // what a single process in development wants and nothing else does.
37
- default: env.get('QUEUE_STORE', 'memory'),
41
+ default: env.get('QUEUE_DRIVER', 'memory'),
38
42
 
39
- stores: {
40
- memory: stores.memory(),
41
- redis: stores.redis({ connection: 'main' }),
43
+ adapters: {
44
+ memory: drivers.memory(),
45
+ redis: drivers.redis({ connection: 'main' }),
42
46
  },
47
+
48
+ // What the worker does between jobs: how long it waits after finding
49
+ // nothing, how often it reclaims jobs a crashed worker left behind, how many
50
+ // it runs at once, and which named queues it serves.
51
+ worker: {
52
+ idleDelay: 2_000,
53
+ stalledInterval: 30_000,
54
+ concurrency: 1,
55
+ // queues: ['critical', 'default'],
56
+ },
57
+
58
+ // Where the job classes live. Every module under here is imported at boot,
59
+ // so a worker resolves a queued record by the class's own name.
60
+ locations: ['app/jobs'],
43
61
  })`,
44
62
  );
45
63
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The console command contract, declared locally.
3
+ *
4
+ * Bay stays framework-agnostic: it must not import `@c9up/ream`, so it
5
+ * describes the shape Ream's console kernel dispatches against rather than
6
+ * importing it.
7
+ *
8
+ * Ream's decorators (`@args` / `@flags`) live in the framework, so the helpers
9
+ * below build the same metadata without them.
10
+ */
11
+
12
+ export interface CommandOptions {
13
+ /** Boot the application before `run()`. Off by default. */
14
+ startApp?: boolean;
15
+ staysAlive?: boolean;
16
+ allowUnknownFlags?: boolean;
17
+ }
18
+
19
+ export interface ArgumentMetaData {
20
+ type: "string" | "spread";
21
+ propertyName: string;
22
+ argumentName: string;
23
+ description?: string;
24
+ required: boolean;
25
+ default?: string | string[];
26
+ }
27
+
28
+ export interface FlagMetaData {
29
+ type: "string" | "boolean" | "number" | "array";
30
+ propertyName: string;
31
+ flagName: string;
32
+ description?: string;
33
+ alias: string[];
34
+ default?: string | string[] | number | boolean;
35
+ required: boolean;
36
+ }
37
+
38
+ /** The static side the kernel reads. */
39
+ export interface BayCommandClass {
40
+ new (): { run(): Promise<void> | void };
41
+ commandName: string;
42
+ description: string;
43
+ options?: CommandOptions;
44
+ args?: readonly ArgumentMetaData[];
45
+ flags?: readonly FlagMetaData[];
46
+ help?: string | string[];
47
+ }
48
+
49
+ /** `startServer` → `start-server`, matching the framework's decorators. */
50
+ function dashCase(value: string): string {
51
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
52
+ }
53
+
54
+ export function flag(
55
+ propertyName: string,
56
+ type: FlagMetaData["type"],
57
+ options: {
58
+ flagName?: string;
59
+ description?: string;
60
+ alias?: string[];
61
+ default?: FlagMetaData["default"];
62
+ required?: boolean;
63
+ } = {},
64
+ ): FlagMetaData {
65
+ return {
66
+ type,
67
+ propertyName,
68
+ flagName: options.flagName ?? dashCase(propertyName),
69
+ description: options.description,
70
+ alias: options.alias ?? [],
71
+ default: options.default,
72
+ required: options.required ?? false,
73
+ };
74
+ }
75
+
76
+ export function argument(
77
+ propertyName: string,
78
+ options: {
79
+ type?: ArgumentMetaData["type"];
80
+ argumentName?: string;
81
+ description?: string;
82
+ required?: boolean;
83
+ default?: ArgumentMetaData["default"];
84
+ } = {},
85
+ ): ArgumentMetaData {
86
+ return {
87
+ type: options.type ?? "string",
88
+ propertyName,
89
+ argumentName: options.argumentName ?? dashCase(propertyName),
90
+ description: options.description,
91
+ required: options.required ?? options.default === undefined,
92
+ default: options.default,
93
+ };
94
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `@c9up/bay/commands` — the commands bay ships.
3
+ *
4
+ * // reamrc.ts, written by `configure()`
5
+ * commands: [() => import('@c9up/bay/commands')]
6
+ *
7
+ * A module answering `getMetaData()` / `getCommand()`, which is how a package
8
+ * adds commands: by shipping them, never by a change to the `ream` binary.
9
+ *
10
+ * `jobsDir` is read through a getter, not captured: these classes are built
11
+ * when the module is imported — before the application boots — while `run()`
12
+ * happens after it, when the config exists.
13
+ */
14
+
15
+ import { getJobsDir } from "../jobs.js";
16
+ import type { BayCommandClass } from "./contract.js";
17
+ import { makeJobCommand } from "./makeJob.js";
18
+ import { queueWorkCommand } from "./queueWork.js";
19
+
20
+ const COMMANDS: readonly BayCommandClass[] = [
21
+ queueWorkCommand(),
22
+ makeJobCommand({
23
+ get jobsDir() {
24
+ return getJobsDir();
25
+ },
26
+ }),
27
+ ];
28
+
29
+ /** What the kernel reads to list a command without importing it. */
30
+ interface CommandMetaData {
31
+ commandName: string;
32
+ namespace: string | null;
33
+ description: string;
34
+ help?: string | string[];
35
+ aliases: string[];
36
+ options: Record<string, unknown>;
37
+ args: readonly unknown[];
38
+ flags: readonly unknown[];
39
+ }
40
+
41
+ function serialize(command: BayCommandClass): CommandMetaData {
42
+ const colon = command.commandName.indexOf(":");
43
+ return {
44
+ commandName: command.commandName,
45
+ namespace: colon === -1 ? null : command.commandName.slice(0, colon),
46
+ description: command.description,
47
+ help: command.help,
48
+ aliases: [],
49
+ options: { ...command.options },
50
+ args: command.args ?? [],
51
+ flags: command.flags ?? [],
52
+ };
53
+ }
54
+
55
+ export async function getMetaData(): Promise<CommandMetaData[]> {
56
+ return COMMANDS.map(serialize);
57
+ }
58
+
59
+ export async function getCommand(
60
+ metadata: CommandMetaData,
61
+ ): Promise<BayCommandClass | null> {
62
+ return (
63
+ COMMANDS.find((command) => command.commandName === metadata.commandName) ??
64
+ null
65
+ );
66
+ }
67
+
68
+ export type { BayCommandClass } from "./contract.js";
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `make:job` — scaffold a job class.
3
+ *
4
+ * ream make:job SendWelcomeEmail → app/jobs/send_welcome_email.ts
5
+ * ream make:job emails/SendWelcomeEmail → app/jobs/emails/send_welcome_email.ts
6
+ *
7
+ * A subdirectory is allowed and is the only reason the name is not a plain
8
+ * identifier: `..` and every other way out of the jobs directory is refused
9
+ * before anything is written.
10
+ */
11
+
12
+ import * as fsp from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { argument, type BayCommandClass } from "./contract.js";
15
+
16
+ export interface MakeJobOptions {
17
+ /** Directory the job files are scaffolded into. */
18
+ jobsDir: string;
19
+ }
20
+
21
+ /** `SendWelcomeEmail` → `send_welcome_email`, `HTTPPing` → `http_ping`. */
22
+ export function toSnakeCase(name: string): string {
23
+ return name
24
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
25
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
26
+ .replace(/[-\s]+/g, "_")
27
+ .toLowerCase();
28
+ }
29
+
30
+ /** `send_welcome_email` / `sendWelcomeEmail` → `SendWelcomeEmail`. */
31
+ export function toPascalCase(name: string): string {
32
+ return name
33
+ .split(/[_\-\s]+/)
34
+ .filter((part) => part.length > 0)
35
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
36
+ .join("");
37
+ }
38
+
39
+ /**
40
+ * Split `emails/SendWelcomeEmail` into the directory it goes in and the class
41
+ * it declares, refusing anything that would leave the jobs directory.
42
+ */
43
+ export function resolveJobName(input: string): {
44
+ dir: string;
45
+ className: string;
46
+ fileName: string;
47
+ } {
48
+ const parts = input.split("/").filter((part) => part.length > 0);
49
+ const last = parts.pop();
50
+ if (last === undefined || last.length === 0) {
51
+ throw new Error(`Invalid job name: '${input}'`);
52
+ }
53
+ for (const part of [...parts, last]) {
54
+ // `..` is the traversal; the rest are characters a filename has no
55
+ // business carrying and a shell has opinions about.
56
+ if (part === ".." || /[\\'";`]/.test(part)) {
57
+ throw new Error(`Invalid job name: '${input}'`);
58
+ }
59
+ }
60
+ const className = toPascalCase(last);
61
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(className)) {
62
+ throw new Error(
63
+ `Invalid job name: '${input}' — a job's name becomes a class name`,
64
+ );
65
+ }
66
+ return {
67
+ dir: parts.join("/"),
68
+ className,
69
+ fileName: `${toSnakeCase(last)}.ts`,
70
+ };
71
+ }
72
+
73
+ /** The file a fresh job starts as. */
74
+ export function jobStub(className: string): string {
75
+ return `import { Job } from '@c9up/bay'
76
+ import type { JobOptions } from '@c9up/bay'
77
+
78
+ interface ${className}Payload {
79
+ // What \`dispatch(${className}, …)\` must be given.
80
+ }
81
+
82
+ export default class ${className} extends Job<${className}Payload> {
83
+ static options: JobOptions = {
84
+ // queue: 'default',
85
+ // maxRetries: 3,
86
+ // delay: '10s',
87
+ // timeout: '1m',
88
+ }
89
+
90
+ async execute(): Promise<void> {
91
+ // The work. Throwing is what makes the attempt fail.
92
+ void this.payload
93
+ }
94
+
95
+ async failed(error: Error): Promise<void> {
96
+ // Once the last attempt has failed — the alert or the cleanup.
97
+ void error
98
+ }
99
+ }
100
+ `;
101
+ }
102
+
103
+ export function makeJobCommand(options: MakeJobOptions): BayCommandClass {
104
+ return class MakeJob {
105
+ static commandName = "make:job";
106
+ static description = "Scaffold a background job class";
107
+ // Pure filesystem work: no reason to boot the app and open a connection.
108
+ static options = { startApp: false };
109
+ static args = [
110
+ argument("name", {
111
+ description: "Job class name, optionally under a subdirectory",
112
+ }),
113
+ ];
114
+
115
+ declare name: string;
116
+
117
+ async run(): Promise<void> {
118
+ let resolved: ReturnType<typeof resolveJobName>;
119
+ try {
120
+ resolved = resolveJobName(this.name);
121
+ } catch (err) {
122
+ console.error(
123
+ `[bay] ${err instanceof Error ? err.message : String(err)}`,
124
+ );
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+
129
+ const dir = path.join(options.jobsDir, resolved.dir);
130
+ const filePath = path.join(dir, resolved.fileName);
131
+ await fsp.mkdir(dir, { recursive: true });
132
+ // `wx`: an existing job is never clobbered, and the error says so.
133
+ await fsp.writeFile(filePath, jobStub(resolved.className), {
134
+ flag: "wx",
135
+ });
136
+ console.log(`Created ${filePath}`);
137
+ }
138
+ };
139
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `queue:work` — run the worker that takes jobs off the queue.
3
+ *
4
+ * The command a deployment runs as its own process, beside the HTTP one:
5
+ *
6
+ * ream queue:work
7
+ * ream queue:work --queue=emails,notifications
8
+ * ream queue:work --concurrency=10
9
+ *
10
+ * It stays alive on purpose — `staysAlive` — and the loop it starts is the
11
+ * application's, so a SIGTERM reaches `BayProvider.shutdown()`, which stops the
12
+ * worker and lets the job in flight finish rather than dropping it.
13
+ */
14
+
15
+ import { getQueue } from "../services/main.js";
16
+ import { type BayCommandClass, flag } from "./contract.js";
17
+
18
+ /** Split `--queue=a,b` into names, dropping the empties a trailing comma makes. */
19
+ export function parseQueues(value: string | undefined): string[] | undefined {
20
+ if (value === undefined) return undefined;
21
+ const names = value
22
+ .split(",")
23
+ .map((name) => name.trim())
24
+ .filter((name) => name.length > 0);
25
+ return names.length > 0 ? names : undefined;
26
+ }
27
+
28
+ export function queueWorkCommand(): BayCommandClass {
29
+ return class QueueWork {
30
+ static commandName = "queue:work";
31
+ static description = "Process queued jobs until the process is stopped";
32
+ // The worker needs the container: the queue it drains is the one the
33
+ // provider booted, with the driver the config named.
34
+ static options = { startApp: true, staysAlive: true };
35
+ static flags = [
36
+ flag("queue", "string", {
37
+ description:
38
+ "Comma-separated queues to serve, in order (default: the default queue)",
39
+ }),
40
+ flag("concurrency", "number", {
41
+ description: "How many jobs to run at once (default: 1)",
42
+ }),
43
+ ];
44
+
45
+ declare queue?: string;
46
+ declare concurrency?: number;
47
+
48
+ async run(): Promise<void> {
49
+ const manager = getQueue();
50
+ if (!manager) {
51
+ // Naming the wiring beats a stack trace out of the service proxy:
52
+ // the usual cause is a project that never added the provider.
53
+ throw new Error(
54
+ "[bay] queue:work found no queue. Add `() => import('@c9up/bay/provider')` " +
55
+ "to the providers in reamrc.ts, or call setQueue(myQueue) at boot.",
56
+ );
57
+ }
58
+
59
+ const queues = parseQueues(this.queue);
60
+ process.stdout.write(
61
+ `[bay] worker started — queues: ${(queues ?? ["default"]).join(", ")}, concurrency: ${this.concurrency ?? 1}\n`,
62
+ );
63
+
64
+ await manager.work({
65
+ queues,
66
+ concurrency: this.concurrency,
67
+ });
68
+ }
69
+ };
70
+ }
@@ -1,27 +1,71 @@
1
1
  /**
2
- * Memory queue driver — in-process queue for development.
2
+ * Memory queue driver — in-process queue for development and tests.
3
3
  */
4
4
 
5
- import type { Job, QueueDriver } from "../QueueManager.js";
5
+ import { DEFAULT_QUEUE } from "../Job.js";
6
+ import { type JobRecord, type QueueDriver, queueOf } from "../QueueManager.js";
6
7
 
7
8
  export class MemoryDriver implements QueueDriver {
8
- #pending: Job[] = [];
9
- #failedJobs: Job[] = [];
9
+ /** One list per named queue, created on first use. */
10
+ #pending: Map<string, JobRecord[]> = new Map();
11
+ /**
12
+ * Jobs whose `runAt` has not arrived, oldest deadline first.
13
+ *
14
+ * Kept apart from the queues rather than filtered on the way out: a delayed
15
+ * job at the head of a list would otherwise be skipped over on every poll,
16
+ * and a queue whose head is not due would look empty while it is not.
17
+ */
18
+ #delayed: JobRecord[] = [];
19
+ #failedJobs: JobRecord[] = [];
10
20
  #maxFailedJobs: number;
11
21
 
12
22
  constructor(options?: { maxFailedJobs?: number }) {
13
23
  this.#maxFailedJobs = options?.maxFailedJobs ?? 1000;
14
24
  }
15
25
 
16
- async push(job: Job): Promise<void> {
17
- this.#pending.push(job);
26
+ #queue(name: string): JobRecord[] {
27
+ const existing = this.#pending.get(name);
28
+ if (existing !== undefined) return existing;
29
+ const created: JobRecord[] = [];
30
+ this.#pending.set(name, created);
31
+ return created;
18
32
  }
19
33
 
20
- async pop(): Promise<Job | null> {
21
- return this.#pending.shift() ?? null;
34
+ /** Move everything whose delay has elapsed into its queue. */
35
+ #promoteDue(now = Date.now()): void {
36
+ if (this.#delayed.length === 0) return;
37
+ const due = this.#delayed.filter((job) => (job.runAt ?? 0) <= now);
38
+ if (due.length === 0) return;
39
+ this.#delayed = this.#delayed.filter((job) => (job.runAt ?? 0) > now);
40
+ for (const job of due) {
41
+ job.runAt = undefined;
42
+ this.#queue(queueOf(job)).push(job);
43
+ }
44
+ }
45
+
46
+ async push(job: JobRecord): Promise<void> {
47
+ if (job.runAt !== undefined && job.runAt > Date.now()) {
48
+ this.#delayed.push(job);
49
+ this.#delayed.sort((a, b) => (a.runAt ?? 0) - (b.runAt ?? 0));
50
+ return;
51
+ }
52
+ this.#queue(queueOf(job)).push(job);
53
+ }
54
+
55
+ async pop(
56
+ queues: readonly string[] = [DEFAULT_QUEUE],
57
+ ): Promise<JobRecord | null> {
58
+ this.#promoteDue();
59
+ // In the order given: naming `['critical', 'default']` is how a worker
60
+ // says which queue it would rather drain first.
61
+ for (const name of queues) {
62
+ const next = this.#pending.get(name)?.shift();
63
+ if (next !== undefined) return next;
64
+ }
65
+ return null;
22
66
  }
23
67
 
24
- async fail(job: Job, error: string): Promise<void> {
68
+ async fail(job: JobRecord, error: string): Promise<void> {
25
69
  job.error = error;
26
70
  job.status = "failed";
27
71
  this.#failedJobs.push(job);
@@ -30,20 +74,28 @@ export class MemoryDriver implements QueueDriver {
30
74
  }
31
75
  }
32
76
 
33
- async complete(_job: Job): Promise<void> {
77
+ async complete(_job: JobRecord): Promise<void> {
34
78
  // Nothing to do for memory driver
35
79
  }
36
80
 
37
- async retry(job: Job): Promise<void> {
81
+ async retry(job: JobRecord): Promise<void> {
38
82
  job.status = "pending";
39
- this.#pending.push(job);
83
+ this.#queue(queueOf(job)).push(job);
40
84
  }
41
85
 
42
- async failed(): Promise<Job[]> {
86
+ async failed(): Promise<JobRecord[]> {
43
87
  return [...this.#failedJobs];
44
88
  }
45
89
 
90
+ /**
91
+ * Everything waiting, across every queue — a delayed job included.
92
+ *
93
+ * It is queued; it is simply not due. Leaving it out would report an empty
94
+ * queue to anything draining one before shutdown.
95
+ */
46
96
  async size(): Promise<number> {
47
- return this.#pending.length;
97
+ let total = this.#delayed.length;
98
+ for (const jobs of this.#pending.values()) total += jobs.length;
99
+ return total;
48
100
  }
49
101
  }