@rvoh/psychic-workers 2.6.0 → 2.8.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 (36) hide show
  1. package/dist/cjs/src/background/BaseBackgroundedModel.js +49 -25
  2. package/dist/cjs/src/background/BaseBackgroundedService.js +24 -12
  3. package/dist/cjs/src/background/BaseScheduledService.js +1 -1
  4. package/dist/cjs/src/background/index.js +383 -98
  5. package/dist/cjs/src/error/background/DebouncedJobOutpacesRateLimit.js +64 -0
  6. package/dist/cjs/src/error/background/DebouncedJobRequiresMinimumDelay.js +52 -0
  7. package/dist/cjs/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.js +31 -0
  8. package/dist/cjs/src/error/background/RateLimitedPsychicJob.js +82 -0
  9. package/dist/cjs/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.js +51 -0
  10. package/dist/cjs/src/package-exports/errors.js +7 -0
  11. package/dist/cjs/src/test-utils/WorkerTestUtils.js +78 -13
  12. package/dist/esm/src/background/BaseBackgroundedModel.js +49 -25
  13. package/dist/esm/src/background/BaseBackgroundedService.js +24 -12
  14. package/dist/esm/src/background/BaseScheduledService.js +1 -1
  15. package/dist/esm/src/background/index.js +383 -98
  16. package/dist/esm/src/error/background/DebouncedJobOutpacesRateLimit.js +64 -0
  17. package/dist/esm/src/error/background/DebouncedJobRequiresMinimumDelay.js +52 -0
  18. package/dist/esm/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.js +31 -0
  19. package/dist/esm/src/error/background/RateLimitedPsychicJob.js +82 -0
  20. package/dist/esm/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.js +51 -0
  21. package/dist/esm/src/package-exports/errors.js +7 -0
  22. package/dist/esm/src/test-utils/WorkerTestUtils.js +78 -13
  23. package/dist/types/src/background/BaseBackgroundedModel.d.ts +49 -25
  24. package/dist/types/src/background/BaseBackgroundedService.d.ts +24 -12
  25. package/dist/types/src/background/BaseScheduledService.d.ts +2 -2
  26. package/dist/types/src/background/index.d.ts +109 -5
  27. package/dist/types/src/error/background/DebouncedJobOutpacesRateLimit.d.ts +31 -0
  28. package/dist/types/src/error/background/DebouncedJobRequiresMinimumDelay.d.ts +23 -0
  29. package/dist/types/src/error/background/NamedWorkstreamRateLimitMissingMaxOrDuration.d.ts +16 -0
  30. package/dist/types/src/error/background/RateLimitedPsychicJob.d.ts +73 -0
  31. package/dist/types/src/error/background/RateLimitedPsychicJobThrownFromWorkerWithoutLimiter.d.ts +16 -0
  32. package/dist/types/src/package-exports/errors.d.ts +1 -0
  33. package/dist/types/src/psychic-app-workers/index.d.ts +102 -53
  34. package/dist/types/src/test-utils/WorkerTestUtils.d.ts +32 -1
  35. package/dist/types/src/types/background.d.ts +88 -6
  36. package/package.json +28 -25
