@shivaedev/effect-pg-boss 0.13.0 → 0.15.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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +36 -5
  3. package/dist/server/error.d.ts +1 -1
  4. package/dist/server/error.d.ts.map +1 -1
  5. package/dist/server/error.js.map +1 -1
  6. package/dist/server/health.d.ts +27 -0
  7. package/dist/server/health.d.ts.map +1 -1
  8. package/dist/server/health.js.map +1 -1
  9. package/dist/server/internal/countsFor.d.ts.map +1 -1
  10. package/dist/server/internal/countsFor.js +3 -2
  11. package/dist/server/internal/countsFor.js.map +1 -1
  12. package/dist/server/internal/deferralStorePlan.d.ts.map +1 -1
  13. package/dist/server/internal/deferralStorePlan.js +3 -2
  14. package/dist/server/internal/deferralStorePlan.js.map +1 -1
  15. package/dist/server/internal/expireAttempt.d.ts.map +1 -1
  16. package/dist/server/internal/expireAttempt.js +2 -1
  17. package/dist/server/internal/expireAttempt.js.map +1 -1
  18. package/dist/server/internal/jobControl.d.ts.map +1 -1
  19. package/dist/server/internal/jobControl.js +4 -4
  20. package/dist/server/internal/jobControl.js.map +1 -1
  21. package/dist/server/internal/listJobs.d.ts +5 -0
  22. package/dist/server/internal/listJobs.d.ts.map +1 -0
  23. package/dist/server/internal/listJobs.js +69 -0
  24. package/dist/server/internal/listJobs.js.map +1 -0
  25. package/dist/server/internal/observeTestingSettlements.d.ts +1 -1
  26. package/dist/server/internal/observeTestingSettlements.d.ts.map +1 -1
  27. package/dist/server/internal/observeTestingSettlements.js +2 -2
  28. package/dist/server/internal/observeTestingSettlements.js.map +1 -1
  29. package/dist/server/internal/service.d.ts.map +1 -1
  30. package/dist/server/internal/service.js +3 -0
  31. package/dist/server/internal/service.js.map +1 -1
  32. package/dist/server/internal/settleDeferral.d.ts.map +1 -1
  33. package/dist/server/internal/settleDeferral.js +3 -2
  34. package/dist/server/internal/settleDeferral.js.map +1 -1
  35. package/dist/server/internal/storedRows.d.ts +6 -0
  36. package/dist/server/internal/storedRows.d.ts.map +1 -0
  37. package/dist/server/internal/storedRows.js +10 -0
  38. package/dist/server/internal/storedRows.js.map +1 -0
  39. package/dist/server/internal/testingClient.d.ts +2 -0
  40. package/dist/server/internal/testingClient.d.ts.map +1 -1
  41. package/dist/server/internal/testingClient.js +12 -1
  42. package/dist/server/internal/testingClient.js.map +1 -1
  43. package/dist/server/internal/testingDeferrals.d.ts.map +1 -1
  44. package/dist/server/internal/testingDeferrals.js +3 -2
  45. package/dist/server/internal/testingDeferrals.js.map +1 -1
  46. package/dist/server/internal/testingIdle.d.ts +16 -0
  47. package/dist/server/internal/testingIdle.d.ts.map +1 -0
  48. package/dist/server/internal/testingIdle.js +31 -0
  49. package/dist/server/internal/testingIdle.js.map +1 -0
  50. package/dist/server/internal/testingReadout.d.ts +2 -2
  51. package/dist/server/internal/testingReadout.d.ts.map +1 -1
  52. package/dist/server/internal/testingReadout.js +4 -4
  53. package/dist/server/internal/testingReadout.js.map +1 -1
  54. package/dist/server/internal/testingSignal.d.ts.map +1 -1
  55. package/dist/server/internal/testingSignal.js +4 -2
  56. package/dist/server/internal/testingSignal.js.map +1 -1
  57. package/dist/server/internal/testingSnapshot.d.ts +6 -0
  58. package/dist/server/internal/testingSnapshot.d.ts.map +1 -0
  59. package/dist/server/internal/testingSnapshot.js +32 -0
  60. package/dist/server/internal/testingSnapshot.js.map +1 -0
  61. package/dist/server/service.d.ts +3 -1
  62. package/dist/server/service.d.ts.map +1 -1
  63. package/dist/server/service.js.map +1 -1
  64. package/dist/server/testing.d.ts +14 -4
  65. package/dist/server/testing.d.ts.map +1 -1
  66. package/dist/server/testing.js +6 -0
  67. package/dist/server/testing.js.map +1 -1
  68. package/package.json +3 -3
  69. package/src/server/error.ts +12 -1
  70. package/src/server/health.ts +32 -0
  71. package/src/server/internal/countsFor.ts +3 -2
  72. package/src/server/internal/deferralStorePlan.ts +3 -2
  73. package/src/server/internal/expireAttempt.ts +2 -1
  74. package/src/server/internal/jobControl.ts +4 -7
  75. package/src/server/internal/listJobs.ts +98 -0
  76. package/src/server/internal/observeTestingSettlements.ts +2 -2
  77. package/src/server/internal/service.ts +3 -0
  78. package/src/server/internal/settleDeferral.ts +3 -2
  79. package/src/server/internal/storedRows.ts +17 -0
  80. package/src/server/internal/testingClient.ts +14 -1
  81. package/src/server/internal/testingDeferrals.ts +3 -2
  82. package/src/server/internal/testingIdle.ts +52 -0
  83. package/src/server/internal/testingReadout.ts +4 -11
  84. package/src/server/internal/testingSignal.ts +4 -2
  85. package/src/server/internal/testingSnapshot.ts +53 -0
  86. package/src/server/service.ts +9 -1
  87. package/src/server/testing.ts +18 -2
