@shivaedev/jobs 0.22.0 → 0.23.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 (94) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +166 -172
  3. package/dist/JobFailure.d.ts +7 -0
  4. package/dist/JobFailure.d.ts.map +1 -0
  5. package/dist/JobFailure.js +8 -0
  6. package/dist/JobFailure.js.map +1 -0
  7. package/dist/server/PgBoss.d.ts +11 -0
  8. package/dist/server/PgBoss.d.ts.map +1 -0
  9. package/dist/server/PgBoss.js +4 -0
  10. package/dist/server/PgBoss.js.map +1 -0
  11. package/dist/server/client.d.ts +1 -1
  12. package/dist/server/client.d.ts.map +1 -1
  13. package/dist/server/health.d.ts +3 -2
  14. package/dist/server/health.d.ts.map +1 -1
  15. package/dist/server/health.js.map +1 -1
  16. package/dist/server/internal/batchHandler.d.ts +2 -1
  17. package/dist/server/internal/batchHandler.d.ts.map +1 -1
  18. package/dist/server/internal/batchHandler.js +49 -31
  19. package/dist/server/internal/batchHandler.js.map +1 -1
  20. package/dist/server/internal/clientErrors.d.ts.map +1 -1
  21. package/dist/server/internal/clientErrors.js +2 -1
  22. package/dist/server/internal/clientErrors.js.map +1 -1
  23. package/dist/server/internal/deferrableAttempt.d.ts +1 -1
  24. package/dist/server/internal/deferrableAttempt.d.ts.map +1 -1
  25. package/dist/server/internal/deferrableAttempt.js +5 -1
  26. package/dist/server/internal/deferrableAttempt.js.map +1 -1
  27. package/dist/server/internal/ensureQueue.d.ts +2 -1
  28. package/dist/server/internal/ensureQueue.d.ts.map +1 -1
  29. package/dist/server/internal/ensureQueue.js +16 -21
  30. package/dist/server/internal/ensureQueue.js.map +1 -1
  31. package/dist/server/internal/failureOutput.d.ts +4 -0
  32. package/dist/server/internal/failureOutput.d.ts.map +1 -0
  33. package/dist/server/internal/failureOutput.js +9 -0
  34. package/dist/server/internal/failureOutput.js.map +1 -0
  35. package/dist/server/internal/lifecycle.d.ts +1 -1
  36. package/dist/server/internal/lifecycle.d.ts.map +1 -1
  37. package/dist/server/internal/lifecycle.js +10 -16
  38. package/dist/server/internal/lifecycle.js.map +1 -1
  39. package/dist/server/internal/listJobs.d.ts.map +1 -1
  40. package/dist/server/internal/listJobs.js +44 -39
  41. package/dist/server/internal/listJobs.js.map +1 -1
  42. package/dist/server/internal/makeService.js +1 -1
  43. package/dist/server/internal/makeService.js.map +1 -1
  44. package/dist/server/internal/register.d.ts +4 -3
  45. package/dist/server/internal/register.d.ts.map +1 -1
  46. package/dist/server/internal/register.js +34 -48
  47. package/dist/server/internal/register.js.map +1 -1
  48. package/dist/server/internal/registerReleasedJobs.d.ts +2 -1
  49. package/dist/server/internal/registerReleasedJobs.d.ts.map +1 -1
  50. package/dist/server/internal/registerReleasedJobs.js +3 -3
  51. package/dist/server/internal/registerReleasedJobs.js.map +1 -1
  52. package/dist/server/internal/registerWorker.d.ts +3 -2
  53. package/dist/server/internal/registerWorker.d.ts.map +1 -1
  54. package/dist/server/internal/registerWorker.js +26 -33
  55. package/dist/server/internal/registerWorker.js.map +1 -1
  56. package/dist/server/internal/retryWake.d.ts +4 -0
  57. package/dist/server/internal/retryWake.d.ts.map +1 -0
  58. package/dist/server/internal/retryWake.js +32 -0
  59. package/dist/server/internal/retryWake.js.map +1 -0
  60. package/dist/server/internal/runQueueWorker.d.ts +5 -0
  61. package/dist/server/internal/runQueueWorker.d.ts.map +1 -0
  62. package/dist/server/internal/runQueueWorker.js +12 -0
  63. package/dist/server/internal/runQueueWorker.js.map +1 -0
  64. package/dist/server/internal/schemaIndexBuilds.d.ts.map +1 -1
  65. package/dist/server/internal/schemaIndexBuilds.js +5 -2
  66. package/dist/server/internal/schemaIndexBuilds.js.map +1 -1
  67. package/dist/server/schema.d.ts.map +1 -1
  68. package/dist/server/schema.js +16 -5
  69. package/dist/server/schema.js.map +1 -1
  70. package/dist/server/service.d.ts +13 -8
  71. package/dist/server/service.d.ts.map +1 -1
  72. package/dist/server/service.js +53 -22
  73. package/dist/server/service.js.map +1 -1
  74. package/package.json +16 -10
  75. package/src/JobFailure.ts +9 -0
  76. package/src/server/PgBoss.ts +8 -0
  77. package/src/server/client.ts +1 -1
  78. package/src/server/health.ts +3 -2
  79. package/src/server/internal/batchHandler.ts +61 -42
  80. package/src/server/internal/clientErrors.ts +2 -1
  81. package/src/server/internal/deferrableAttempt.ts +5 -1
  82. package/src/server/internal/ensureQueue.ts +18 -20
  83. package/src/server/internal/failureOutput.ts +9 -0
  84. package/src/server/internal/lifecycle.ts +13 -16
  85. package/src/server/internal/listJobs.ts +57 -47
  86. package/src/server/internal/makeService.ts +1 -1
  87. package/src/server/internal/register.ts +68 -66
  88. package/src/server/internal/registerReleasedJobs.ts +8 -3
  89. package/src/server/internal/registerWorker.ts +35 -46
  90. package/src/server/internal/retryWake.ts +33 -0
  91. package/src/server/internal/runQueueWorker.ts +19 -0
  92. package/src/server/internal/schemaIndexBuilds.ts +6 -2
  93. package/src/server/schema.ts +22 -6
  94. package/src/server/service.ts +84 -49