@@ -1,10 +1,41 @@
1
+ import { Queue } from 'bullmq';
2
+ /**
3
+ * @internal
4
+ *
5
+ * resolves the queue's expected Redis name through `nameToRedisQueueName`,
6
+ * rather than hand-reconstructing a cluster hash tag or parallel-test suffix.
7
+ * `queue.opts.connection` is the same `Redis | Cluster` instance the queue was
8
+ * built with, which is why the cast is safe for every queue this package builds.
9
+ */
10
+ export declare function queueNamesMatch(queue: Queue, compareQueueName: string): boolean;
1
11
  export default class WorkerTestUtils {
2
12
  static parallelTestSafeQueueName(queueName: string): string;
3
13
  static work(opts?: TestWorkerWorkOffOpts): Promise<void>;
4
14
  static workScheduled(opts?: TestWorkerScheduledWorkOffOpts): Promise<void>;
15
+ /**
16
+ * runs a delayed job in place. A `RateLimitedPsychicJob` thrown from a job on
17
+ * a queue whose workers carry no BullMQ `limiter` surfaces as the same
18
+ * misconfiguration error production would fail the job with; on a queue whose
19
+ * workers carry one, it propagates untranslated so the spec can assert on it.
20
+ */
21
+ private static doScheduledWork;
5
22
  static clean(): Promise<void>;
6
23
  private static workOne;
7
- private static queueNamesMatch;
24
+ /**
25
+ * runs one fetched job to completion or failure, mirroring what a real worker
26
+ * would do with a `RateLimitedPsychicJob`, then surfacing it so the spec goes
27
+ * red rather than looping:
28
+ *
29
+ * - on a queue whose workers carry no BullMQ `limiter`, the job is failed with
30
+ * the misconfiguration error production would fail it with (so, with the
31
+ * queue's `attempts` > 1, it lands in `delayed` for its retry, out of
32
+ * `active`, where `clean()` can reach it), and that error is rethrown
33
+ * - on a queue whose workers carry one, the job is moved back to the queue
34
+ * with no attempt counted (BullMQ's own rate-limit path), and the
35
+ * `RateLimitedPsychicJob` is rethrown
36
+ *
37
+ * Every other error only fails the job, as before.
38
+ */
8
39
  private static processJob;
9
40
  }