@@ -0,0 +1,98 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+ import type { PgBossClient } from "#client.ts";
3
+ import type { JobPayloadSchema, QueueDefinition } from "#definition.ts";
4
+ import { type PgBossError, type PgBossPayloadError, toPgBossError } from "#error.ts";
5
+ import { type DeadLetteredJob, deadLetterQueueName, type JobPage, type JobPageOptions } from "#health.ts";
6
+ import { decodeStoredData, quotedSchema } from "#internal/storedRows.ts";
7
+ import type { PgBossService } from "#service.ts";
8
+
9
+ const Cursor = Schema.StringFromBase64Url.pipe(Schema.decodeTo(Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.String]))));
10
+
11
+ const Row = Schema.Struct({
12
+ completedOn: Schema.OptionFromNullOr(Schema.DateTimeUtcFromDate),
13
+ createdOn: Schema.DateTimeUtcFromDate,
14
+ cursorAt: Schema.String,
15
+ data: Schema.Unknown,
16
+ id: Schema.String,
17
+ output: Schema.Unknown,
18
+ queue: Schema.String,
19
+ retryCount: Schema.Number,
20
+ retryLimit: Schema.Number,
21
+ sourceId: Schema.OptionFromNullOr(Schema.String),
22
+ sourceOutput: Schema.Unknown,
23
+ startedOn: Schema.OptionFromNullOr(Schema.DateTimeUtcFromDate),
24
+ state: Schema.Literals(["created", "retry", "active", "completed", "cancelled", "failed"]),
25
+ });
26
+
27
+ const Rows = Schema.Array(Row);
28
+
29
+ type Listing = "deadLetteredJobs" | "failedJobs";
30
+
31
+ export function failedJobsFor(client: PgBossClient, schema: string): PgBossService["failedJobs"] {
32
+ return (queue, options) =>
33
+ listJobs(client, schema, "failedJobs", queue, options).pipe(
34
+ Effect.map(({ jobs, next }) => ({ jobs: jobs.map(({ sourceId: _id, sourceOutput: _output, ...job }) => job), next })),
35
+ );
36
+ }
37
+
38
+ export function deadLetteredJobsFor(client: PgBossClient, schema: string): PgBossService["deadLetteredJobs"] {
39
+ return (queue, options) => listJobs(client, schema, "deadLetteredJobs", queue, options);
40
+ }
41
+
42
+ function listJobs<const TName extends string, const TPayload extends JobPayloadSchema>(
43
+ client: PgBossClient,
44
+ schema: string,
45
+ listing: Listing,
46
+ queue: QueueDefinition<TName, TPayload>,
47
+ options: JobPageOptions,
48
+ ): Effect.Effect<JobPage<DeadLetteredJob<TPayload["Type"]>>, PgBossError | PgBossPayloadError, TPayload["DecodingServices"]> {
49
+ return Effect.tryPromise({
50
+ catch: (error) => toPgBossError(listing, error, queue.name),
51
+ try: async () => {
52
+ if (client.getDb === undefined) {
53
+ throw new TypeError("This pg-boss client does not support getDb");
54
+ }
55
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1) {
56
+ throw new RangeError("The page limit must be a positive integer");
57
+ }
58
+ const after = options.cursor === undefined ? undefined : await Schema.decodeUnknownPromise(Cursor)(options.cursor);
59
+ const quoted = quotedSchema(schema);
60
+ const { rows } = await client.getDb().executeSql(
61
+ `SELECT
62
+ id,
63
+ name AS queue,
64
+ state,
65
+ retry_count AS "retryCount",
66
+ retry_limit AS "retryLimit",
67
+ created_on AS "createdOn",
68
+ started_on AS "startedOn",
69
+ completed_on AS "completedOn",
70
+ output,
71
+ data,
72
+ source_id AS "sourceId",
73
+ source_output AS "sourceOutput",
74
+ to_json(created_on) #>> '{}' AS "cursorAt"
75
+ FROM ${quoted}.job
76
+ WHERE name = $1
77
+ ${listing === "failedJobs" ? "AND state = 'failed'" : ""}
78
+ AND ($2::timestamptz IS NULL OR (created_on, id) < ($2::timestamptz, $3::uuid))
79
+ ORDER BY created_on DESC, id DESC
80
+ LIMIT $4`,
81
+ [listing === "failedJobs" ? queue.name : deadLetterQueueName(queue.name), after?.[0] ?? null, after?.[1] ?? null, options.limit + 1],
82
+ );
83
+ return Schema.decodeUnknownPromise(Rows)(rows);
84
+ },
85
+ }).pipe(
86
+ Effect.flatMap((rows) => {
87
+ const page = rows.slice(0, options.limit);
88
+ const last = page.at(-1);
89
+ const next =
90
+ rows.length > options.limit && last !== undefined ? Option.some(Schema.encodeSync(Cursor)([last.cursorAt, last.id])) : Option.none();
91
+ return Effect.forEach(page, ({ cursorAt: _, data, ...row }) =>
92
+ decodeStoredData(queue, data).pipe(
93
+ Effect.map((decoded) => ({ ...row, attempt: Option.isSome(row.startedOn) ? row.retryCount + 1 : 0, data: decoded })),
94
+ ),
95
+ ).pipe(Effect.map((jobs) => ({ jobs, next })));
96
+ }),
97
+ );
98
+ }
@@ -1,6 +1,6 @@
1
1
  import type { PgBoss } from "pg-boss";