@@ -4,7 +4,7 @@ import { defaultClientFactory, type PgBossClient, type PgBossClientFactory } fro
4
4
  import { PgBossClientOptionsError, type PgBossError, toPgBossError } from "#error.ts";
5
5
  import { deferralStorePlan } from "#internal/deferralStorePlan.ts";
6
6
  import { acquireCachedClient, releaseCachedClient } from "./clientCache.ts";
7
- import { logClientError, observeClientErrors } from "./clientErrors.ts";
7
+ import { observeClientErrors } from "./clientErrors.ts";
8
8
 
9
9
  export interface AcquireClientOptions {
10
10
  readonly clientCacheKey?: string | symbol | undefined;
@@ -37,21 +37,18 @@ async function startClient(options: AcquireClientOptions, report: (error: Error)
37
37
  }
38
38
  }
39
39
 
40
- export function acquireClient(options: AcquireClientOptions): Effect.Effect<AcquiredClient, PgBossError | PgBossClientOptionsError> {
41
- return Effect.contextWith((context) => {
42
- const run = Effect.runForkWith(context);
43
- function report(error: Error) {
44
- run(logClientError(error));
45
- }
46
- return Effect.tryPromise({
47
- catch: (error) => (error instanceof PgBossClientOptionsError ? error : toPgBossError("start", error)),
48
- try: async () => {
49
- const cacheKey = options.clientCacheKey;
50
- return cacheKey === undefined
51
- ? { client: await startClient(options, report), reused: false }
52
- : { cacheKey, ...(await acquireCachedClient(cacheKey, options.constructor, () => startClient(options, report))) };
53
- },
54
- });
40
+ export function acquireClient(
41
+ options: AcquireClientOptions,
42
+ report: (error: Error) => void,
43
+ ): Effect.Effect<AcquiredClient, PgBossError | PgBossClientOptionsError> {
44
+ return Effect.tryPromise({
45
+ catch: (error) => (error instanceof PgBossClientOptionsError ? error : toPgBossError("start", error)),
46
+ try: async () => {
47
+ const cacheKey = options.clientCacheKey;
48
+ return cacheKey === undefined
49
+ ? { client: await startClient(options, report), reused: false }
50
+ : { cacheKey, ...(await acquireCachedClient(cacheKey, options.constructor, () => startClient(options, report))) };
51
+ },
55
52
  });
56
53
  }
57
54
 
@@ -1,12 +1,26 @@
1
- import { Effect, Option, Schema } from "effect";
1
+ import { Effect, Option, Redacted, Schema } from "effect";
2
2
  import type { PgBossClient } from "#client.ts";
3
3
  import type { JobPayloadSchema } from "#definition.ts";
4
- import { type PgBossError, type PgBossPayloadError, toPgBossError } from "#error.ts";
4
+ import { type PgBossError, PgBossPayloadError, toPgBossError } from "#error.ts";
5
5
  import { type DeadLetteredJob, deadLetterQueueName, type JobPage, type JobPageOptions } from "#health.ts";
6
6
  import { decodeStoredData, quotedSchema } from "#internal/storedRows.ts";
7
+ import { JobFailure } from "#JobFailure.ts";
7
8
  import type { PgBossService } from "#service.ts";
8
9
 
9
- const Cursor = Schema.StringFromBase64Url.pipe(Schema.decodeTo(Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.String]))));
10
+ const Cursor = Schema.StringFromBase64Url.pipe(
11
+ Schema.decodeTo(
12
+ Schema.fromJsonString(
13
+ Schema.Tuple([
14
+ Schema.String.check(Schema.makeFilter((value) => Option.isSome(Schema.decodeUnknownOption(Schema.DateTimeUtcFromString)(value)))),
15
+ Schema.String.check(Schema.isUUID()),
16
+ ]),
17
+ ),
18
+ ),
19
+ );
20
+ const PageOptions = Schema.Struct({
21
+ cursor: Schema.optionalKey(Schema.UndefinedOr(Cursor)),
22
+ limit: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)),
23
+ });
10
24
 
