@shivaedev/jobs 0.0.0-stage → 0.19.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 (219) hide show
  1. package/CHANGELOG.md +319 -0
  2. package/LICENSE +21 -0
  3. package/README.md +528 -2
  4. package/dist/server/client.d.ts +25 -0
  5. package/dist/server/client.d.ts.map +1 -0
  6. package/dist/server/client.js +3 -0
  7. package/dist/server/client.js.map +1 -0
  8. package/dist/server/definition.d.ts +96 -0
  9. package/dist/server/definition.d.ts.map +1 -0
  10. package/dist/server/definition.js +40 -0
  11. package/dist/server/definition.js.map +1 -0
  12. package/dist/server/error.d.ts +51 -0
  13. package/dist/server/error.d.ts.map +1 -0
  14. package/dist/server/error.js +37 -0
  15. package/dist/server/error.js.map +1 -0
  16. package/dist/server/health.d.ts +51 -0
  17. package/dist/server/health.d.ts.map +1 -0
  18. package/dist/server/health.js +4 -0
  19. package/dist/server/health.js.map +1 -0
  20. package/dist/server/internal/batchHandler.d.ts +5 -0
  21. package/dist/server/internal/batchHandler.d.ts.map +1 -0
  22. package/dist/server/internal/batchHandler.js +51 -0
  23. package/dist/server/internal/batchHandler.js.map +1 -0
  24. package/dist/server/internal/clientCache.d.ts +8 -0
  25. package/dist/server/internal/clientCache.d.ts.map +1 -0
  26. package/dist/server/internal/clientCache.js +61 -0
  27. package/dist/server/internal/clientCache.js.map +1 -0
  28. package/dist/server/internal/clientErrors.d.ts +8 -0
  29. package/dist/server/internal/clientErrors.d.ts.map +1 -0
  30. package/dist/server/internal/clientErrors.js +49 -0
  31. package/dist/server/internal/clientErrors.js.map +1 -0
  32. package/dist/server/internal/countsFor.d.ts +5 -0
  33. package/dist/server/internal/countsFor.d.ts.map +1 -0
  34. package/dist/server/internal/countsFor.js +28 -0
  35. package/dist/server/internal/countsFor.js.map +1 -0
  36. package/dist/server/internal/deadlineOf.d.ts +3 -0
  37. package/dist/server/internal/deadlineOf.d.ts.map +1 -0
  38. package/dist/server/internal/deadlineOf.js +7 -0
  39. package/dist/server/internal/deadlineOf.js.map +1 -0
  40. package/dist/server/internal/deferrableAttempt.d.ts +7 -0
  41. package/dist/server/internal/deferrableAttempt.d.ts.map +1 -0
  42. package/dist/server/internal/deferrableAttempt.js +28 -0
  43. package/dist/server/internal/deferrableAttempt.js.map +1 -0
  44. package/dist/server/internal/deferral.d.ts +15 -0
  45. package/dist/server/internal/deferral.d.ts.map +1 -0
  46. package/dist/server/internal/deferral.js +14 -0
  47. package/dist/server/internal/deferral.js.map +1 -0
  48. package/dist/server/internal/deferralStorePlan.d.ts +2 -0
  49. package/dist/server/internal/deferralStorePlan.d.ts.map +1 -0
  50. package/dist/server/internal/deferralStorePlan.js +35 -0
  51. package/dist/server/internal/deferralStorePlan.js.map +1 -0
  52. package/dist/server/internal/definitionOptions.d.ts +17 -0
  53. package/dist/server/internal/definitionOptions.d.ts.map +1 -0
  54. package/dist/server/internal/definitionOptions.js +12 -0
  55. package/dist/server/internal/definitionOptions.js.map +1 -0
  56. package/dist/server/internal/enqueueFor.d.ts +4 -0
  57. package/dist/server/internal/enqueueFor.d.ts.map +1 -0
  58. package/dist/server/internal/enqueueFor.js +23 -0
  59. package/dist/server/internal/enqueueFor.js.map +1 -0
  60. package/dist/server/internal/ensureQueue.d.ts +4 -0
  61. package/dist/server/internal/ensureQueue.d.ts.map +1 -0
  62. package/dist/server/internal/ensureQueue.js +44 -0
  63. package/dist/server/internal/ensureQueue.js.map +1 -0
  64. package/dist/server/internal/expireAttempt.d.ts +3 -0
  65. package/dist/server/internal/expireAttempt.d.ts.map +1 -0
  66. package/dist/server/internal/expireAttempt.js +17 -0
  67. package/dist/server/internal/expireAttempt.js.map +1 -0
  68. package/dist/server/internal/healthFor.d.ts +5 -0
  69. package/dist/server/internal/healthFor.d.ts.map +1 -0
  70. package/dist/server/internal/healthFor.js +27 -0
  71. package/dist/server/internal/healthFor.js.map +1 -0
  72. package/dist/server/internal/jobControl.d.ts +5 -0
  73. package/dist/server/internal/jobControl.d.ts.map +1 -0
  74. package/dist/server/internal/jobControl.js +35 -0
  75. package/dist/server/internal/jobControl.js.map +1 -0
  76. package/dist/server/internal/lifecycle.d.ts +17 -0
  77. package/dist/server/internal/lifecycle.d.ts.map +1 -0
  78. package/dist/server/internal/lifecycle.js +55 -0
  79. package/dist/server/internal/lifecycle.js.map +1 -0
  80. package/dist/server/internal/listJobs.d.ts +5 -0
  81. package/dist/server/internal/listJobs.d.ts.map +1 -0
  82. package/dist/server/internal/listJobs.js +69 -0
  83. package/dist/server/internal/listJobs.js.map +1 -0
  84. package/dist/server/internal/makeService.d.ts +4 -0
  85. package/dist/server/internal/makeService.d.ts.map +1 -0
  86. package/dist/server/internal/makeService.js +17 -0
  87. package/dist/server/internal/makeService.js.map +1 -0
  88. package/dist/server/internal/nativeClient.d.ts +3 -0
  89. package/dist/server/internal/nativeClient.d.ts.map +1 -0
  90. package/dist/server/internal/nativeClient.js +28 -0
  91. package/dist/server/internal/nativeClient.js.map +1 -0
  92. package/dist/server/internal/observeTestingSettlements.d.ts +6 -0
  93. package/dist/server/internal/observeTestingSettlements.d.ts.map +1 -0
  94. package/dist/server/internal/observeTestingSettlements.js +35 -0
  95. package/dist/server/internal/observeTestingSettlements.js.map +1 -0
  96. package/dist/server/internal/register.d.ts +8 -0
  97. package/dist/server/internal/register.d.ts.map +1 -0
  98. package/dist/server/internal/register.js +98 -0
  99. package/dist/server/internal/register.js.map +1 -0
  100. package/dist/server/internal/registerReleasedJobs.d.ts +5 -0
  101. package/dist/server/internal/registerReleasedJobs.d.ts.map +1 -0
  102. package/dist/server/internal/registerReleasedJobs.js +35 -0
  103. package/dist/server/internal/registerReleasedJobs.js.map +1 -0
  104. package/dist/server/internal/registerWorker.d.ts +5 -0
  105. package/dist/server/internal/registerWorker.d.ts.map +1 -0
  106. package/dist/server/internal/registerWorker.js +33 -0
  107. package/dist/server/internal/registerWorker.js.map +1 -0
  108. package/dist/server/internal/runSchemaPlan.d.ts +7 -0
  109. package/dist/server/internal/runSchemaPlan.d.ts.map +1 -0
  110. package/dist/server/internal/runSchemaPlan.js +25 -0
  111. package/dist/server/internal/runSchemaPlan.js.map +1 -0
  112. package/dist/server/internal/scheduleDefinition.d.ts +3 -0
  113. package/dist/server/internal/scheduleDefinition.d.ts.map +1 -0
  114. package/dist/server/internal/scheduleDefinition.js +19 -0
  115. package/dist/server/internal/scheduleDefinition.js.map +1 -0
  116. package/dist/server/internal/schemaIndexBuilds.d.ts +4 -0
  117. package/dist/server/internal/schemaIndexBuilds.d.ts.map +1 -0
  118. package/dist/server/internal/schemaIndexBuilds.js +34 -0
  119. package/dist/server/internal/schemaIndexBuilds.js.map +1 -0
  120. package/dist/server/internal/settleDeferral.d.ts +7 -0
  121. package/dist/server/internal/settleDeferral.d.ts.map +1 -0
  122. package/dist/server/internal/settleDeferral.js +48 -0
  123. package/dist/server/internal/settleDeferral.js.map +1 -0
  124. package/dist/server/internal/storedRows.d.ts +6 -0
  125. package/dist/server/internal/storedRows.d.ts.map +1 -0
  126. package/dist/server/internal/storedRows.js +10 -0
  127. package/dist/server/internal/storedRows.js.map +1 -0
  128. package/dist/server/internal/testingClient.d.ts +13 -0
  129. package/dist/server/internal/testingClient.d.ts.map +1 -0
  130. package/dist/server/internal/testingClient.js +117 -0
  131. package/dist/server/internal/testingClient.js.map +1 -0
  132. package/dist/server/internal/testingDeferrals.d.ts +11 -0
  133. package/dist/server/internal/testingDeferrals.d.ts.map +1 -0
  134. package/dist/server/internal/testingDeferrals.js +29 -0
  135. package/dist/server/internal/testingDeferrals.js.map +1 -0
  136. package/dist/server/internal/testingExpirations.d.ts +6 -0
  137. package/dist/server/internal/testingExpirations.d.ts.map +1 -0
  138. package/dist/server/internal/testingExpirations.js +27 -0
  139. package/dist/server/internal/testingExpirations.js.map +1 -0
  140. package/dist/server/internal/testingIdle.d.ts +16 -0
  141. package/dist/server/internal/testingIdle.d.ts.map +1 -0
  142. package/dist/server/internal/testingIdle.js +31 -0
  143. package/dist/server/internal/testingIdle.js.map +1 -0
  144. package/dist/server/internal/testingReadout.d.ts +44 -0
  145. package/dist/server/internal/testingReadout.d.ts.map +1 -0
  146. package/dist/server/internal/testingReadout.js +13 -0
  147. package/dist/server/internal/testingReadout.js.map +1 -0
  148. package/dist/server/internal/testingSignal.d.ts +7 -0
  149. package/dist/server/internal/testingSignal.d.ts.map +1 -0
  150. package/dist/server/internal/testingSignal.js +29 -0
  151. package/dist/server/internal/testingSignal.js.map +1 -0
  152. package/dist/server/internal/testingSnapshot.d.ts +6 -0
  153. package/dist/server/internal/testingSnapshot.d.ts.map +1 -0
  154. package/dist/server/internal/testingSnapshot.js +32 -0
  155. package/dist/server/internal/testingSnapshot.js.map +1 -0
  156. package/dist/server/internal/tracing.d.ts +6 -0
  157. package/dist/server/internal/tracing.d.ts.map +1 -0
  158. package/dist/server/internal/tracing.js +37 -0
  159. package/dist/server/internal/tracing.js.map +1 -0
  160. package/dist/server/internal/waitForBatchExpiry.d.ts +6 -0
  161. package/dist/server/internal/waitForBatchExpiry.d.ts.map +1 -0
  162. package/dist/server/internal/waitForBatchExpiry.js +28 -0
  163. package/dist/server/internal/waitForBatchExpiry.js.map +1 -0
  164. package/dist/server/schema.d.ts +10 -0
  165. package/dist/server/schema.d.ts.map +1 -0
  166. package/dist/server/schema.js +67 -0
  167. package/dist/server/schema.js.map +1 -0
  168. package/dist/server/service.d.ts +29 -0
  169. package/dist/server/service.d.ts.map +1 -0
  170. package/dist/server/service.js +33 -0
  171. package/dist/server/service.js.map +1 -0
  172. package/dist/server/testing.d.ts +77 -0
  173. package/dist/server/testing.d.ts.map +1 -0
  174. package/dist/server/testing.js +90 -0
  175. package/dist/server/testing.js.map +1 -0
  176. package/package.json +74 -3
  177. package/src/server/client.ts +40 -0
  178. package/src/server/definition.ts +140 -0
  179. package/src/server/error.ts +73 -0
  180. package/src/server/health.ts +60 -0
  181. package/src/server/internal/batchHandler.ts +71 -0
  182. package/src/server/internal/clientCache.ts +73 -0
  183. package/src/server/internal/clientErrors.ts +64 -0
  184. package/src/server/internal/countsFor.ts +34 -0
  185. package/src/server/internal/deadlineOf.ts +8 -0
  186. package/src/server/internal/deferrableAttempt.ts +41 -0
  187. package/src/server/internal/deferral.ts +19 -0
  188. package/src/server/internal/deferralStorePlan.ts +35 -0
  189. package/src/server/internal/definitionOptions.ts +14 -0
  190. package/src/server/internal/enqueueFor.ts +39 -0
  191. package/src/server/internal/ensureQueue.ts +47 -0
  192. package/src/server/internal/expireAttempt.ts +21 -0
  193. package/src/server/internal/healthFor.ts +35 -0
  194. package/src/server/internal/jobControl.ts +44 -0
  195. package/src/server/internal/lifecycle.ts +68 -0
  196. package/src/server/internal/listJobs.ts +98 -0
  197. package/src/server/internal/makeService.ts +19 -0
  198. package/src/server/internal/nativeClient.ts +30 -0
  199. package/src/server/internal/observeTestingSettlements.ts +36 -0
  200. package/src/server/internal/register.ts +145 -0
  201. package/src/server/internal/registerReleasedJobs.ts +44 -0
  202. package/src/server/internal/registerWorker.ts +56 -0
  203. package/src/server/internal/runSchemaPlan.ts +32 -0
  204. package/src/server/internal/scheduleDefinition.ts +21 -0
  205. package/src/server/internal/schemaIndexBuilds.ts +44 -0
  206. package/src/server/internal/settleDeferral.ts +54 -0
  207. package/src/server/internal/storedRows.ts +17 -0
  208. package/src/server/internal/testingClient.ts +134 -0
  209. package/src/server/internal/testingDeferrals.ts +44 -0
  210. package/src/server/internal/testingExpirations.ts +31 -0
  211. package/src/server/internal/testingIdle.ts +52 -0
  212. package/src/server/internal/testingReadout.ts +19 -0
  213. package/src/server/internal/testingSignal.ts +30 -0
  214. package/src/server/internal/testingSnapshot.ts +53 -0
  215. package/src/server/internal/tracing.ts +44 -0
  216. package/src/server/internal/waitForBatchExpiry.ts +30 -0
  217. package/src/server/schema.ts +84 -0
  218. package/src/server/service.ts +100 -0
  219. package/src/server/testing.ts +118 -0
