@shivaedev/effect-pg-boss 0.8.0 → 0.9.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 (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +51 -3
  3. package/dist/server/error.d.ts +10 -0
  4. package/dist/server/error.d.ts.map +1 -1
  5. package/dist/server/error.js +14 -0
  6. package/dist/server/error.js.map +1 -1
  7. package/dist/server/internal/clientCache.d.ts +7 -0
  8. package/dist/server/internal/clientCache.d.ts.map +1 -0
  9. package/dist/server/internal/clientCache.js +51 -0
  10. package/dist/server/internal/clientCache.js.map +1 -0
  11. package/dist/server/internal/clientErrors.d.ts +8 -0
  12. package/dist/server/internal/clientErrors.d.ts.map +1 -0
  13. package/dist/server/internal/clientErrors.js +49 -0
  14. package/dist/server/internal/clientErrors.js.map +1 -0
  15. package/dist/server/internal/ensureQueue.d.ts.map +1 -1
  16. package/dist/server/internal/ensureQueue.js +19 -2
  17. package/dist/server/internal/ensureQueue.js.map +1 -1
  18. package/dist/server/internal/expireAttempt.d.ts +3 -0
  19. package/dist/server/internal/expireAttempt.d.ts.map +1 -0
  20. package/dist/server/internal/expireAttempt.js +14 -0
  21. package/dist/server/internal/expireAttempt.js.map +1 -0
  22. package/dist/server/internal/lifecycle.d.ts +2 -2
  23. package/dist/server/internal/lifecycle.d.ts.map +1 -1
  24. package/dist/server/internal/lifecycle.js +36 -79
  25. package/dist/server/internal/lifecycle.js.map +1 -1
  26. package/dist/server/internal/observeTestingSettlements.d.ts +6 -0
  27. package/dist/server/internal/observeTestingSettlements.d.ts.map +1 -0
  28. package/dist/server/internal/observeTestingSettlements.js +35 -0
  29. package/dist/server/internal/observeTestingSettlements.js.map +1 -0
  30. package/dist/server/internal/register.d.ts +2 -2
  31. package/dist/server/internal/register.d.ts.map +1 -1
  32. package/dist/server/internal/register.js +8 -3
  33. package/dist/server/internal/register.js.map +1 -1
  34. package/dist/server/internal/testingClient.d.ts +10 -0
  35. package/dist/server/internal/testingClient.d.ts.map +1 -0
  36. package/dist/server/internal/testingClient.js +101 -0
  37. package/dist/server/internal/testingClient.js.map +1 -0
  38. package/dist/server/internal/testingExpirations.d.ts +6 -0
  39. package/dist/server/internal/testingExpirations.d.ts.map +1 -0
  40. package/dist/server/internal/testingExpirations.js +27 -0
  41. package/dist/server/internal/testingExpirations.js.map +1 -0
  42. package/dist/server/internal/testingReadout.d.ts +44 -0
  43. package/dist/server/internal/testingReadout.d.ts.map +1 -0
  44. package/dist/server/internal/testingReadout.js +13 -0
  45. package/dist/server/internal/testingReadout.js.map +1 -0
  46. package/dist/server/internal/testingSignal.d.ts +7 -0
  47. package/dist/server/internal/testingSignal.d.ts.map +1 -0
  48. package/dist/server/internal/testingSignal.js +27 -0
  49. package/dist/server/internal/testingSignal.js.map +1 -0
  50. package/dist/server/service.d.ts.map +1 -1
  51. package/dist/server/service.js +4 -12
  52. package/dist/server/service.js.map +1 -1
  53. package/dist/server/testing.d.ts +61 -0
  54. package/dist/server/testing.d.ts.map +1 -0
  55. package/dist/server/testing.js +69 -0
  56. package/dist/server/testing.js.map +1 -0
  57. package/docs/roadmap.md +6 -5
  58. package/package.json +7 -5
  59. package/src/server/error.ts +21 -1
  60. package/src/server/internal/clientCache.ts +61 -0
  61. package/src/server/internal/clientErrors.ts +64 -0
  62. package/src/server/internal/ensureQueue.ts +20 -2
  63. package/src/server/internal/expireAttempt.ts +18 -0
  64. package/src/server/internal/lifecycle.ts +37 -97
  65. package/src/server/internal/observeTestingSettlements.ts +36 -0
  66. package/src/server/internal/register.ts +8 -4
  67. package/src/server/internal/testingClient.ts +115 -0
  68. package/src/server/internal/testingExpirations.ts +31 -0
  69. package/src/server/internal/testingReadout.ts +26 -0
  70. package/src/server/internal/testingSignal.ts +28 -0
  71. package/src/server/service.ts +5 -14
  72. package/src/server/testing.ts +85 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/effect-pg-boss",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Effect-native jobs and lifecycle management for pg-boss",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-pg-boss#readme",
6
6
  "bugs": {
@@ -43,17 +43,19 @@
43
43
  "README.md"
44
44
  ],
45
45
  "devDependencies": {
46
- "@shivaedev/quality": "0.22.0",
47
- "@types/node": "24.19.1",
46
+ "@shivaedev/effect-test-sql": "0.5.4",
47
+ "@shivaedev/quality": "0.23.0",
48
+ "@shivaedev/test-story": "0.6.0",
49
+ "@types/node": "24.19.2",
48
50
  "@typescript/native": "npm:typescript@7.0.2",
49
51
  "effect": "4.0.4",
50
- "pg-boss": "12.37.0",
52
+ "pg-boss": "12.37.1",
51
53
  "typescript": "npm:@typescript/typescript6@6.0.2",
52
54
  "vitest": "5.0.3"
53
55
  },
54
56
  "peerDependencies": {
55
57
  "effect": "4.0.4",
56
- "pg-boss": "12.37.0"
58
+ "pg-boss": "12.37.1"
57
59
  },
58
60
  "engines": {
59
61
  "node": ">=24"
@@ -6,7 +6,21 @@ export class PgBossError extends Data.TaggedError("PgBossError")<{
6
6
  readonly operation: PgBossOperation;
7
7
  readonly queue?: string | undefined;
8
8
  readonly original: Redacted.Redacted<unknown>;
9
- }> {}
9
+ }> {
10
+ // The original's message can quote payloads or connection strings, so only its class and code are shown.
11
+ override get message(): string {
12
+ const target = this.queue === undefined ? "" : ` for queue ${this.queue}`;
13
+ return `pg-boss ${this.operation}${target} failed: ${originalKind(Redacted.value(this.original))}`;
14
+ }
15
+ }
16
+
17
+ function originalKind(original: unknown): string {
18
+ if (!(original instanceof Error)) {
19
+ return typeof original;
20
+ }
21
+ const code: unknown = Reflect.get(original, "code");
22
+ return typeof code === "string" ? `${original.constructor.name} ${code}` : original.constructor.name;
23
+ }
10
24
 
11
25
  export class PgBossQueueOptionsError extends Data.TaggedError("PgBossQueueOptionsError")<{
12
26
  readonly queue: string;
@@ -23,6 +37,12 @@ export class PgBossPayloadError extends Data.TaggedError("PgBossPayloadError")<{
23
37
  readonly original: Redacted.Redacted<unknown>;
24
38
  }> {}
25
39
 
40
+ export class PgBossTestingError extends Data.TaggedError("PgBossTestingError")<{
41
+ readonly operation: string;
42
+ readonly original: Redacted.Redacted<unknown>;
43
+ readonly queue?: string | undefined;
44
+ }> {}
45
+
26
46
  export const toPgBossError = (operation: PgBossOperation, error: unknown, queue?: string): PgBossError =>
27
47
  new PgBossError({
28
48
  operation,
@@ -0,0 +1,61 @@
1
+ import type { PgBossClient } from "#client.ts";
2
+
3
+ interface CachedClient {
4
+ readonly client: Promise<PgBossClient>;
5
+ references: number;
6
+ }
7
+
8
+ const cacheSymbol = Symbol.for("@shivaedev/effect-pg-boss/client-cache");
9
+ const shared: unknown = Reflect.get(globalThis, cacheSymbol);
10
+ const clients: Map<string | symbol, unknown> = shared instanceof Map ? shared : new Map<string | symbol, unknown>();
11
+ Reflect.set(globalThis, cacheSymbol, clients);
12
+
13
+ function entryFor(key: string | symbol): CachedClient | undefined {
14
+ const value = clients.get(key);
15
+ return isCachedClient(value) ? value : undefined;
16
+ }
17
+
18
+ function isCachedClient(value: unknown): value is CachedClient {
19
+ return (
20
+ typeof value === "object"
21
+ && value !== null
22
+ && Reflect.get(value, "client") instanceof Promise
23
+ && typeof Reflect.get(value, "references") === "number"
24
+ );
25
+ }
26
+
27
+ export async function acquireCachedClient(key: string | symbol, start: () => Promise<PgBossClient>) {
28
+ const existing = entryFor(key);
29
+ if (existing !== undefined) {
30
+ existing.references += 1;
31
+ try {
32
+ return { client: await existing.client, reused: true };
33
+ } catch (error) {
34
+ existing.references -= 1;
35
+ throw error;
36
+ }
37
+ }
38
+ const entry: CachedClient = { client: start(), references: 1 };
39
+ clients.set(key, entry);
40
+ try {
41
+ return { client: await entry.client, reused: false };
42
+ } catch (error) {
43
+ if (clients.get(key) === entry) {
44
+ clients.delete(key);
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ export function releaseCachedClient(key: string | symbol): Promise<PgBossClient> | undefined {
51
+ const entry = entryFor(key);
52
+ if (entry === undefined) {
53
+ return;
54
+ }
55
+ entry.references -= 1;
56
+ if (entry.references > 0) {
57
+ return;
58
+ }
59
+ clients.delete(key);
60
+ return entry.client;
61
+ }
@@ -0,0 +1,64 @@
1
+ import { Effect } from "effect";
2
+ import type { PgBossClient } from "#client.ts";
3
+
4
+ type Listener = (error: Error) => void;
5
+
6
+ interface Observer {
7
+ readonly dispose: () => void;
8
+ readonly listeners: Set<Listener>;
9
+ }
10
+
11
+ const key = Symbol.for("@shivaedev/effect-pg-boss/error-observers");
12
+ const shared: unknown = Reflect.get(globalThis, key);
13
+ const observers: WeakMap<WeakKey, unknown> = shared instanceof WeakMap ? shared : new WeakMap<WeakKey, unknown>();
14
+ Reflect.set(globalThis, key, observers);
15
+
16
+ export function logClientError(error: Error): Effect.Effect<void> {
17
+ const message = { event: "pg_boss_error", message: error.message };
18
+ return Reflect.get(error, "code") === "57P01" ? Effect.logDebug(message) : Effect.logError(message);
19
+ }
20
+
21
+ export function observeClientErrors(client: PgBossClient, fallback: Listener): void {
22
+ closeClientErrors(client);
23
+ const listeners = new Set<Listener>();
24
+ const listener: Listener = (error) => {
25
+ if (listeners.size === 0) {
26
+ fallback(error);
27
+ } else {
28
+ for (const report of listeners) {
29
+ report(error);
30
+ }
31
+ }
32
+ };
33
+ // Pool shutdown can resolve before its clients finish disconnecting.
34
+ client.on("error", listener);
35
+ observers.set(client, { dispose: () => client.off("error", listener), listeners });
36
+ }
37
+
38
+ export function subscribeClientErrors(client: PgBossClient, listener: Listener): () => void {
39
+ const observer = observerFor(client);
40
+ if (observer === undefined) {
41
+ throw new Error("pg-boss client has no error observer");
42
+ }
43
+ observer.listeners.add(listener);
44
+ return () => observer.listeners.delete(listener);
45
+ }
46
+
47
+ function closeClientErrors(client: PgBossClient): void {
48
+ observerFor(client)?.dispose();
49
+ observers.delete(client);
50
+ }
51
+
52
+ function observerFor(client: PgBossClient): Observer | undefined {
53
+ const value = observers.get(client);
54
+ return isObserver(value) ? value : undefined;
55
+ }
56
+
57
+ function isObserver(value: unknown): value is Observer {
58
+ return (
59
+ typeof value === "object"
60
+ && value !== null
61
+ && typeof Reflect.get(value, "dispose") === "function"
62
+ && Reflect.get(value, "listeners") instanceof Set
63
+ );
64
+ }
@@ -3,11 +3,29 @@ import type { PgBossClient } from "#client.ts";
3
3
  import { PgBossQueueOptionsError } from "#error.ts";
4
4
  import { deadLetterQueueName } from "#health.ts";
5
5
 
6
+ const DEADLOCK_DETECTED = "40P01";
7
+ const CREATE_QUEUE_ATTEMPTS = 6;
8
+
9
+ // A partitioned queue attaches its table to pg-boss's job table, which can deadlock with job traffic on other queues.
10
+ // PostgreSQL rolls the whole creation back, so trying it again is safe.
11
+ async function createQueue(client: PgBossClient, name: string, options?: Omit<Queue, "name">): Promise<void> {
12
+ for (let attempt = 1; ; attempt += 1) {
13
+ try {
14
+ return await client.createQueue(name, options);
15
+ } catch (error) {
16
+ if (attempt === CREATE_QUEUE_ATTEMPTS || !(error instanceof Error) || Reflect.get(error, "code") !== DEADLOCK_DETECTED) {
17
+ throw error;
18
+ }
19
+ await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** (attempt - 1)));
20
+ }
21
+ }
22
+ }
23
+
6
24
  export async function ensureQueue(client: PgBossClient, name: string, options: Omit<Queue, "name" | "deadLetter">): Promise<void> {
7
25
  const deadLetter = deadLetterQueueName(name);
8
26
  const desired = { retryBackoff: true, retryLimit: 3, ...options, deadLetter };
9
- await client.createQueue(deadLetter);
10
- await client.createQueue(name, desired);
27
+ await createQueue(client, deadLetter);
28
+ await createQueue(client, name, desired);
11
29
  const existing = await client.getQueue(name);
12
30
  if (existing === null) {
13
31
  return;
@@ -0,0 +1,18 @@
1
+ import type { Job, PgBoss } from "pg-boss";
2
+
3
+ export async function expireAttempt(client: PgBoss, schema: string, job: Job<unknown>): Promise<void> {
4
+ if ((await client.schemaVersion()) !== 45) {
5
+ throw new Error("Testing expiry requires pg-boss schema version 45");
6
+ }
7
+ const quoted = `"${schema.replaceAll('"', '""')}"`;
8
+ await client.getDb().executeSql(
9
+ `UPDATE ${quoted}.job SET started_on = ${quoted}.job_now() - (expire_seconds + 1) * interval '1 second'
10
+ WHERE name = $1 AND id = $2 AND retry_count = $3 AND state = 'active'`,
11
+ [job.name, job.id, job.retryCount],
12
+ );
13
+ // Supervision owns expiry and dead lettering; make its claim due with the job's deadline.
14
+ await client
15
+ .getDb()
16
+ .executeSql(`UPDATE ${quoted}.queue SET monitor_claim_on = ${quoted}.job_now() - interval '1 week' WHERE name = $1`, [job.name]);
17
+ await client.supervise(job.name);
18
+ }
@@ -2,30 +2,8 @@ import { Effect } from "effect";
2
2
  import type { ConstructorOptions, StopOptions } from "pg-boss";
3
3
  import { defaultClientFactory, type PgBossClient, type PgBossClientFactory } from "#client.ts";
4
4
  import { toPgBossError } from "#error.ts";
5
-
6
- interface CachedClient {
7
- readonly client: Promise<PgBossClient>;
8
- references: number;
9
- }
10
-
11
- const CacheKey = Symbol.for("@shivaedev/effect-pg-boss/client-cache");
12
- const sharedCache: unknown = Reflect.get(globalThis, CacheKey);
13
- const clientCache: Map<string | symbol, unknown> = sharedCache instanceof Map ? sharedCache : new Map<string | symbol, unknown>();
14
- Reflect.set(globalThis, CacheKey, clientCache);
15
-
16
- function isCachedClient(value: unknown): value is CachedClient {
17
- return (
18
- typeof value === "object"
19
- && value !== null
20
- && Reflect.get(value, "client") instanceof Promise
21
- && typeof Reflect.get(value, "references") === "number"
22
- );
23
- }
24
-
25
- function cachedClient(key: string | symbol): CachedClient | undefined {
26
- const entry = clientCache.get(key);
27
- return isCachedClient(entry) ? entry : undefined;
28
- }
5
+ import { acquireCachedClient, releaseCachedClient } from "./clientCache.ts";
6
+ import { logClientError, observeClientErrors } from "./clientErrors.ts";
29
7
 
30
8
  export interface AcquireClientOptions {
31
9
  readonly clientCacheKey?: string | symbol | undefined;
@@ -39,86 +17,48 @@ export interface AcquiredClient {
39
17
  readonly reused: boolean;
40
18
  }
41
19
 
42
- function startClient(options: AcquireClientOptions): Promise<PgBossClient> {
20
+ async function startClient(options: AcquireClientOptions, report: (error: Error) => void): Promise<PgBossClient> {
43
21
  const client = (options.clientFactory ?? defaultClientFactory)(options.constructor);
44
- return client.start().then(
45
- () => client,
46
- async (error) => {
47
- try {
48
- await client.stop();
49
- } catch {
50
- // Preserve the startup failure; stop is best effort on failed acquisition.
51
- }
52
- throw error;
53
- },
54
- );
22
+ observeClientErrors(client, report);
23
+ try {
24
+ await client.start();
25
+ return client;
26
+ } catch (error) {
27
+ try {
28
+ await client.stop();
29
+ } catch {
30
+ // Preserve the startup failure; stop is best effort on failed acquisition.
31
+ }
32
+ throw error;
33
+ }
55
34
  }
56
35
 
57
- export const acquireClient = (options: AcquireClientOptions): Effect.Effect<AcquiredClient, import("#error.ts").PgBossError> =>
58
- Effect.tryPromise({
59
- catch: (error) => toPgBossError("start", error),
60
- try: async () => {
61
- const cacheKey = options.clientCacheKey;
62
- if (cacheKey === undefined) {
63
- return {
64
- client: await startClient(options),
65
- reused: false,
66
- };
67
- }
68
-
69
- const existing = cachedClient(cacheKey);
70
- if (existing !== undefined) {
71
- existing.references += 1;
72
- try {
73
- return {
74
- cacheKey,
75
- client: await existing.client,
76
- reused: true,
77
- };
78
- } catch (error) {
79
- existing.references -= 1;
80
- throw error;
81
- }
82
- }
83
-
84
- const entry: CachedClient = {
85
- client: startClient(options),
86
- references: 1,
87
- };
88
- clientCache.set(cacheKey, entry);
89
- try {
90
- return {
91
- cacheKey,
92
- client: await entry.client,
93
- reused: false,
94
- };
95
- } catch (error) {
96
- if (clientCache.get(cacheKey) === entry) {
97
- clientCache.delete(cacheKey);
98
- }
99
- throw error;
100
- }
101
- },
36
+ export function acquireClient(options: AcquireClientOptions): Effect.Effect<AcquiredClient, import("#error.ts").PgBossError> {
37
+ return Effect.contextWith((context) => {
38
+ const run = Effect.runForkWith(context);
39
+ function report(error: Error) {
40
+ run(logClientError(error));
41
+ }
42
+ return Effect.tryPromise({
43
+ catch: (error) => toPgBossError("start", error),
44
+ try: async () => {
45
+ const cacheKey = options.clientCacheKey;
46
+ return cacheKey === undefined
47
+ ? { client: await startClient(options, report), reused: false }
48
+ : { cacheKey, ...(await acquireCachedClient(cacheKey, () => startClient(options, report))) };
49
+ },
50
+ });
102
51
  });
52
+ }
103
53
 
104
- export const releaseClient = (acquired: AcquiredClient, stopOptions?: StopOptions): Effect.Effect<void> =>
105
- Effect.tryPromise({
54
+ export function releaseClient(acquired: AcquiredClient, stopOptions?: StopOptions): Effect.Effect<void> {
55
+ return Effect.tryPromise({
106
56
  catch: (error) => toPgBossError("stop", error),
107
57
  try: async () => {
108
- if (acquired.cacheKey === undefined) {
109
- await acquired.client.stop(stopOptions);
110
- return;
111
- }
112
-
113
- const entry = cachedClient(acquired.cacheKey);
114
- if (entry === undefined) {
115
- return;
58
+ const client = acquired.cacheKey === undefined ? acquired.client : await releaseCachedClient(acquired.cacheKey);
59
+ if (client !== undefined) {
60
+ await client.stop(stopOptions);
116
61
  }
117
- entry.references -= 1;
118
- if (entry.references > 0) {
119
- return;
120
- }
121
- clientCache.delete(acquired.cacheKey);
122
- await acquired.client.stop(stopOptions);
123
62
  },
124
63
  }).pipe(Effect.catch((error) => Effect.logError(error)));
64
+ }
@@ -0,0 +1,36 @@
1
+ import type { PgBoss } from "pg-boss";
2
+
3
+ export function observeTestingSettlements(client: PgBoss, changed: () => void) {
4
+ const observers = new Map<string, () => void>();
5
+ return {
6
+ close() {
7
+ for (const close of observers.values()) {
8
+ close();
9
+ }
10
+ observers.clear();
11
+ },
12
+ watch(name: string) {
13
+ if (observers.has(name)) {
14
+ return;
15
+ }
16
+ const spy = client.getSpy(name);
17
+ let open = true;
18
+ async function observe() {
19
+ while (true) {
20
+ await Promise.race([spy.waitForJob(() => true, "completed"), spy.waitForJob(() => true, "failed")]);
21
+ if (!open) {
22
+ return;
23
+ }
24
+ // This controller owns the spy; clear history and the losing wait before rearming both outcomes.
25
+ spy.clear();
26
+ changed();
27
+ }
28
+ }
29
+ observers.set(name, () => {
30
+ open = false;
31
+ spy.clear();
32
+ });
33
+ void observe().catch((error: unknown) => client.emit("error", error instanceof Error ? error : new Error(String(error))));
34
+ },
35
+ };
36
+ }
@@ -9,7 +9,7 @@ import {
9
9
  type ScheduleDefinition,
10
10
  type ScheduledWorker,
11
11
  } from "#definition.ts";
12
- import { PgBossPayloadError, PgBossQueueOptionsError, toPgBossError } from "#error.ts";
12
+ import { PgBossError, PgBossPayloadError, PgBossQueueOptionsError, toPgBossError } from "#error.ts";
13
13
  import { batchHandler } from "#internal/batchHandler.ts";
14
14
  import { ensureQueue } from "#internal/ensureQueue.ts";
15
15
  import { payloadOf, traceJob } from "#internal/tracing.ts";
@@ -51,7 +51,11 @@ async function prepareQueues(client: PgBossClient, registrations: readonly JobRe
51
51
  }
52
52
  for (const registration of registrations) {
53
53
  const definition = registrationDefinition(registration);
54
- await ensureQueue(client, definition.name, definition.queueOptions);
54
+ try {
55
+ await ensureQueue(client, definition.name, definition.queueOptions);
56
+ } catch (error) {
57
+ throw error instanceof PgBossQueueOptionsError ? error : toPgBossError("register", error, definition.name);
58
+ }
55
59
  }
56
60
  }
57
61
 
@@ -113,9 +117,9 @@ export const registerJobs = <R>(
113
117
  registrations: readonly JobRegistration[],
114
118
  context: Context.Context<R>,
115
119
  replaceWorkers: boolean,
116
- ): Effect.Effect<void, import("#error.ts").PgBossError | PgBossQueueOptionsError> =>
120
+ ): Effect.Effect<void, PgBossError | PgBossQueueOptionsError> =>
117
121
  Effect.tryPromise({
118
- catch: (error) => (error instanceof PgBossQueueOptionsError ? error : toPgBossError("register", error)),
122
+ catch: (error) => (error instanceof PgBossQueueOptionsError || error instanceof PgBossError ? error : toPgBossError("register", error)),
119
123
  try: async () => {
120
124
  registrationNames(registrations);
121
125
  await prepareQueues(client, registrations, replaceWorkers);
@@ -0,0 +1,115 @@
1
+ import type { Job, PgBoss, SendOptions, UpdateOptions, UpdateRequest, UpdateResponse } from "pg-boss";
2
+ import type { PgBossClient } from "#client.ts";
3
+ import { expireAttempt } from "#internal/expireAttempt.ts";
4
+ import { observeTestingSettlements } from "#internal/observeTestingSettlements.ts";
5
+ import { testingExpirations } from "#internal/testingExpirations.ts";
6
+
7
+ export interface TestingControls {
8
+ readonly changed: () => void;
9
+ readonly expirations: Map<string, number>;
10
+ readonly failures: Set<string>;
11
+ readonly retryPolicy: Pick<SendOptions, "retryBackoff" | "retryDelay">;
12
+ }
13
+
14
+ export function testingClient(client: PgBoss, schema: string, controls: TestingControls): PgBossClient {
15
+ const settlements = observeTestingSettlements(client, controls.changed);
16
+ const expirations = testingExpirations(client);
17
+ function update(request: UpdateRequest): Promise<UpdateResponse>;
18
+ function update(name: string, data: object | null | undefined, options?: UpdateOptions): Promise<UpdateResponse>;
19
+ async function update(request: string | UpdateRequest, data?: object | null, options?: UpdateOptions): Promise<UpdateResponse> {
20
+ const result = typeof request === "string" ? await client.update(request, data, options) : await client.update(request);
21
+ controls.changed();
22
+ return result;
23
+ }
24
+ const overrides: Partial<PgBossClient> = {
25
+ complete: async (...args) => {
26
+ const result = await client.complete(...args);
27
+ controls.changed();
28
+ return result;
29
+ },
30
+ createQueue: (name, options) => {
31
+ const { retryDelayMax, ...rest } = options ?? {};
32
+ return client.createQueue(name, {
33
+ ...rest,
34
+ ...(controls.retryPolicy.retryBackoff === false || retryDelayMax === undefined ? {} : { retryDelayMax }),
35
+ ...controls.retryPolicy,
36
+ });
37
+ },
38
+ fail: async (...args) => {
39
+ const result = await client.fail(...args);
40
+ controls.changed();
41
+ return result;
42
+ },
43
+ retry: async (...args) => {
44
+ const result = await client.retry(...args);
45
+ controls.changed();
46
+ return result;
47
+ },
48
+ send: async (name, data, options) => {
49
+ if (controls.failures.delete(name)) {
50
+ throw new Error("Injected pg-boss send failure");
51
+ }
52
+ const attempts = controls.expirations.get(name);
53
+ const { retryDelayMax, ...rest } = options ?? {};
54
+ const id = await expirations.send(
55
+ name,
56
+ data,
57
+ {
58
+ ...rest,
59
+ ...(controls.retryPolicy.retryBackoff === false || retryDelayMax === undefined ? {} : { retryDelayMax }),
60
+ ...controls.retryPolicy,
61
+ ...(attempts === undefined ? {} : { retryLimit: attempts - 1 }),
62
+ },
63
+ attempts,
64
+ );
65
+ controls.changed();
66
+ return id;
67
+ },
68
+ stop: async (options) => {
69
+ try {
70
+ await client.stop(options);
71
+ } finally {
72
+ settlements.close();
73
+ }
74
+ },
75
+ update,
76
+ updateQueue: (name, options) =>
77
+ client.updateQueue(name, {
78
+ ...options,
79
+ ...controls.retryPolicy,
80
+ ...(controls.retryPolicy.retryBackoff === false ? { retryDelayMax: null } : {}),
81
+ }),
82
+ work: (name, options, handler) => {
83
+ settlements.watch(name);
84
+ return client.work(name, options, async (jobs: Job<unknown>[]) => {
85
+ controls.changed();
86
+ try {
87
+ const ready: Job<unknown>[] = [];
88
+ for (const job of jobs) {
89
+ const attempts = await expirations.attemptsFor(job);
90
+ if (attempts !== undefined && job.retryCount + 1 >= attempts) {
91
+ await expireAttempt(client, schema, job);
92
+ } else {
93
+ ready.push(job);
94
+ }
95
+ }
96
+ if (ready.length > 0) {
97
+ await handler(ready);
98
+ }
99
+ } finally {
100
+ controls.changed();
101
+ }
102
+ });
103
+ },
104
+ };
105
+ return new Proxy(client, {
106
+ get(target, property) {
107
+ if (Object.hasOwn(overrides, property)) {
108
+ const override: unknown = Reflect.get(overrides, property);
109
+ return override;
110
+ }
111
+ const value: unknown = Reflect.get(target, property);
112
+ return typeof value === "function" ? value.bind(target) : value;
113
+ },
114
+ });
115
+ }
@@ -0,0 +1,31 @@
1
+ import type { Job, PgBoss, SendOptions } from "pg-boss";
2
+
3
+ export function testingExpirations(client: PgBoss) {
4
+ const snapshots = new Map<string, Promise<number | undefined>>();
5
+ return {
6
+ attemptsFor: (job: Job<unknown>) => snapshots.get(`${job.name}:${job.id}`),
7
+ send(name: string, data: object | null | undefined, options: SendOptions, attempts: number | undefined) {
8
+ if (attempts === undefined && options.id === undefined) {
9
+ return client.send(name, data, options);
10
+ }
11
+ const id = options.id ?? crypto.randomUUID();
12
+ const key = `${name}:${id}`;
13
+ const previous = snapshots.get(key);
14
+ const sending = client.send(name, data, { ...options, id });
15
+ // A worker may claim the row before send returns. Wait for its result before applying the snapshot.
16
+ const snapshot = sending
17
+ .then(
18
+ (sent) => (sent === id ? attempts : previous),
19
+ () => previous,
20
+ )
21
+ .then((value) => {
22
+ if (value === undefined && snapshots.get(key) === snapshot) {
23
+ snapshots.delete(key);
24
+ }
25
+ return value;
26
+ });
27
+ snapshots.set(key, snapshot);
28
+ return sending;
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,26 @@
1
+ import { Effect, Redacted, Schema } from "effect";
2
+ import type { FindJobsOptions, PgBoss } from "pg-boss";
3
+ import type { JobPayloadSchema, QueueDefinition } from "#definition.ts";
4
+ import { PgBossPayloadError, PgBossTestingError } from "#error.ts";
5
+ import { payloadOf } from "#internal/tracing.ts";
6
+
7
+ export function testingOperation<A>(operation: string, run: () => Promise<A>, queue?: string) {
8
+ return Effect.tryPromise({
9
+ catch: (original) => new PgBossTestingError({ operation, original: Redacted.make(original), queue }),
10
+ try: run,
11
+ });
12
+ }
13
+
14
+ export function testingReadout(client: () => PgBoss) {
15
+ return <TName extends string, TPayload extends JobPayloadSchema>(queue: QueueDefinition<TName, TPayload>, options?: FindJobsOptions) =>
16
+ testingOperation("jobs", () => client().findJobs<unknown>(queue.name, options), queue.name).pipe(
17
+ Effect.flatMap((jobs) =>
18
+ Effect.forEach(jobs, (job) =>
19
+ Schema.decodeUnknownEffect(queue.schema)(payloadOf(job.data)).pipe(
20
+ Effect.mapError((original) => new PgBossPayloadError({ direction: "decode", original: Redacted.make(original), queue: queue.name })),
21
+ Effect.map((data) => ({ ...job, data })),
22
+ ),
23
+ ),
24
+ ),
25
+ );
26
+ }
@@ -0,0 +1,28 @@
1
+ import { Effect } from "effect";
2
+
3
+ export function testingSignal() {
4
+ let revision = 0;
5
+ const listeners = new Set<(revision: number) => void>();
6
+ return {
7
+ changed() {
8
+ revision += 1;
9
+ for (const listener of listeners) {
10
+ listener(revision);
11
+ }
12
+ listeners.clear();
13
+ },
14
+ revision: Effect.sync(() => revision),
15
+ waitForChange: (after: number): Effect.Effect<number> =>
16
+ Effect.callback((resume) => {
17
+ if (revision !== after) {
18
+ resume(Effect.succeed(revision));
19
+ return;
20
+ }
21
+ function listener(current: number) {
22
+ resume(Effect.succeed(current));
23
+ }
24
+ listeners.add(listener);
25
+ return Effect.sync(() => listeners.delete(listener));
26
+ }),
27
+ };
28
+ }