11
25
  const Row = Schema.Struct({
12
26
  completedOn: Schema.OptionFromNullOr(Schema.DateTimeUtcFromDate),
@@ -14,12 +28,12 @@ const Row = Schema.Struct({
14
28
  cursorAt: Schema.String,
15
29
  data: Schema.Unknown,
16
30
  id: Schema.String,
17
- output: Schema.Unknown,
31
+ output: Schema.NullOr(JobFailure),
18
32
  queue: Schema.String,
19
33
  retryCount: Schema.Number,
20
34
  retryLimit: Schema.Number,
21
35
  sourceId: Schema.OptionFromNullOr(Schema.String),
22
- sourceOutput: Schema.Unknown,
36
+ sourceOutput: Schema.NullOr(JobFailure),
23
37
  startedOn: Schema.OptionFromNullOr(Schema.DateTimeUtcFromDate),
24
38
  state: Schema.Literals(["created", "retry", "active", "completed", "cancelled", "failed"]),
25
39
  });
@@ -39,60 +53,56 @@ export function deadLetteredJobsFor(client: PgBossClient, schema: string): PgBos
39
53
  return (queue, options) => listJobs(client, schema, "deadLetteredJobs", queue, options);
40
54
  }
41
55
 
42
- function listJobs<const TName extends string, const TPayload extends JobPayloadSchema | typeof Schema.Null>(
56
+ const listJobs = Effect.fnUntraced(function* <const TName extends string, const TPayload extends JobPayloadSchema | typeof Schema.Null>(
43
57
  client: PgBossClient,
44
58
  schema: string,
45
59
  listing: Listing,
46
60
  queue: { readonly name: TName; readonly schema: TPayload },
47
61
  options: JobPageOptions,
48
- ): Effect.Effect<JobPage<DeadLetteredJob<TPayload["Type"]>>, PgBossError | PgBossPayloadError, TPayload["DecodingServices"]> {
49
- return Effect.tryPromise({
62
+ ): Effect.fn.Return<JobPage<DeadLetteredJob<TPayload["Type"]>>, PgBossError | PgBossPayloadError | Schema.SchemaError, TPayload["DecodingServices"]> {
63
+ const { cursor: after, limit } = yield* Schema.decodeUnknownEffect(PageOptions)(options);
64
+ const rows = yield* Effect.tryPromise({
50
65
  catch: (error) => toPgBossError(listing, error, queue.name),
51
66
  try: async () => {
52
67
  if (client.getDb === undefined) {
53
68
  throw new TypeError("This pg-boss client does not support getDb");
54
69
  }
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
70
  const quoted = quotedSchema(schema);
60
- const { rows } = await client.getDb().executeSql(
71
+ const { rows: storedRows } = await client.getDb().executeSql(
61
72
  `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],
73
+ id,
74
+ name AS queue,
75
+ state,
76
+ retry_count AS "retryCount",
77
+ retry_limit AS "retryLimit",
78
+ created_on AS "createdOn",
79
+ started_on AS "startedOn",
80
+ completed_on AS "completedOn",
81
+ output,
82
+ data,
83
+ source_id AS "sourceId",
84
+ source_output AS "sourceOutput",
85
+ to_json(created_on) #>> '{}' AS "cursorAt"
86
+ FROM ${quoted}.job
87
+ WHERE name = $1
88
+ ${listing === "failedJobs" ? "AND state = 'failed'" : ""}
89
+ AND ($2::timestamptz IS NULL OR (created_on, id) < ($2::timestamptz, $3::uuid))
90
+ ORDER BY created_on DESC, id DESC
91
+ LIMIT $4`,
92
+ [listing === "failedJobs" ? queue.name : deadLetterQueueName(queue.name), after?.[0] ?? null, after?.[1] ?? null, limit + 1],
82
93
  );
83
- return Schema.decodeUnknownPromise(Rows)(rows);
94
+ return storedRows;
84
95
  },
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
- }),
96
+ });
97
+ const decodedRows = yield* Schema.decodeUnknownEffect(Rows)(rows).pipe(
98
+ Effect.mapError((error) => new PgBossPayloadError({ direction: "decode", original: Redacted.make(error), queue: queue.name })),
97
99
  );
98
- }
100
+ const page = decodedRows.slice(0, limit);
101
+ const last = page.at(-1);
102
+ const next = decodedRows.length > limit && last !== undefined ? Option.some(Schema.encodeSync(Cursor)([last.cursorAt, last.id])) : Option.none();
103
+ return yield* Effect.forEach(page, ({ cursorAt: _, data, ...row }) =>
104
+ decodeStoredData(queue, data).pipe(
105
+ Effect.map((decoded) => ({ ...row, attempt: Option.isSome(row.startedOn) ? row.retryCount + 1 : 0, data: decoded })),
106
+ ),
107
+ ).pipe(Effect.map((jobs) => ({ jobs, next })));
108
+ });
@@ -13,7 +13,7 @@ export function makeService(client: PgBossClient, names: readonly string[], sche
13
13
  enqueue: enqueueFor(client),
14
14
  failedJobs: failedJobsFor(client, schema),
15
15
  getJob: getJobFor(client),
16
- health: healthFor(client, names),
16
+ health: () => healthFor(client, names),
17
17
  retry: retryFor(client),
18
18
  };
19
19
  }
@@ -1,18 +1,11 @@
1
- import { type Context, Effect, Redacted, Schema, type Scope } from "effect";
2
- import type { Job } from "pg-boss";
1
+ import { type Context, Effect, type Scope } from "effect";
3
2
  import type { PgBossClient } from "#client.ts";
4
- import {
5
- type JobRegistration,
6
- jobContext,
7
- type QueueDefinition,
8
- type QueueWorker,
9
- type ScheduleDefinition,
10
- type ScheduledWorker,
11
- } from "#definition.ts";
12
- import { PgBossError, PgBossPayloadError, PgBossQueueOptionsError, toPgBossError } from "#error.ts";
3
+ import type { JobRegistration, QueueDefinition, QueueWorker, ScheduleDefinition, ScheduledWorker } from "#definition.ts";
4
+ import { PgBossQueueOptionsError, toPgBossError } from "#error.ts";
5
+ import type { PgBossRunner } from "#internal/batchHandler.ts";
13
6
  import { ensureQueue } from "#internal/ensureQueue.ts";
14
7
  import { registerWorker } from "#internal/registerWorker.ts";
15
- import { payloadOf } from "#internal/tracing.ts";
8
+ import { runQueueWorker } from "#internal/runQueueWorker.ts";
16
9
 
17
10
  function isQueueWorker(registration: JobRegistration): registration is QueueWorker {
18
11
  return registration._tag === "QueueWorker";
@@ -43,21 +36,19 @@ export function registrationName(registration: JobRegistration): string {
43
36
  return registrationDefinition(registration).name;
44
37
  }
45
38
 
46
- async function prepareQueues(client: PgBossClient, registrations: readonly JobRegistration[], replaceWorkers: boolean): Promise<void> {
39
+ const prepareQueues = Effect.fnUntraced(function* (client: PgBossClient, registrations: readonly JobRegistration[], replaceWorkers: boolean) {
47
40
  for (const registration of registrations) {
48
41
  if (replaceWorkers && !isQueueDefinition(registration)) {
49
- await client.offWork(registrationName(registration));
42
+ yield* Effect.tryPromise({ catch: (error) => toPgBossError("register", error), try: () => client.offWork(registrationName(registration)) });
50
43
  }
51
44
  }
52
45
  for (const registration of registrations) {
53
46
  const definition = registrationDefinition(registration);
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
- }
47
+ yield* ensureQueue(client, definition.name, definition.queueOptions).pipe(
48
+ Effect.mapError((error) => (error instanceof PgBossQueueOptionsError ? error : toPgBossError("register", error, definition.name))),
49
+ );
59
50
  }
60
- }
51
+ });
61
52
 
62
53
  // The layer requires RegistrationRequirements of its jobs, so its context provides every registered worker.
63
54
  function workerContext<R>(context: Context.Context<R>): Context.Context<unknown>;
@@ -65,46 +56,61 @@ function workerContext(context: unknown): unknown {
65
56
  return context;
66
57
  }
67
58
 
68
- function runQueueWorker(worker: QueueWorker, job: Job<unknown>) {
69
- return Schema.decodeUnknownEffect(worker.queue.schema)(payloadOf(job.data)).pipe(
70
- Effect.mapError(
71
- (error) =>
72
- new PgBossPayloadError({
73
- direction: "decode",
74
- original: Redacted.make(error),
75
- queue: worker.queue.name,
76
- }),
77
- ),
78
- Effect.flatMap((payload) => worker.handler(payload, jobContext(job))),
79
- );
80
- }
81
-
82
- async function registerQueue(
59
+ const registerQueue = Effect.fnUntraced(function* (
83
60
  client: PgBossClient,
84
61
  registration: QueueWorker | QueueDefinition,
85
62
  context: Context.Context<unknown>,
86
63
  scope: Scope.Scope,
87
64
  schema: string,
88
- ): Promise<void> {
65
+ execute: PgBossRunner,
66
+ graceful: boolean,
67
+ ) {
89
68
  const queue = registration._tag === "QueueWorker" ? registration.queue : registration;
90
69
  const name = queue.name;
91
70
  if (registration._tag === "QueueDefinition") {
92
71
  return;
93
72
  }
94
- await registerWorker(client, name, queue.workerOptions, (job) => runQueueWorker(registration, job), scope, schema, queue.maxDeferrals, context);
95
- }
73
+ yield* registerWorker(
74
+ client,
75
+ name,
76
+ queue.workerOptions,
77
+ (job) => runQueueWorker(registration, job),
78
+ scope,
79
+ schema,
80
+ queue.maxDeferrals,
81
+ context,
82
+ execute,
83
+ graceful,
84
+ );
85
+ });
96
86
 
97
- async function registerSchedule(
87
+ const registerSchedule = Effect.fnUntraced(function* (
98
88
  client: PgBossClient,
99
89
  worker: ScheduledWorker,
100
90
  context: Context.Context<unknown>,
101
91
  scope: Scope.Scope,
102
92
  schema: string,
103
- ): Promise<void> {
93
+ execute: PgBossRunner,
94
+ graceful: boolean,
95
+ ) {
104
96
  const { schedule } = worker;
105
- await registerWorker(client, schedule.name, schedule.workerOptions, () => worker.effect, scope, schema, schedule.maxDeferrals, context);
106
- await client.schedule(schedule.name, schedule.cron, null, schedule.scheduleOptions);
107
- }
97
+ yield* registerWorker(
98
+ client,
99
+ schedule.name,
100
+ schedule.workerOptions,
101
+ () => worker.effect,
102
+ scope,
103
+ schema,
104
+ schedule.maxDeferrals,
105
+ context,
106
+ execute,
107
+ graceful,
108
+ );
109
+ yield* Effect.tryPromise({
110
+ catch: (error) => toPgBossError("register", error, schedule.name),
111
+ try: () => client.schedule(schedule.name, schedule.cron, null, schedule.scheduleOptions),
112
+ });
113
+ });
108
114
 
109
115
  export function registrationNames(registrations: readonly JobRegistration[]): readonly string[] {
110
116
  const names = registrations.map(registrationName);
@@ -114,32 +120,28 @@ export function registrationNames(registrations: readonly JobRegistration[]): re
114
120
  return names;
115
121
  }
116
122
 
117
- export function registerJobs<R>(
123
+ export const registerJobs = Effect.fnUntraced(function* <R>(
118
124
  client: PgBossClient,
119
125
  registrations: readonly JobRegistration[],
120
126
  context: Context.Context<R>,
121
127
  replaceWorkers: boolean,
128
+ execute: PgBossRunner,
122
129
  schema = "pgboss",
123
130
  startWorkers = true,
124
- ): Effect.Effect<void, PgBossError | PgBossQueueOptionsError, Scope.Scope> {
125
- return Effect.flatMap(Effect.scope, (scope) =>
126
- Effect.tryPromise({
127
- catch: (error) => (error instanceof PgBossQueueOptionsError || error instanceof PgBossError ? error : toPgBossError("register", error)),
128
- try: async () => {
129
- registrationNames(registrations);
130
- await prepareQueues(client, registrations, replaceWorkers);
131
- if (!startWorkers) {
132
- return;
133
- }
134
- const provided = workerContext(context);
135
- for (const registration of registrations) {
136
- if (isQueueWorker(registration) || isQueueDefinition(registration)) {
137
- await registerQueue(client, registration, provided, scope, schema);
138
- } else if (isScheduledWorker(registration)) {
139
- await registerSchedule(client, registration, provided, scope, schema);
140
- }
141
- }
142
- },
143
- }),
144
- );
145
- }
131
+ graceful = true,
132
+ ) {
133
+ const scope = yield* Effect.scope;
134
+ yield* Effect.try({ catch: (error) => toPgBossError("register", error), try: () => registrationNames(registrations) });
135
+ yield* prepareQueues(client, registrations, replaceWorkers);
136
+ if (!startWorkers) {
137
+ return;
138
+ }
139
+ const provided = workerContext(context);
140
+ for (const registration of registrations) {
141
+ if (isQueueWorker(registration) || isQueueDefinition(registration)) {
142
+ yield* registerQueue(client, registration, provided, scope, schema, execute, graceful);
143
+ } else if (isScheduledWorker(registration)) {
144
+ yield* registerSchedule(client, registration, provided, scope, schema, execute, graceful);
145
+ }
146
+ }
147
+ });
@@ -3,6 +3,7 @@ import { WorkerRelease } from "@shivaedev/sql/WorkerRelease.ts";
3
3
  import type { PgBossClient } from "#client.ts";
4
4
  import type { JobRegistration } from "#definition.ts";
5
5
  import { toPgBossError } from "#error.ts";
6
+ import type { PgBossRunner } from "#internal/batchHandler.ts";
6
7
  import { registerJobs, registrationName } from "./register.ts";
7
8
 
8
9
  export const registerReleasedJobs = Effect.fnUntraced(function* <R>(
@@ -10,16 +11,20 @@ export const registerReleasedJobs = Effect.fnUntraced(function* <R>(
10
11
  jobs: readonly JobRegistration[],
11
12
  context: Context.Context<R>,
12
13
  replaceWorkers: boolean,
13
- schema?: string,
14
+ execute: PgBossRunner,
15
+ schema: string | undefined,
16
+ graceful: boolean,
14
17
  ) {
15
18
  const signal = yield* Effect.serviceOption(WorkerRelease);
16
19
  const workers = jobs.filter((job) => job._tag !== "QueueDefinition");
17
20
  const scope = yield* Effect.scope;
18
- const register = registerJobs(client, jobs, context, replaceWorkers, schema).pipe(Effect.provideService(Scope.Scope, scope));
21
+ const register = registerJobs(client, jobs, context, replaceWorkers, execute, schema, true, graceful).pipe(
22
+ Effect.provideService(Scope.Scope, scope),
23
+ );
19
24
  if (Option.isNone(signal) || workers.length === 0) {
20
25
  return yield* register;
21
26
  }
22
- yield* registerJobs(client, jobs, context, replaceWorkers, schema, false);
27
+ yield* registerJobs(client, jobs, context, replaceWorkers, execute, schema, false, graceful);
23
28
  let working = replaceWorkers;
24
29
  let paused = false;
25
30
  yield* signal.value.register((active) =>
@@ -1,11 +1,13 @@
1
- import { Clock, type Context, Effect, type Scope } from "effect";
1
+ import { type Context, Effect, Fiber, Scope } from "effect";
2
2
  import type { Job, WorkOptions } from "pg-boss";
3
3
  import type { PgBossClient } from "#client.ts";
4
- import { batchHandler } from "#internal/batchHandler.ts";
4
+ import { toPgBossError } from "#error.ts";
5
+ import { batchHandler, type PgBossRunner } from "#internal/batchHandler.ts";
5
6
  import { deferrableAttempt } from "#internal/deferrableAttempt.ts";
7
+ import { retryWake } from "#internal/retryWake.ts";
6
8
  import { traceJob } from "#internal/tracing.ts";
7
9
 
8
- export function registerWorker(
10
+ export const registerWorker = Effect.fnUntraced(function* (
9
11
  client: PgBossClient,
10
12
  name: string,
11
13
  options: WorkOptions,
@@ -14,47 +16,34 @@ export function registerWorker(
14
16
  schema: string,
15
17
  maxDeferrals: number,
16
18
  context: Context.Context<unknown>,
17
- ): Promise<string> {
18
- const registration = client.work(
19
- name,
20
- { ...options, includeMetadata: true },
21
- batchHandler(
22
- client,
23
- name,
24
- (job) => {
25
- const attempt = traceJob(deferrableAttempt(run(job), client, schema, job, maxDeferrals), job, name);
26
- return Effect.provide(attempt, context);
27
- },
28
- async (attempt) => {
29
- if (client.findJobs === undefined || client.notifyWorker === undefined) {
30
- return;
31
- }
32
- const job = (await client.findJobs(name, { id: attempt.id }))[0];
33
- if (job?.state !== "retry") {
34
- return;
35
- }
36
- const workId = await registration;
37
- await Effect.runPromise(
38
- Effect.gen(function* () {
39
- // Host timers can fire early; PostgreSQL also retains precision that Date drops.
40
- const deadline = job.startAfter.getTime() + 1;
41
- let remaining = deadline - (yield* Clock.currentTimeMillis);
42
- while (remaining > 0) {
43
- yield* Effect.sleep(remaining);
44
- remaining = deadline - (yield* Clock.currentTimeMillis);
45
- }
46
- const workerIds = client
47
- .getWipData?.()
48
- .filter((worker) => worker.workId === workId)
49
- .map((worker) => worker.id) ?? [workId];
50
- for (const workerId of workerIds) {
51
- client.notifyWorker?.(workerId);
52
- }
53
- }).pipe(Effect.forkIn(scope)),
54
- );
55
- },
56
- Effect.runPromiseWith(context),
57
- ),
19
+ execute: PgBossRunner,
20
+ graceful: boolean,
21
+ ) {
22
+ const fibers = yield* Scope.fork(scope);
23
+ function runJob(job: Job<unknown>) {
24
+ return Effect.provide(traceJob(deferrableAttempt(run(job), client, schema, job, maxDeferrals), job, name), context);
25
+ }
26
+ function afterFailure(attempt: Job<unknown>) {
27
+ return retryWake(client, name, attempt.id, registration).pipe(Effect.forkIn(fibers), Effect.asVoid);
28
+ }
29
+ const handle = yield* Effect.try({
30
+ catch: (error) => toPgBossError("register", error, name),
31
+ try: () => batchHandler(client, name, runJob, afterFailure),
32
+ });
33
+ const registration: Promise<string> = yield* Effect.try({
34
+ catch: (error) => toPgBossError("register", error, name),
35
+ try: () =>
36
+ client.work(name, { ...options, includeMetadata: true }, (jobs) =>
37
+ execute(handle(jobs).pipe(Effect.forkIn(fibers), Effect.flatMap(Fiber.join))),
38
+ ),
39
+ });
40
+ const id = yield* Effect.tryPromise({ catch: (error) => toPgBossError("register", error, name), try: () => registration });
41
+ yield* Scope.addFinalizer(
42
+ scope,
43
+ Effect.tryPromise({
44
+ catch: (error) => toPgBossError("stop", error, name),
45
+ try: () => client.offWork(name, { id, wait: graceful }),
46
+ }).pipe(Effect.catch(Effect.logError)),
58
47
  );
59
- return registration;
60
- }
48
+ return id;
49
+ });
@@ -0,0 +1,33 @@
1
+ import { Clock, Effect } from "effect";
2
+ import { redactDiagnosticCause } from "@shivaedev/errors/redactCause.ts";
3
+ import type { PgBossClient } from "#client.ts";
4
+ import { toPgBossError } from "#error.ts";
5
+
6
+ export function retryWake(client: PgBossClient, name: string, id: string, registration: Promise<string>): Effect.Effect<void> {
7
+ return Effect.gen(function* () {
8
+ const findJobs = client.findJobs?.bind(client);
9
+ if (findJobs === undefined || client.notifyWorker === undefined) {
10
+ return;
11
+ }
12
+ const jobs = yield* Effect.tryPromise({ catch: (error) => toPgBossError("settle", error, name), try: () => findJobs(name, { id }) });
13
+ const job = jobs[0];
14
+ if (job?.state !== "retry") {
15
+ return;
16
+ }
17
+ const workId = yield* Effect.tryPromise({ catch: (error) => toPgBossError("register", error, name), try: () => registration });
18
+ // Host timers can fire early; PostgreSQL retains precision that Date drops.
19
+ const deadline = job.startAfter.getTime() + 1;
20
+ let remaining = deadline - (yield* Clock.currentTimeMillis);
21
+ while (remaining > 0) {
22
+ yield* Effect.sleep(remaining);
23
+ remaining = deadline - (yield* Clock.currentTimeMillis);
24
+ }
25
+ const workerIds = client
26
+ .getWipData?.()
27
+ .filter((worker) => worker.workId === workId)
28
+ .map((worker) => worker.id) ?? [workId];
29
+ for (const workerId of workerIds) {
30
+ client.notifyWorker(workerId);
31
+ }
32
+ }).pipe(Effect.catchCause((cause) => Effect.logError(redactDiagnosticCause(cause))));
33
+ }
@@ -0,0 +1,19 @@
1
+ import { Effect, Redacted, Schema } from "effect";
2
+ import type { Job } from "pg-boss";
3
+ import { jobContext, type QueueWorker } from "#definition.ts";
4
+ import { PgBossPayloadError } from "#error.ts";
5
+ import { payloadOf } from "#internal/tracing.ts";
6
+
7
+ export function runQueueWorker(worker: QueueWorker, job: Job<unknown>) {
8
+ return Schema.decodeUnknownEffect(worker.queue.schema)(payloadOf(job.data)).pipe(
9
+ Effect.mapError(
10
+ (error) =>
11
+ new PgBossPayloadError({
12
+ direction: "decode",
13
+ original: Redacted.make(error),
14
+ queue: worker.queue.name,
15
+ }),
16
+ ),
17
+ Effect.flatMap((payload) => worker.handler(payload, jobContext(job))),
18
+ );
19
+ }
@@ -1,5 +1,9 @@
1
+ import { Schema } from "effect";
1
2
  import type { PgBoss } from "pg-boss";
2
3
 
4
+ const IndexBuilds = Schema.Array(Schema.Struct({ command: Schema.String, id: Schema.String }));
5
+ const IndexValidity = Schema.Array(Schema.Struct({ invalid: Schema.Boolean }));
6
+
3
7
  const BUILD_PREFIX = "effect-pg-boss-schema:";
4
8
 
5
9
  function indexName(command: string): string {
@@ -30,11 +34,11 @@ export async function resumeSchemaIndexes(client: PgBoss, schema: string): Promi
30
34
  `SELECT id, command FROM ${schema}.bam WHERE starts_with(name, $1) AND status <> 'completed' ORDER BY created_on, name`,
31
35
  [BUILD_PREFIX],
32
36
  );
33
- for (const row of rows) {
37
+ for (const row of Schema.decodeUnknownSync(IndexBuilds)(rows)) {
34
38
  const name = indexName(row.command);
35
39
  if (/^\s*CREATE /mu.test(row.command)) {
36
40
  const probe = await db.executeSql("SELECT NOT indisvalid AS invalid FROM pg_index WHERE indexrelid = to_regclass($1)", [`${schema}.${name}`]);
37
- if (probe.rows[0]?.invalid === true) {
41
+ if (Schema.decodeUnknownSync(IndexValidity)(probe.rows)[0]?.invalid === true) {
38
42
  await db.executeSql(`DROP INDEX CONCURRENTLY IF EXISTS ${schema}."${name}"`);
39
43
  }
40
44
  }
@@ -1,7 +1,7 @@
1
- import { Effect } from "effect";
1
+ import { Effect, Schema } from "effect";
2
2
  import { SqlClient } from "effect/sql";
3
3
  import type { Connection } from "effect/sql/SqlConnection";
4
- import { getConstructionPlans, getMigrationPlans, type MigrationPartition, PgBoss } from "pg-boss";
4
+ import { getConstructionPlans, getMigrationPlans, PgBoss } from "pg-boss";
5
5
  import pgBossPackage from "pg-boss/package.json" with { type: "json" };
6
6
  import type { SchemaComponent } from "@shivaedev/sql/SchemaComponent.ts";
7
7
  import { type PgBossError, toPgBossError } from "#error.ts";
@@ -9,6 +9,15 @@ import { deferralStorePlan } from "#internal/deferralStorePlan.ts";
9
9
  import { runSchemaPlan } from "#internal/runSchemaPlan.ts";
10
10
  import { resumeSchemaIndexes } from "#internal/schemaIndexBuilds.ts";
11
11
 
12
+ const Partitions = Schema.Array(
13
+ Schema.Struct({
14
+ policy: Schema.String,
15
+ tableName: Schema.String,
16
+ }),
17
+ );
18
+ const InstalledSchema = Schema.Array(Schema.Struct({ name: Schema.NullOr(Schema.String) }));
19
+ const InstalledVersion = Schema.Array(Schema.Struct({ version: Schema.Number }));
20
+
12
21
  export interface PgBossSchemaOptions {
13
22
  readonly schema?: string;
14
23
  }
@@ -39,10 +48,13 @@ async function installSchema(
39
48
  await resumeSchemaIndexes(client, schema);
40
49
  }
41
50
  if (version < pgBossPackage.pgboss.schema) {
42
- const partitionTables: MigrationPartition[] = Array.from(
51
+ const partitionTables = Schema.decodeUnknownSync(Partitions)(
43
52
  await run(connection.executeUnprepared(`SELECT table_name AS "tableName", policy FROM ${schema}.queue WHERE partition = true`, [], undefined)),
44
53
  );
45
- await runSchemaPlan(connection, getMigrationPlans(schema, version, { partitionTables }), run, { schema, version: pgBossPackage.pgboss.schema });
54
+ await runSchemaPlan(connection, getMigrationPlans(schema, version, { partitionTables: Array.from(partitionTables) }), run, {
55
+ schema,
56
+ version: pgBossPackage.pgboss.schema,
57
+ });
46
58
  await resumeSchemaIndexes(client, schema);
47
59
  }
48
60
  }
@@ -72,9 +84,13 @@ export function pgBossSchema(options: PgBossSchemaOptions = {}): SchemaComponent
72
84
  pending: Effect.gen(function* () {
73
85
  const sql = yield* SqlClient.SqlClient;
74
86
  const table = sql`${sql(schema)}.${sql("version")}`;
75
- const [installed] = yield* sql<{ name: string | null }>`select to_regclass(${table.compile()[0]})::text as name`.withoutTransform;
87
+ const [installed] = yield* sql`select to_regclass(${table.compile()[0]})::text as name`.withoutTransform.pipe(
88
+ Effect.flatMap(Schema.decodeUnknownEffect(InstalledSchema)),
89
+ );
76
90
  const [row] =
77
- installed === undefined || installed.name === null ? [] : yield* sql<{ version: number }>`select version from ${table}`.withoutTransform;
91
+ installed === undefined || installed.name === null
92
+ ? []
93
+ : yield* sql`select version from ${table}`.withoutTransform.pipe(Effect.flatMap(Schema.decodeUnknownEffect(InstalledVersion)));
78
94
  const required = pgBossPackage.pgboss.schema;
79
95
  return row === undefined || row.version < required
80
96
  ? `schema ${schema}, installed version ${row?.version ?? "missing"}, required version ${required}`