2
2
 
3
- export function observeTestingSettlements(client: PgBoss, changed: () => void) {
3
+ export function observeTestingSettlements(client: PgBoss, settled: (name: string) => void) {
4
4
  const observers = new Map<string, () => void>();
5
5
  return {
6
6
  close() {
@@ -23,7 +23,7 @@ export function observeTestingSettlements(client: PgBoss, changed: () => void) {
23
23
  }
24
24
  // This controller owns the spy; clear history and the losing wait before rearming both outcomes.
25
25
  spy.clear();
26
- changed();
26
+ settled(name);
27
27
  }
28
28
  }
29
29
  observers.set(name, () => {
@@ -4,11 +4,13 @@ import { type PgBossError, PgBossPayloadError, toPgBossError } from "#error.ts";
4
4
  import { countsFor } from "#internal/countsFor.ts";
5
5
  import { healthFor } from "#internal/health.ts";
6
6
  import { getJobFor, retryFor } from "#internal/jobControl.ts";
7
+ import { deadLetteredJobsFor, failedJobsFor } from "#internal/listJobs.ts";
7
8
  import { capturePayload } from "#internal/tracing.ts";
8
9
  import type { PgBossService } from "#service.ts";
9
10
 
10
11
  export const makeService = (client: PgBossClient, names: readonly string[], schema = "pgboss"): PgBossService => ({
11
12
  counts: (queue) => countsFor(client, schema, queue.name),
13
+ deadLetteredJobs: deadLetteredJobsFor(client, schema),
12
14
  enqueue: (queue, payload, options) =>
13
15
  Schema.encodeUnknownEffect(queue.schema)(payload).pipe(
14
16
  Effect.mapError(
@@ -40,6 +42,7 @@ export const makeService = (client: PgBossClient, names: readonly string[], sche
40
42
  );
41
43
  }),
42
44
  ),
45
+ failedJobs: failedJobsFor(client, schema),
43
46
  getJob: getJobFor(client),
44
47
  health: healthFor(client, names),
45
48
  retry: retryFor(client),
@@ -1,7 +1,8 @@
1
1
  import { Schema } from "effect";
2
2
  import type { Job } from "pg-boss";
3
3
  import type { PgBossClient } from "#client.ts";
4
- import { STATELY_DEFERRAL_POLICY } from "./deferral.ts";
4
+ import { STATELY_DEFERRAL_POLICY } from "#internal/deferral.ts";
5
+ import { quotedSchema } from "#internal/storedRows.ts";
5
6
 
6
7
  const Settlement = Schema.Struct({ claimed: Schema.Boolean, deferred: Schema.Boolean });
7
8
 
@@ -9,7 +10,7 @@ export async function settleDeferral(client: PgBossClient, schema: string, job:
9
10
  if (client.getDb === undefined) {
10
11
  throw new TypeError("This pg-boss client does not support getDb for RetryAt");
11
12
  }
12
- const quoted = `"${schema.replaceAll('"', '""')}"`;
13
+ const quoted = quotedSchema(schema);
13
14
  // Native failure settlement deletes and reinserts jobs, so a cascading job foreign key would erase the counter.
14
15
  const { rows } = await client.getDb().executeSql(
15
16
  `WITH claimed AS MATERIALIZED (
@@ -0,0 +1,17 @@
1
+ import { Effect, Redacted, Schema } from "effect";
2
+ import type { JobPayloadSchema, QueueDefinition } from "#definition.ts";
3
+ import { PgBossPayloadError } from "#error.ts";
4
+ import { payloadOf } from "#internal/tracing.ts";
5
+
6
+ export function quotedSchema(schema: string): string {
7
+ return `"${schema.replaceAll('"', '""')}"`;
8
+ }
9
+
10
+ export function decodeStoredData<const TName extends string, const TPayload extends JobPayloadSchema>(
11
+ queue: Pick<QueueDefinition<TName, TPayload>, "name" | "schema">,
12
+ data: unknown,
13
+ ): Effect.Effect<TPayload["Type"], PgBossPayloadError, TPayload["DecodingServices"]> {
14
+ return Schema.decodeUnknownEffect(queue.schema)(payloadOf(data)).pipe(
15
+ Effect.mapError((error) => new PgBossPayloadError({ direction: "decode", original: Redacted.make(error), queue: queue.name })),
16
+ );
17
+ }
@@ -4,16 +4,25 @@ import { deferralObservers } from "#internal/deferral.ts";
4
4
  import { expireAttempt } from "#internal/expireAttempt.ts";
5
5
  import { observeTestingSettlements } from "#internal/observeTestingSettlements.ts";
6
6
  import { testingExpirations } from "#internal/testingExpirations.ts";
7
+ import type { TestingBatch, TestingWork } from "#internal/testingIdle.ts";
7
8
 
8
9
  export interface TestingControls {
9
10
  readonly changed: () => void;
10
11
  readonly expirations: Map<string, number>;
11
12
  readonly failures: Set<string>;
12
13
  readonly retryPolicy: Pick<SendOptions, "retryBackoff" | "retryDelay">;
14
+ readonly work: TestingWork;
13
15
  }
14
16
 
15
17
  export function testingClient(client: PgBoss, schema: string, controls: TestingControls): PgBossClient {
16
- const settlements = observeTestingSettlements(client, controls.changed);
18
+ const settlements = observeTestingSettlements(client, (name) => {
19
+ for (const batch of controls.work.batches) {
20
+ if (batch.name === name) {
21
+ batch.settled = true;
22
+ }
23
+ }
24
+ controls.changed();
25
+ });
17
26
  const expirations = testingExpirations(client);
18
27
  function update(request: UpdateRequest): Promise<UpdateResponse>;
19
28
  function update(name: string, data: object | null | undefined, options?: UpdateOptions): Promise<UpdateResponse>;
@@ -82,7 +91,10 @@ export function testingClient(client: PgBoss, schema: string, controls: TestingC
82
91
  }),
83
92
  work: (name, options, handler) => {
84
93
  settlements.watch(name);
94
+ controls.work.queues.add(name);
85
95
  return client.work(name, options, async (jobs: Job<unknown>[]) => {
96
+ const batch: TestingBatch = { name, settled: false };
97
+ controls.work.batches.add(batch);
86
98
  controls.changed();
87
99
  try {
88
100
  const ready: Job<unknown>[] = [];
@@ -98,6 +110,7 @@ export function testingClient(client: PgBoss, schema: string, controls: TestingC
98
110
  await handler(ready);
99
111
  }
100
112
  } finally {
113
+ controls.work.batches.delete(batch);
101
114
  controls.changed();
102
115
  }
103
116
  });
@@ -2,6 +2,7 @@ import { DateTime } from "effect";
2
2
  import type { PgBoss } from "pg-boss";
3
3
  import type { QueueDefinition } from "#definition.ts";
4
4
  import { deferralOf } from "#internal/deferral.ts";
5
+ import { quotedSchema } from "#internal/storedRows.ts";
5
6
  import { testingOperation } from "#internal/testingReadout.ts";
6
7
 
7
8
  export function testingDeferrals(client: () => PgBoss, schema: () => string, changed: () => void) {
@@ -22,11 +23,11 @@ export function testingDeferrals(client: () => PgBoss, schema: () => string, cha
22
23
  testingOperation(
23
24
  "runDeferredNow",
24
25
  async () => {
25
- const quotedSchema = `"${schema().replaceAll('"', '""')}"`;
26
+ const quoted = quotedSchema(schema());
26
27
  await client()
27
28
  .getDb()
28
29
  .executeSql(
29
- `UPDATE ${quotedSchema}.job SET start_after = ${quotedSchema}.job_now()
30
+ `UPDATE ${quoted}.job SET start_after = ${quoted}.job_now()
30
31
  WHERE name = $1 AND state = 'retry' AND output ? 'jobDeferral' AND started_on IS NULL`,
31
32
  [queue.name],
32
33
  );
@@ -0,0 +1,52 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { PgBoss } from "pg-boss";
3
+ import type { PgBossTestingError } from "#error.ts";
4
+ import { quotedSchema } from "#internal/storedRows.ts";
5
+ import { testingOperation } from "#internal/testingReadout.ts";
6
+
7
+ export interface TestingBatch {
8
+ readonly name: string;
9
+ settled: boolean;
10
+ }
11
+
12
+ export interface TestingWork {
13
+ readonly batches: Set<TestingBatch>;
14
+ readonly queues: Set<string>;
15
+ }
16
+
17
+ const Busy = Schema.Struct({ busy: Schema.Boolean });
18
+
19
+ export function testingIdle(
20
+ client: () => PgBoss,
21
+ schema: () => string,
22
+ work: TestingWork,
23
+ signal: { readonly revision: Effect.Effect<number>; readonly waitForChange: (after: number) => Effect.Effect<number> },
24
+ ): Effect.Effect<void, PgBossTestingError> {
25
+ const busy = testingOperation("waitForIdle", async () => {
26
+ const quoted = quotedSchema(schema());
27
+ const { rows } = await client()
28
+ .getDb()
29
+ .executeSql(
30
+ `/* effect-pg-boss testing idle */ SELECT EXISTS (
31
+ SELECT 1 FROM ${quoted}.job
32
+ WHERE name = ANY($1::text[]) AND (
33
+ (state < 'active' AND NOT blocked AND start_after <= ${quoted}.job_now())
34
+ OR state = 'active'
35
+ )
36
+ ) AS busy`,
37
+ [[...work.queues]],
38
+ );
39
+ return (await Schema.decodeUnknownPromise(Busy)(rows[0])).busy;
40
+ });
41
+ return Effect.gen(function* () {
42
+ while (true) {
43
+ const revision = yield* signal.revision;
44
+ // A batch with no settlement since its fetch still holds active jobs, so it is busy without a read.
45
+ const unsettled = [...work.batches].some((batch) => !batch.settled);
46
+ if (work.queues.size === 0 || !(unsettled || (yield* busy))) {
47
+ return;
48
+ }
49
+ yield* signal.waitForChange(revision);
50
+ }
51
+ });
52
+ }
@@ -1,8 +1,8 @@
1
- import { Effect, Redacted, Schema } from "effect";
1
+ import { Effect, Redacted } from "effect";
2
2
  import type { FindJobsOptions, PgBoss } from "pg-boss";
3
3
  import type { JobPayloadSchema, QueueDefinition } from "#definition.ts";
4
- import { PgBossPayloadError, PgBossTestingError } from "#error.ts";
5
- import { payloadOf } from "#internal/tracing.ts";
4
+ import { PgBossTestingError } from "#error.ts";
5
+ import { decodeStoredData } from "#internal/storedRows.ts";
6
6
 
7
7
  export function testingOperation<A>(operation: string, run: () => Promise<A>, queue?: string) {
8
8
  return Effect.tryPromise({
@@ -14,13 +14,6 @@ export function testingOperation<A>(operation: string, run: () => Promise<A>, qu
14
14
  export function testingReadout(client: () => PgBoss) {
15
15
  return <TName extends string, TPayload extends JobPayloadSchema>(queue: QueueDefinition<TName, TPayload>, options?: FindJobsOptions) =>
16
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
- ),
17
+ Effect.flatMap((jobs) => Effect.forEach(jobs, (job) => decodeStoredData(queue, job.data).pipe(Effect.map((data) => ({ ...job, data }))))),
25
18
  );
26
19
  }
@@ -6,10 +6,12 @@ export function testingSignal() {
6
6
  return {
7
7
  changed() {
8
8
  revision += 1;
9
- for (const listener of listeners) {
9
+ // A resumed waiter can wait again synchronously; it must wait for the next change, not this one.
10
+ const waiting = [...listeners];
11
+ listeners.clear();
12
+ for (const listener of waiting) {
10
13
  listener(revision);
11
14
  }
12
- listeners.clear();
13
15
  },
14
16
  revision: Effect.sync(() => revision),
15
17
  waitForChange: (after: number): Effect.Effect<number> =>
@@ -0,0 +1,53 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { PgBoss } from "pg-boss";
3
+ import type { PgBossPayloadError, PgBossTestingError } from "#error.ts";
4
+ import { decodeStoredData, quotedSchema } from "#internal/storedRows.ts";
5
+ import { testingOperation } from "#internal/testingReadout.ts";
6
+ import type { SnapshotQueue, UnfinishedJob } from "#testing.ts";
7
+
8
+ const Rows = Schema.Array(
9
+ Schema.Struct({
10
+ data: Schema.Unknown,
11
+ id: Schema.String,
12
+ queue: Schema.String,
13
+ startAfter: Schema.DateTimeUtcFromDate,
14
+ state: Schema.Literals(["created", "retry", "active", "failed"]),
15
+ }),
16
+ );
17
+
18
+ export function testingSnapshot(client: () => PgBoss, schema: () => string) {
19
+ return <const TQueues extends readonly SnapshotQueue[]>(
20
+ queues: TQueues,
21
+ ): Effect.Effect<
22
+ readonly UnfinishedJob<TQueues[number]>[],
23
+ PgBossPayloadError | PgBossTestingError,
24
+ TQueues[number]["schema"]["DecodingServices"]
25
+ > => {
26
+ const byName = new Map<string, TQueues[number]>(queues.map((queue) => [queue.name, queue]));
27
+ return testingOperation("snapshot", async () => {
28
+ const quoted = quotedSchema(schema());
29
+ const { rows } = await client()
30
+ .getDb()
31
+ .executeSql(
32
+ `SELECT id, name AS queue, state, data, start_after AS "startAfter"
33
+ FROM ${quoted}.job
34
+ WHERE name = ANY($1::text[]) AND state IN ('created', 'retry', 'active', 'failed')
35
+ ORDER BY created_on, id`,
36
+ [[...byName.keys()]],
37
+ );
38
+ return Schema.decodeUnknownPromise(Rows)(rows);
39
+ }).pipe(
40
+ Effect.flatMap((rows) =>
41
+ Effect.forEach(rows, ({ data, queue: name, ...row }) => {
42
+ const queue = byName.get(name);
43
+ if (queue === undefined) {
44
+ return Effect.die(new Error(`The snapshot returned a job of an unrequested queue ${name}`));
45
+ }
46
+ return decodeStoredData<TQueues[number]["name"], TQueues[number]["schema"]>(queue, data).pipe(
47
+ Effect.map((decoded): UnfinishedJob<TQueues[number]> => ({ ...row, data: decoded, queue: queue.name })),
48
+ );
49
+ }),
50
+ ),
51
+ );
52
+ };
53
+ }
@@ -3,7 +3,7 @@ import type { ConstructorOptions, JobWithMetadata, SendOptions, StopOptions } fr
3
3
  import type { PgBossClientFactory } from "#client.ts";
4
4
  import type { JobPayloadSchema, JobRegistration, QueueDefinition, RegistrationRequirements } from "#definition.ts";
5
5
  import type { PgBossError, PgBossPayloadError, PgBossQueueOptionsError } from "#error.ts";
6
- import type { JobsHealth, QueueCounts } from "#health.ts";
6
+ import type { DeadLetteredJob, JobPage, JobPageOptions, JobsHealth, ListedJob, QueueCounts } from "#health.ts";
7
7
  import { logClientError, subscribeClientErrors } from "#internal/clientErrors.ts";
8
8
  import { acquireClient, releaseClient } from "#internal/lifecycle.ts";
9
9
  import { registerJobs, registrationName } from "#internal/register.ts";
@@ -11,11 +11,19 @@ import { makeService } from "#internal/service.ts";
11
11
 
12
12
  export interface PgBossService {
13
13
  readonly counts: (queue: Pick<QueueDefinition, "name">) => Effect.Effect<QueueCounts, PgBossError>;
14
+ readonly deadLetteredJobs: <const TName extends string, const TPayload extends JobPayloadSchema>(
15
+ queue: QueueDefinition<TName, TPayload>,
16
+ options: JobPageOptions,
17
+ ) => Effect.Effect<JobPage<DeadLetteredJob<TPayload["Type"]>>, PgBossError | PgBossPayloadError, TPayload["DecodingServices"]>;
14
18
  readonly enqueue: <const TName extends string, const TPayload extends JobPayloadSchema, const TQueue extends QueueDefinition<TName, TPayload>>(
15
19
  queue: QueueDefinition<TName, TPayload> & TQueue,
16
20
  payload: NoInfer<Schema.Schema.Type<TPayload>>,
17
21
  options?: NoInfer<TQueue["key"] extends undefined ? SendOptions : Omit<SendOptions, "singletonKey"> & { readonly singletonKey?: never }>,
18
22
  ) => Effect.Effect<Option.Option<string>, PgBossError | PgBossPayloadError, TPayload["EncodingServices"]>;
23
+ readonly failedJobs: <const TName extends string, const TPayload extends JobPayloadSchema>(
24
+ queue: QueueDefinition<TName, TPayload>,
25
+ options: JobPageOptions,
26
+ ) => Effect.Effect<JobPage<ListedJob<TPayload["Type"]>>, PgBossError | PgBossPayloadError, TPayload["DecodingServices"]>;
19
27
  readonly getJob: <const TName extends string, const TPayload extends JobPayloadSchema>(
20
28
  queue: QueueDefinition<TName, TPayload>,
21
29
  id: string,
@@ -1,12 +1,24 @@
1
- import { Effect, Redacted } from "effect";
1
+ import { type DateTime, Effect, Redacted } from "effect";
2
2
  import { PgBoss } from "pg-boss";
3
3
  import type { PgBossClientFactory } from "#client.ts";
4
- import type { QueueDefinition } from "#definition.ts";
4
+ import type { JobPayloadSchema, QueueDefinition } from "#definition.ts";
5
5
  import { PgBossTestingError } from "#error.ts";
6
6
  import { testingClient } from "#internal/testingClient.ts";
7
7
  import { testingDeferrals } from "#internal/testingDeferrals.ts";
8
+ import { type TestingWork, testingIdle } from "#internal/testingIdle.ts";
8
9
  import { testingOperation, testingReadout } from "#internal/testingReadout.ts";
9
10
  import { testingSignal } from "#internal/testingSignal.ts";
11
+ import { testingSnapshot } from "#internal/testingSnapshot.ts";
12
+
13
+ export type SnapshotQueue = Pick<QueueDefinition<string, JobPayloadSchema>, "name" | "schema">;
14
+
15
+ export interface UnfinishedJob<TQueue extends SnapshotQueue> {
16
+ readonly data: TQueue["schema"]["Type"];
17
+ readonly id: string;
18
+ readonly queue: TQueue["name"];
19
+ readonly startAfter: DateTime.Utc;
20
+ readonly state: "active" | "created" | "failed" | "retry";
21
+ }
10
22
 
11
23
  export const testRetryPolicy = Object.freeze({ retryBackoff: false, retryDelay: 0 });
12
24
 
@@ -18,11 +30,13 @@ export function makePgBossTesting(options: PgBossTestingOptions = {}) {
18
30
  let current: PgBoss | undefined;
19
31
  let schema = "pgboss";
20
32
  const signal = testingSignal();
33
+ const work: TestingWork = { batches: new Set(), queues: new Set() };
21
34
  const controls = {
22
35
  changed: signal.changed,
23
36
  expirations: new Map<string, number>(),
24
37
  failures: new Set<string>(),
25
38
  retryPolicy: options.retryPolicy ?? {},
39
+ work,
26
40
  };
27
41
  function client(): PgBoss {
28
42
  if (current === undefined) {
@@ -82,7 +96,9 @@ export function makePgBossTesting(options: PgBossTestingOptions = {}) {
82
96
  jobs: testingReadout(client),
83
97
  revision: signal.revision,
84
98
  schemaVersion: testingOperation("schemaVersion", () => client().schemaVersion()),
99
+ snapshot: testingSnapshot(client, () => schema),
85
100
  waitForChange: signal.waitForChange,
101
+ waitForIdle: testingIdle(client, () => schema, work, signal),
86
102
  };
87
103
  }
88
104