10
41
  interface TestWorkerWorkOffOpts {
@@ -29,14 +29,73 @@ export interface BackgroundJobData {
29
29
  */
30
30
  globalName?: string;
31
31
  }
32
- export type DelayedJobOpts = DelayedJobDuration & {
32
+ /**
33
+ * the delay to hold a job for and, when a `jobId` is provided, the debounce
34
+ * that delay drives.
35
+ *
36
+ * Calling a delayed background method repeatedly with the same `jobId`
37
+ * collapses those calls into a **single execution**. Each call slides the
38
+ * pending job's fire time further out, and the job runs once, after the last
39
+ * call, when the delay has finally elapsed without another call arriving.
40
+ *
41
+ * ```ts
42
+ * // ten calls in quick succession; the report is generated once,
43
+ * // ten seconds after the last of them
44
+ * await Report.backgroundWith({ delay: { seconds: 10, jobId: `report-${user.id}` } }, 'generate', user.id)
45
+ * ```
46
+ *
47
+ * A delay object must carry at least one of `seconds`, `minutes`, `hours` or
48
+ * `days`. `{}` and `{ jobId: 'my-job' }` are compile errors, since a delay with
49
+ * no duration delays nothing and a `jobId` with no duration debounces
50
+ * nothing.
51
+ */
52
+ export type DelayedJobOpts = AtLeastOneDelayedJobDuration & {
33
53
  /**
34
- * a unique identifier for your job. this identifier will be
35
- * used to debounce, leveraging the internal throttling mechanisms
36
- * provided by BullMQ
54
+ * a **deduplication key**, which debounces repeated calls: calls carrying the
55
+ * same `jobId` collapse into a single execution, which runs once the delay
56
+ * has elapsed without another call arriving. The window runs from the last
57
+ * call, not the first, so a burst is collapsed however long it lasts.
58
+ *
59
+ * Despite the name, this is not the BullMQ job id of the enqueued job, and
60
+ * `queue.getJob(jobId)` will not resolve the debounced job through it. BullMQ
61
+ * stores it as a separate Redis key; the matching read is
62
+ * `queue.getDeduplicationJobId(jobId)`.
63
+ *
64
+ * A delay carrying a `jobId` must be **at least three seconds**, and a
65
+ * shorter one is refused at enqueue.
66
+ *
67
+ * On a rate-limited workstream the delay must also clear the limiter's
68
+ * spacing: `delay - 1s >= duration / max`, so a workstream limited to one job
69
+ * a minute needs a 61-second delay. A delay that does not is refused at
70
+ * enqueue, naming the queue, both limiter numbers and a delay that would
71
+ * pass.
37
72
  */
38
73
  jobId?: string;
39
74
  };
75
+ /**
76
+ * `DelayedJobDuration`, but at least one field has to be there. Every field is
77
+ * optional on its own, so `{}` would otherwise compile and delay nothing.
78
+ * TypeScript has no "at least one of these" operator, so this spells out the
79
+ * four ways to satisfy it and joins them with `|`:
80
+ *
81
+ * ```ts
82
+ * | { seconds: number; minutes?: number; hours?: number; days?: number }
83
+ * | { minutes: number; seconds?: number; hours?: number; days?: number }
84
+ * | { hours: number; seconds?: number; minutes?: number; days?: number }
85
+ * | { days: number; seconds?: number; minutes?: number; hours?: number }
86
+ * ```
87
+ *
88
+ * A value has to match one of those, which means carrying that line's required
89
+ * field. `{ seconds: 30 }` matches the first. `{}` matches none, and neither
90
+ * does `{ jobId: 'my-job' }`.
91
+ *
92
+ * Not exported: it exists only to narrow `DelayedJobOpts`. `DelayedJobDuration`
93
+ * itself stays all-optional because it is also the parameter type of the shared
94
+ * `durationToSeconds` helper.
95
+ */
96
+ type AtLeastOneDelayedJobDuration = {
97
+ [Field in keyof DelayedJobDuration]-?: Required<Pick<DelayedJobDuration, Field>> & Partial<Omit<DelayedJobDuration, Field>>;
98
+ }[keyof DelayedJobDuration];
40
99
  export interface DelayedJobDuration {
41
100
  seconds?: number;
42
101
  minutes?: number;
@@ -49,8 +108,15 @@ export interface BackgroundWithOpts {
49
108
  /**
50
109
  * an optional delay to hold off the job for a certain amount of
51
110
  * time after it is entered into the queue. Accepts the same options
52
- * as `backgroundWithDelay`, including an optional `jobId` which
53
- * debounces repeated calls within the delay window.
111
+ * as `backgroundWithDelay`, and must carry at least one of `seconds`,
112
+ * `minutes`, `hours` or `days`.
113
+ *
114
+ * Adding a `jobId` turns the delay into a **debounce**: repeated calls
115
+ * carrying the same `jobId` collapse into a single execution, which runs
116
+ * once the delay has elapsed without another call arriving. `jobId` is a
117
+ * deduplication key rather than a BullMQ job id.
118
+ *
119
+ * See {@link DelayedJobOpts.jobId}.
54
120
  */
55
121
  delay?: DelayedJobOpts;
56
122
  /**
@@ -71,4 +137,20 @@ export interface QueueBackgroundJobConfig<T extends BaseScheduledService | BaseB
71
137
  }
72
138
  export type BackgroundJobConfig<T extends BaseScheduledService | BaseBackgroundedService> = Either<WorkstreamBackgroundJobConfig<T>, QueueBackgroundJobConfig<T>>;
73
139
  export type PsychicBackgroundOptions = (PsychicBackgroundSimpleOptions & Partial<Record<Exclude<keyof PsychicBackgroundNativeBullMQOptions, keyof PsychicBackgroundSimpleOptions>, never>>) | (PsychicBackgroundNativeBullMQOptions & Partial<Record<Exclude<keyof PsychicBackgroundSimpleOptions, keyof PsychicBackgroundNativeBullMQOptions>, never>>);
140
+ /**
141
+ * @internal
142
+ *
143
+ * how a queue was configured, as far as the misconfiguration message needs to
144
+ * know in order to name the fix that applies to the worker that threw
145
+ */
146
+ export interface WorkerQueueDescription {
147
+ /** simple (workstream) configuration or native BullMQ configuration */
148
+ mode: 'simple' | 'native';
149
+ /** the default workstream/queue, as opposed to a named one */
150
+ isDefaultQueue: boolean;
151
+ /** the workstream or queue name as configured (not the formatted Redis queue name) */
152
+ configuredName: string;
153
+ /** a workstream declared under `transitionalWorkstreams` */
154
+ transitional: boolean;
155
+ }
74
156
  export {};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic-workers",
4
4
  "description": "Background job system for Psychic applications",
5
- "version": "2.6.0",
5
+ "version": "2.8.0",
6
6
  "author": "RVO Health",
7
7
  "publishConfig": {
8
8
  "access": "public"
@@ -40,6 +40,9 @@
40
40
  "files": [
41
41
  "dist/**/*"
42
42
  ],
43
+ "engines": {
44
+ "node": ">=24"
45
+ },
43
46
  "scripts": {
44
47
  "psy": "NODE_ENV=${NODE_ENV:-test} pnpm psy:ts",
45
48
  "psy:js": "node ./dist/test-app/src/cli/index.js",
@@ -64,46 +67,46 @@
64
67
  "@eslint/js": "^10.0.1",
65
68
  "@koa/cors": "^5.0.0",
66
69
  "@koa/etag": "^5.0.2",
67
- "@koa/router": "^15.4.0",
68
- "@rvoh/dream": "^2.5.3",
69
- "@rvoh/dream-spec-helpers": "^2.1.1",
70
- "@rvoh/psychic": "^3.0.3",
71
- "@rvoh/psychic-spec-helpers": "^3.0.0",
72
- "@types/koa": "^3.0.2",
70
+ "@koa/router": "^15.7.0",
71
+ "@rvoh/dream": "^2.33.0",
72
+ "@rvoh/dream-spec-helpers": "^2.3.0",
73
+ "@rvoh/psychic": "^3.15.2",
74
+ "@rvoh/psychic-spec-helpers": "^3.4.0",
75
+ "@types/koa": "^3.0.3",
73
76
  "@types/koa-bodyparser": "^4.3.13",
74
77
  "@types/koa-conditional-get": "^2.0.4",
75
78
  "@types/koa-etag": "^3.0.4",
76
79
  "@types/koa__cors": "^5.0.1",
77
80
  "@types/koa__router": "^12.0.5",
78
- "@types/node": "^22.12.0",
81
+ "@types/node": "^26.6.3",
79
82
  "@types/pg": "^8",
80
- "@types/supertest": "^6.0.3",
81
- "@typescript-eslint/parser": "^8.67.0",
83
+ "@types/supertest": "^7.2.1",
84
+ "@typescript-eslint/parser": "^8.70.1",
82
85
  "bullmq": "^5.64.0",
83
- "eslint": "^10.0.1",
86
+ "eslint": "^10.11.0",
84
87
  "ioredis": "^5.8.2",
85
- "koa": "^3.1.2",
88
+ "koa": "^3.2.1",
86
89
  "koa-bodyparser": "^4.4.1",
87
90
  "koa-conditional-get": "^3.0.0",
88
- "kysely": "^0.28.17",
91
+ "kysely": "^0.29.6",
89
92
  "kysely-codegen": "~0.20.0",
90
93
  "luxon-jest-matchers": "^0.1.14",
91
- "openapi-typescript": "^7.9.1",
92
- "pg": "^8.13.1",
93
- "prettier": "^3.3.3",
94
- "supertest": "^7.2.2",
94
+ "openapi-typescript": "^7.13.0",
95
+ "pg": "^8.23.0",
96
+ "prettier": "^3.9.9",
97
+ "supertest": "^7.3.0",
95
98
  "tslib": "^2.7.0",
96
- "tsx": "^4.20.6",
97
- "typedoc": "^0.28.17",
98
- "typescript": "^5.8.2",
99
- "typescript-eslint": "^8.67.0",
100
- "vite": "^7.3.6",
101
- "vitest": "^4.1.11"
99
+ "tsx": "^4.23.15",
100
+ "typedoc": "^0.28.20",
101
+ "typescript": "^5.9.3",
102
+ "typescript-eslint": "^8.70.1",
103
+ "vite": "^8.3.1",
104
+ "vitest": "^5.0.2"
102
105
  },
103
106
  "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
104
107
  "dependencies": {
105
- "commander": "^14.0.2",
106
- "dotenv": "^17.2.3",
108
+ "commander": "^14.0.3",
109
+ "dotenv": "^17.4.2",
107
110
  "pluralize-esm": "^9.0.5"
108
111
  }
109
112
  }