@@ -0,0 +1,44 @@
1
+ import { Effect, Option } from "effect";
2
+ import type { PgBossClient } from "#client.ts";
3
+ import { toPgBossError } from "#error.ts";
4
+ import { decodeStoredData } from "#internal/storedRows.ts";
5
+ import type { PgBossService } from "#service.ts";
6
+
7
+ export function getJobFor(client: PgBossClient): PgBossService["getJob"] {
8
+ return (queue, id) =>
9
+ Effect.tryPromise({
10
+ catch: (error) => toPgBossError("getJob", error, queue.name),
11
+ try: async () => {
12
+ if (client.findJobs === undefined) {
13
+ throw new TypeError("This pg-boss client does not support findJobs");
14
+ }
15
+ return (await client.findJobs(queue.name, { id }))[0];
16
+ },
17
+ }).pipe(
18
+ Effect.flatMap((job) =>
19
+ job === undefined
20
+ ? Effect.succeed(Option.none())
21
+ : decodeStoredData(queue, job.data).pipe(Effect.map((data) => Option.some({ ...job, data }))),
22
+ ),
23
+ );
24
+ }
25
+
26
+ export function retryFor(client: PgBossClient): PgBossService["retry"] {
27
+ return (queue, id) =>
28
+ Effect.tryPromise({
29
+ catch: (error) => toPgBossError("retry", error, queue.name),
30
+ try: async () => {
31
+ if (client.findJobs === undefined || client.retry === undefined || client.update === undefined) {
32
+ throw new TypeError("This pg-boss client does not support findJobs, retry and update");
33
+ }
34
+ const job = (await client.findJobs(queue.name, { id }))[0];
35
+ if (job?.state !== "failed" && job?.state !== "retry") {
36
+ return;
37
+ }
38
+ if (job.state === "failed") {
39
+ await client.retry(queue.name, id);
40
+ }
41
+ await client.update(queue.name, undefined, { id, startAfter: 0 });
42
+ },
43
+ });
44
+ }
@@ -0,0 +1,68 @@
1
+ import { Effect } from "effect";
2
+ import type { ConstructorOptions, StopOptions } from "pg-boss";
3
+ import { defaultClientFactory, type PgBossClient, type PgBossClientFactory } from "#client.ts";
4
+ import { PgBossClientOptionsError, type PgBossError, toPgBossError } from "#error.ts";
5
+ import { deferralStorePlan } from "#internal/deferralStorePlan.ts";
6
+ import { acquireCachedClient, releaseCachedClient } from "./clientCache.ts";
7
+ import { logClientError, observeClientErrors } from "./clientErrors.ts";
8
+
9
+ export interface AcquireClientOptions {
10
+ readonly clientCacheKey?: string | symbol | undefined;
11
+ readonly clientFactory?: PgBossClientFactory | undefined;
12
+ readonly constructor: ConstructorOptions;
13
+ }
14
+
15
+ export interface AcquiredClient {
16
+ readonly cacheKey?: string | symbol;
17
+ readonly client: PgBossClient;
18
+ readonly reused: boolean;
19
+ }
20
+
21
+ async function startClient(options: AcquireClientOptions, report: (error: Error) => void): Promise<PgBossClient> {
22
+ const client = (options.clientFactory ?? defaultClientFactory)(options.constructor);
23
+ observeClientErrors(client, report);
24
+ try {
25
+ await client.start();
26
+ if (options.constructor.migrate !== false && client.getDb !== undefined) {
27
+ await client.getDb().executeSql(deferralStorePlan(options.constructor.schema ?? "pgboss"));
28
+ }
29
+ return client;
30
+ } catch (error) {
31
+ try {
32
+ await client.stop();
33
+ } catch {
34
+ // Preserve the startup failure; stop is best effort on failed acquisition.
35
+ }
36
+ throw error;
37
+ }
38
+ }
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
+ });
55
+ });
56
+ }
57
+
58
+ export function releaseClient(acquired: AcquiredClient, stopOptions?: StopOptions): Effect.Effect<void> {
59
+ return Effect.tryPromise({
60
+ catch: (error) => toPgBossError("stop", error),
61
+ try: async () => {
62
+ const client = acquired.cacheKey === undefined ? acquired.client : await releaseCachedClient(acquired.cacheKey);
63
+ if (client !== undefined) {
64
+ await client.stop(stopOptions);
65
+ }
66
+ },
67
+ }).pipe(Effect.catch((error) => Effect.logError(error)));
68
+ }
@@ -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: Pick<QueueDefinition<TName, TPayload>, "name" | "schema">,
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
+ }
@@ -0,0 +1,19 @@
1
+ import type { PgBossClient } from "#client.ts";
2
+ import { countsFor } from "#internal/countsFor.ts";
3
+ import { enqueueFor } from "#internal/enqueueFor.ts";
4
+ import { healthFor } from "#internal/healthFor.ts";
5
+ import { getJobFor, retryFor } from "#internal/jobControl.ts";
6
+ import { deadLetteredJobsFor, failedJobsFor } from "#internal/listJobs.ts";
7
+ import type { PgBossService } from "#service.ts";
8
+
9
+ export function makeService(client: PgBossClient, names: readonly string[], schema = "pgboss"): PgBossService {
10
+ return {
11
+ counts: (queue) => countsFor(client, schema, queue.name),
12
+ deadLetteredJobs: deadLetteredJobsFor(client, schema),
13
+ enqueue: enqueueFor(client),
14
+ failedJobs: failedJobsFor(client, schema),
15
+ getJob: getJobFor(client),
16
+ health: healthFor(client, names),
17
+ retry: retryFor(client),
18
+ };
19
+ }
@@ -0,0 +1,30 @@
1
+ import { Socket } from "node:net";
2
+ import { type ConstructorOptions, PgBoss } from "pg-boss";
3
+
4
+ export function nativeClient(options: ConstructorOptions) {
5
+ const disconnected = new Set<Promise<void>>();
6
+ const config = {
7
+ ...options,
8
+ stream: () => {
9
+ const socket = new Socket();
10
+ const closed = new Promise<void>((resolve) =>
11
+ socket.once("close", () => {
12
+ disconnected.delete(closed);
13
+ resolve();
14
+ }),
15
+ );
16
+ disconnected.add(closed);
17
+ return socket;
18
+ },
19
+ };
20
+ const client = new PgBoss(config);
21
+ const stop = client.stop.bind(client);
22
+ client.stop = async (stopOptions) => {
23
+ await stop(stopOptions);
24
+ // pg-boss's pool shutdown does not wait for its transports to disconnect.
25
+ if (stopOptions?.close !== false) {
26
+ await Promise.all([...disconnected]);
27
+ }
28
+ };
29
+ return client;
30
+ }
@@ -0,0 +1,36 @@
1
+ import type { PgBoss } from "pg-boss";
2
+
3
+ export function observeTestingSettlements(client: PgBoss, settled: (name: string) => 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
+ settled(name);
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
+ }
@@ -0,0 +1,145 @@
1
+ import { type Context, Effect, Redacted, Schema, type Scope } from "effect";
2
+ import type { Job } from "pg-boss";
3
+ 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";
13
+ import { ensureQueue } from "#internal/ensureQueue.ts";
14
+ import { registerWorker } from "#internal/registerWorker.ts";
15
+ import { payloadOf } from "#internal/tracing.ts";
16
+
17
+ function isQueueWorker(registration: JobRegistration): registration is QueueWorker {
18
+ return registration._tag === "QueueWorker";
19
+ }
20
+
21
+ function isScheduledWorker(registration: JobRegistration): registration is ScheduledWorker {
22
+ return registration._tag === "ScheduledWorker";
23
+ }
24
+
25
+ function isQueueDefinition(registration: JobRegistration): registration is QueueDefinition {
26
+ return registration._tag === "QueueDefinition";
27
+ }
28
+
29
+ function registrationDefinition(registration: JobRegistration): QueueDefinition | ScheduleDefinition {
30
+ if (isQueueDefinition(registration)) {
31
+ return registration;
32
+ }
33
+ if (isQueueWorker(registration)) {
34
+ return registration.queue;
35
+ }
36
+ if (isScheduledWorker(registration)) {
37
+ return registration.schedule;
38
+ }
39
+ throw new TypeError(`Unknown pg-boss registration: ${registration._tag}`);
40
+ }
41
+
42
+ export function registrationName(registration: JobRegistration): string {
43
+ return registrationDefinition(registration).name;
44
+ }
45
+
46
+ async function prepareQueues(client: PgBossClient, registrations: readonly JobRegistration[], replaceWorkers: boolean): Promise<void> {
47
+ for (const registration of registrations) {
48
+ if (replaceWorkers && !isQueueDefinition(registration)) {
49
+ await client.offWork(registrationName(registration));
50
+ }
51
+ }
52
+ for (const registration of registrations) {
53
+ 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
+ }
59
+ }
60
+ }
61
+
62
+ // The layer requires RegistrationRequirements of its jobs, so its context provides every registered worker.
63
+ function workerContext<R>(context: Context.Context<R>): Context.Context<unknown>;
64
+ function workerContext(context: unknown): unknown {
65
+ return context;
66
+ }
67
+
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(
83
+ client: PgBossClient,
84
+ registration: QueueWorker | QueueDefinition,
85
+ context: Context.Context<unknown>,
86
+ scope: Scope.Scope,
87
+ schema: string,
88
+ ): Promise<void> {
89
+ const queue = registration._tag === "QueueWorker" ? registration.queue : registration;
90
+ const name = queue.name;
91
+ if (registration._tag === "QueueDefinition") {
92
+ return;
93
+ }
94
+ await registerWorker(client, name, queue.workerOptions, (job) => runQueueWorker(registration, job), scope, schema, queue.maxDeferrals, context);
95
+ }
96
+
97
+ async function registerSchedule(
98
+ client: PgBossClient,
99
+ worker: ScheduledWorker,
100
+ context: Context.Context<unknown>,
101
+ scope: Scope.Scope,
102
+ schema: string,
103
+ ): Promise<void> {
104
+ 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
+ }
108
+
109
+ export function registrationNames(registrations: readonly JobRegistration[]): readonly string[] {
110
+ const names = registrations.map(registrationName);
111
+ if (new Set(names).size !== names.length) {
112
+ throw new TypeError("pg-boss job names must be unique");
113
+ }
114
+ return names;
115
+ }
116
+
117
+ export function registerJobs<R>(
118
+ client: PgBossClient,
119
+ registrations: readonly JobRegistration[],
120
+ context: Context.Context<R>,
121
+ replaceWorkers: boolean,
122
+ schema = "pgboss",
123
+ 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
+ }
@@ -0,0 +1,44 @@
1
+ import { type Context, Effect, Option, Scope } from "effect";
2
+ import { WorkerRelease } from "@shivaedev/sql/WorkerRelease.ts";
3
+ import type { PgBossClient } from "#client.ts";
4
+ import type { JobRegistration } from "#definition.ts";
5
+ import { toPgBossError } from "#error.ts";
6
+ import { registerJobs, registrationName } from "./register.ts";
7
+
8
+ export const registerReleasedJobs = Effect.fnUntraced(function* <R>(
9
+ client: PgBossClient,
10
+ jobs: readonly JobRegistration[],
11
+ context: Context.Context<R>,
12
+ replaceWorkers: boolean,
13
+ schema?: string,
14
+ ) {
15
+ const signal = yield* Effect.serviceOption(WorkerRelease);
16
+ const workers = jobs.filter((job) => job._tag !== "QueueDefinition");
17
+ const scope = yield* Effect.scope;
18
+ const register = registerJobs(client, jobs, context, replaceWorkers, schema).pipe(Effect.provideService(Scope.Scope, scope));
19
+ if (Option.isNone(signal) || workers.length === 0) {
20
+ return yield* register;
21
+ }
22
+ yield* registerJobs(client, jobs, context, replaceWorkers, schema, false);
23
+ let working = replaceWorkers;
24
+ let paused = false;
25
+ yield* signal.value.register((active) =>
26
+ Effect.gen(function* () {
27
+ if (active) {
28
+ yield* register;
29
+ working = true;
30
+ if (paused) {
31
+ yield* Effect.logInfo("Workers resumed for database release");
32
+ }
33
+ } else if (working) {
34
+ yield* Effect.tryPromise({
35
+ catch: (error) => toPgBossError("register", error),
36
+ try: () => Promise.all(workers.map((job) => client.offWork(registrationName(job)))),
37
+ });
38
+ working = false;
39
+ paused = true;
40
+ yield* Effect.logInfo("Workers paused for another database release");
41
+ }
42
+ }).pipe(Effect.provide(context)),
43
+ );
44
+ });
@@ -0,0 +1,56 @@
1
+ import { Clock, type Context, Effect, type Scope } from "effect";
2
+ import type { Job, WorkOptions } from "pg-boss";
3
+ import type { PgBossClient } from "#client.ts";
4
+ import { batchHandler } from "#internal/batchHandler.ts";
5
+ import { deferrableAttempt } from "#internal/deferrableAttempt.ts";
6
+ import { traceJob } from "#internal/tracing.ts";
7
+
8
+ export function registerWorker(
9
+ client: PgBossClient,
10
+ name: string,
11
+ options: WorkOptions,
12
+ run: (job: Job<unknown>) => Effect.Effect<unknown, unknown, unknown>,
13
+ scope: Scope.Scope,
14
+ schema: string,
15
+ maxDeferrals: number,
16
+ 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
+ const now = yield* Clock.currentTimeMillis;
40
+ // PostgreSQL timestamps retain sub-millisecond precision that Date drops.
41
+ yield* Effect.sleep(Math.max(0, job.startAfter.getTime() + 1 - now));
42
+ const workerIds = client
43
+ .getWipData?.()
44
+ .filter((worker) => worker.workId === workId)
45
+ .map((worker) => worker.id) ?? [workId];
46
+ for (const workerId of workerIds) {
47
+ client.notifyWorker?.(workerId);
48
+ }
49
+ }).pipe(Effect.forkIn(scope)),
50
+ );
51
+ },
52
+ Effect.runPromiseWith(context),
53
+ ),
54
+ );
55
+ return registration;
56
+ }
@@ -0,0 +1,32 @@
1
+ import type { Effect } from "effect";
2
+ import type { Connection } from "effect/sql/SqlConnection";
3
+ import { pendingIndexInserts } from "./schemaIndexBuilds.ts";
4
+
5
+ export async function runSchemaPlan(
6
+ connection: Connection,
7
+ plan: string,
8
+ run: <A, E>(effect: Effect.Effect<A, E>) => Promise<A>,
9
+ upgrade?: { readonly schema: string; readonly version: number },
10
+ ): Promise<void> {
11
+ // Native exported plans append concurrent index DDL after the migration transaction.
12
+ const parts = /^\s*BEGIN;(?<body>[\s\S]*)\n {4}COMMIT;(?<concurrent>[\s\S]*)$/u.exec(plan);
13
+ if (parts?.groups === undefined) {
14
+ throw new Error("Unrecognized pg-boss schema plan transaction");
15
+ }
16
+ const { body = "", concurrent = "" } = parts.groups;
17
+ function quote(text: string) {
18
+ return `'${text.replaceAll("'", "''")}'`;
19
+ }
20
+ const commands = concurrent
21
+ .trim()
22
+ .split(/;\s*(?:\n|$)/u)
23
+ .filter((statement) => statement.trim() !== "");
24
+ const pending = upgrade === undefined ? "" : pendingIndexInserts(upgrade.schema, upgrade.version, commands, quote);
25
+ // Effect SQL uses the extended protocol, which accepts one statement per command.
26
+ await run(connection.executeRaw(`DO ${quote(`BEGIN EXECUTE ${quote(`${body}\n${pending}`)}; END`)}`, []));
27
+ if (upgrade === undefined) {
28
+ for (const statement of commands) {
29
+ await run(connection.executeRaw(statement, []));
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,21 @@
1
+ import type { DefineScheduleOptions, ScheduleDefinition } from "#definition.ts";
2
+ import { maxDeferrals, nativeQueueOptions } from "#internal/definitionOptions.ts";
3
+
4
+ export function scheduleDefinition<const TName extends string>(options: DefineScheduleOptions<TName>): ScheduleDefinition<TName> {
5
+ const definition: ScheduleDefinition<TName> = {
6
+ _tag: "ScheduleDefinition",
7
+ cron: options.cron,
8
+ maxDeferrals: maxDeferrals(options.queue),
9
+ name: options.name,
10
+ queueOptions: nativeQueueOptions(options.queue),
11
+ run: (effect) =>
12
+ Object.freeze({
13
+ _tag: "ScheduledWorker" as const,
14
+ effect,
15
+ schedule: definition,
16
+ }),
17
+ scheduleOptions: Object.freeze({ tz: "UTC", ...options.schedule }),
18
+ workerOptions: Object.freeze({ batchSize: 10, burstWhenBatchFull: true, ...options.worker }),
19
+ };
20
+ return Object.freeze(definition);
21
+ }
@@ -0,0 +1,44 @@
1
+ import type { PgBoss } from "pg-boss";
2
+
3
+ const BUILD_PREFIX = "effect-pg-boss-schema:";
4
+
5
+ function indexName(command: string): string {
6
+ const match =
7
+ /^\s*(?:CREATE (?:UNIQUE )?INDEX CONCURRENTLY IF NOT EXISTS |DROP INDEX CONCURRENTLY IF EXISTS (?:(?:"[^"]+"|\w+)\.))(?<name>\w+)/mu.exec(
8
+ command,
9
+ );
10
+ const name = match?.groups?.name;
11
+ if (name === undefined) {
12
+ throw new Error("Unrecognized pg-boss concurrent index command");
13
+ }
14
+ return name;
15
+ }
16
+
17
+ export function pendingIndexInserts(schema: string, version: number, commands: readonly string[], quote: (text: string) => string): string {
18
+ return commands
19
+ .map((command, position) => {
20
+ const table = indexName(command).replace(/_i\d+$/u, "");
21
+ const name = `${BUILD_PREFIX}${String(position).padStart(6, "0")}`;
22
+ return `INSERT INTO ${schema}.bam (name, version, table_name, command) VALUES (${quote(name)}, ${version}, ${quote(table)}, ${quote(command)});`;
23
+ })
24
+ .join("\n");
25
+ }
26
+
27
+ export async function resumeSchemaIndexes(client: PgBoss, schema: string): Promise<void> {
28
+ const db = client.getDb();
29
+ const { rows } = await db.executeSql(
30
+ `SELECT id, command FROM ${schema}.bam WHERE starts_with(name, $1) AND status <> 'completed' ORDER BY created_on, name`,
31
+ [BUILD_PREFIX],
32
+ );
33
+ for (const row of rows) {
34
+ const name = indexName(row.command);
35
+ if (/^\s*CREATE /mu.test(row.command)) {
36
+ 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) {
38
+ await db.executeSql(`DROP INDEX CONCURRENTLY IF EXISTS ${schema}."${name}"`);
39
+ }
40
+ }
41
+ await db.executeSql(row.command);
42
+ await db.executeSql(`UPDATE ${schema}.bam SET status = 'completed', completed_on = clock_timestamp() WHERE id = $1`, [row.id]);
43
+ }
44
+ }