@shivaedev/effect-pg-boss 0.8.1 → 0.10.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 (60) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +75 -24
  3. package/dist/server/error.d.ts +10 -1
  4. package/dist/server/error.d.ts.map +1 -1
  5. package/dist/server/error.js +2 -0
  6. package/dist/server/error.js.map +1 -1
  7. package/dist/server/internal/expireAttempt.d.ts +3 -0
  8. package/dist/server/internal/expireAttempt.d.ts.map +1 -0
  9. package/dist/server/internal/expireAttempt.js +14 -0
  10. package/dist/server/internal/expireAttempt.js.map +1 -0
  11. package/dist/server/internal/observeTestingSettlements.d.ts +6 -0
  12. package/dist/server/internal/observeTestingSettlements.d.ts.map +1 -0
  13. package/dist/server/internal/observeTestingSettlements.js +35 -0
  14. package/dist/server/internal/observeTestingSettlements.js.map +1 -0
  15. package/dist/server/internal/runSchemaPlan.d.ts +7 -0
  16. package/dist/server/internal/runSchemaPlan.d.ts.map +1 -0
  17. package/dist/server/internal/runSchemaPlan.js +25 -0
  18. package/dist/server/internal/runSchemaPlan.js.map +1 -0
  19. package/dist/server/internal/schemaIndexBuilds.d.ts +4 -0
  20. package/dist/server/internal/schemaIndexBuilds.d.ts.map +1 -0
  21. package/dist/server/internal/schemaIndexBuilds.js +34 -0
  22. package/dist/server/internal/schemaIndexBuilds.js.map +1 -0
  23. package/dist/server/internal/testingClient.d.ts +10 -0
  24. package/dist/server/internal/testingClient.d.ts.map +1 -0
  25. package/dist/server/internal/testingClient.js +101 -0
  26. package/dist/server/internal/testingClient.js.map +1 -0
  27. package/dist/server/internal/testingExpirations.d.ts +6 -0
  28. package/dist/server/internal/testingExpirations.d.ts.map +1 -0
  29. package/dist/server/internal/testingExpirations.js +27 -0
  30. package/dist/server/internal/testingExpirations.js.map +1 -0
  31. package/dist/server/internal/testingReadout.d.ts +44 -0
  32. package/dist/server/internal/testingReadout.d.ts.map +1 -0
  33. package/dist/server/internal/testingReadout.js +13 -0
  34. package/dist/server/internal/testingReadout.js.map +1 -0
  35. package/dist/server/internal/testingSignal.d.ts +7 -0
  36. package/dist/server/internal/testingSignal.d.ts.map +1 -0
  37. package/dist/server/internal/testingSignal.js +27 -0
  38. package/dist/server/internal/testingSignal.js.map +1 -0
  39. package/dist/server/schema.d.ts +8 -0
  40. package/dist/server/schema.d.ts.map +1 -0
  41. package/dist/server/schema.js +43 -0
  42. package/dist/server/schema.js.map +1 -0
  43. package/dist/server/testing.d.ts +61 -0
  44. package/dist/server/testing.d.ts.map +1 -0
  45. package/dist/server/testing.js +69 -0
  46. package/dist/server/testing.js.map +1 -0
  47. package/package.json +7 -8
  48. package/src/server/error.ts +7 -1
  49. package/src/server/internal/expireAttempt.ts +18 -0
  50. package/src/server/internal/observeTestingSettlements.ts +36 -0
  51. package/src/server/internal/runSchemaPlan.ts +32 -0
  52. package/src/server/internal/schemaIndexBuilds.ts +44 -0
  53. package/src/server/internal/testingClient.ts +115 -0
  54. package/src/server/internal/testingExpirations.ts +31 -0
  55. package/src/server/internal/testingReadout.ts +26 -0
  56. package/src/server/internal/testingSignal.ts +28 -0
  57. package/src/server/schema.ts +57 -0
  58. package/src/server/testing.ts +85 -0
  59. package/docs/north-star.md +0 -48
  60. package/docs/roadmap.md +0 -35
@@ -0,0 +1,31 @@
1
+ import type { Job, PgBoss, SendOptions } from "pg-boss";
2
+
3
+ export function testingExpirations(client: PgBoss) {
4
+ const snapshots = new Map<string, Promise<number | undefined>>();
5
+ return {
6
+ attemptsFor: (job: Job<unknown>) => snapshots.get(`${job.name}:${job.id}`),
7
+ send(name: string, data: object | null | undefined, options: SendOptions, attempts: number | undefined) {
8
+ if (attempts === undefined && options.id === undefined) {
9
+ return client.send(name, data, options);
10
+ }
11
+ const id = options.id ?? crypto.randomUUID();
12
+ const key = `${name}:${id}`;
13
+ const previous = snapshots.get(key);
14
+ const sending = client.send(name, data, { ...options, id });
15
+ // A worker may claim the row before send returns. Wait for its result before applying the snapshot.
16
+ const snapshot = sending
17
+ .then(
18
+ (sent) => (sent === id ? attempts : previous),
19
+ () => previous,
20
+ )
21
+ .then((value) => {
22
+ if (value === undefined && snapshots.get(key) === snapshot) {
23
+ snapshots.delete(key);
24
+ }
25
+ return value;
26
+ });
27
+ snapshots.set(key, snapshot);
28
+ return sending;
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,26 @@
1
+ import { Effect, Redacted, Schema } from "effect";
2
+ import type { FindJobsOptions, PgBoss } from "pg-boss";
3
+ import type { JobPayloadSchema, QueueDefinition } from "#definition.ts";
4
+ import { PgBossPayloadError, PgBossTestingError } from "#error.ts";
5
+ import { payloadOf } from "#internal/tracing.ts";
6
+
7
+ export function testingOperation<A>(operation: string, run: () => Promise<A>, queue?: string) {
8
+ return Effect.tryPromise({
9
+ catch: (original) => new PgBossTestingError({ operation, original: Redacted.make(original), queue }),
10
+ try: run,
11
+ });
12
+ }
13
+
14
+ export function testingReadout(client: () => PgBoss) {
15
+ return <TName extends string, TPayload extends JobPayloadSchema>(queue: QueueDefinition<TName, TPayload>, options?: FindJobsOptions) =>
16
+ testingOperation("jobs", () => client().findJobs<unknown>(queue.name, options), queue.name).pipe(
17
+ Effect.flatMap((jobs) =>
18
+ Effect.forEach(jobs, (job) =>
19
+ Schema.decodeUnknownEffect(queue.schema)(payloadOf(job.data)).pipe(
20
+ Effect.mapError((original) => new PgBossPayloadError({ direction: "decode", original: Redacted.make(original), queue: queue.name })),
21
+ Effect.map((data) => ({ ...job, data })),
22
+ ),
23
+ ),
24
+ ),
25
+ );
26
+ }
@@ -0,0 +1,28 @@
1
+ import { Effect } from "effect";
2
+
3
+ export function testingSignal() {
4
+ let revision = 0;
5
+ const listeners = new Set<(revision: number) => void>();
6
+ return {
7
+ changed() {
8
+ revision += 1;
9
+ for (const listener of listeners) {
10
+ listener(revision);
11
+ }
12
+ listeners.clear();
13
+ },
14
+ revision: Effect.sync(() => revision),
15
+ waitForChange: (after: number): Effect.Effect<number> =>
16
+ Effect.callback((resume) => {
17
+ if (revision !== after) {
18
+ resume(Effect.succeed(revision));
19
+ return;
20
+ }
21
+ function listener(current: number) {
22
+ resume(Effect.succeed(current));
23
+ }
24
+ listeners.add(listener);
25
+ return Effect.sync(() => listeners.delete(listener));
26
+ }),
27
+ };
28
+ }
@@ -0,0 +1,57 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import type { Connection } from "effect/sql/SqlConnection";
4
+ import { getConstructionPlans, getMigrationPlans, type MigrationPartition, PgBoss } from "pg-boss";
5
+ import pgBossPackage from "pg-boss/package.json" with { type: "json" };
6
+ import { type PgBossError, toPgBossError } from "#error.ts";
7
+ import { runSchemaPlan } from "#internal/runSchemaPlan.ts";
8
+ import { resumeSchemaIndexes } from "#internal/schemaIndexBuilds.ts";
9
+
10
+ export interface PgBossSchemaOptions {
11
+ readonly schema?: string;
12
+ }
13
+
14
+ async function installSchema(
15
+ connection: Connection,
16
+ options: PgBossSchemaOptions,
17
+ run: <A, E>(effect: Effect.Effect<A, E>) => Promise<A>,
18
+ ): Promise<void> {
19
+ const schema = options.schema ?? "pgboss";
20
+ const construction = getConstructionPlans(schema);
21
+ const client = new PgBoss({
22
+ db: { executeSql: async (text, values = []) => ({ rows: Array.from(await run(connection.executeUnprepared(text, values, undefined))) }) },
23
+ schema,
24
+ });
25
+ if (!(await client.isInstalled())) {
26
+ await runSchemaPlan(connection, construction, run);
27
+ return;
28
+ }
29
+ const version = await client.schemaVersion();
30
+ if (version === null) {
31
+ throw new Error("The pg-boss version table is empty");
32
+ }
33
+ if (version > pgBossPackage.pgboss.schema) {
34
+ return;
35
+ }
36
+ if (version >= 27) {
37
+ await resumeSchemaIndexes(client, schema);
38
+ }
39
+ if (version < pgBossPackage.pgboss.schema) {
40
+ const partitionTables: MigrationPartition[] = Array.from(
41
+ await run(connection.executeUnprepared(`SELECT table_name AS "tableName", policy FROM ${schema}.queue WHERE partition = true`, [], undefined)),
42
+ );
43
+ await runSchemaPlan(connection, getMigrationPlans(schema, version, { partitionTables }), run, { schema, version: pgBossPackage.pgboss.schema });
44
+ await resumeSchemaIndexes(client, schema);
45
+ }
46
+ }
47
+
48
+ export function installPgBossSchema(options: PgBossSchemaOptions = {}): Effect.Effect<void, PgBossError, SqlClient.SqlClient> {
49
+ return Effect.scoped(
50
+ Effect.gen(function* () {
51
+ const sql = yield* SqlClient.SqlClient;
52
+ const connection = yield* sql.reserve;
53
+ const run = Effect.runPromiseWith(yield* Effect.context<never>());
54
+ yield* Effect.tryPromise({ catch: (error) => error, try: () => installSchema(connection, options, run) });
55
+ }).pipe(Effect.mapError((error) => toPgBossError("install", error))),
56
+ ).pipe(Effect.uninterruptible);
57
+ }
@@ -0,0 +1,85 @@
1
+ import { Effect, Redacted } from "effect";
2
+ import { PgBoss } from "pg-boss";
3
+ import type { PgBossClientFactory } from "#client.ts";
4
+ import type { QueueDefinition } from "#definition.ts";
5
+ import { PgBossTestingError } from "#error.ts";
6
+ import { testingClient } from "#internal/testingClient.ts";
7
+ import { testingOperation, testingReadout } from "#internal/testingReadout.ts";
8
+ import { testingSignal } from "#internal/testingSignal.ts";
9
+
10
+ export const testRetryPolicy = Object.freeze({ retryBackoff: false, retryDelay: 0 });
11
+
12
+ export interface PgBossTestingOptions {
13
+ readonly retryPolicy?: typeof testRetryPolicy;
14
+ }
15
+
16
+ export function makePgBossTesting(options: PgBossTestingOptions = {}) {
17
+ let current: PgBoss | undefined;
18
+ const signal = testingSignal();
19
+ const controls = {
20
+ changed: signal.changed,
21
+ expirations: new Map<string, number>(),
22
+ failures: new Set<string>(),
23
+ retryPolicy: options.retryPolicy ?? {},
24
+ };
25
+ function client(): PgBoss {
26
+ if (current === undefined) {
27
+ throw new Error("Provide the testing clientFactory to a jobs Layer first");
28
+ }
29
+ return current;
30
+ }
31
+ const clientFactory: PgBossClientFactory = (clientOptions) => {
32
+ if (current !== undefined) {
33
+ throw new Error("A testing controller owns one jobs client; create a controller per runtime");
34
+ }
35
+ current = new PgBoss({ ...clientOptions, "__test__enableSpies": true });
36
+ return testingClient(current, clientOptions.schema ?? "pgboss", controls);
37
+ };
38
+ return {
39
+ checkBatchWindow: (queue: QueueDefinition, millisecondsPerJob: number) =>
40
+ testingOperation(
41
+ "checkBatchWindow",
42
+ async () => {
43
+ const stored = await client().getQueue(queue.name);
44
+ if (stored === null) {
45
+ throw new Error("Queue is not registered");
46
+ }
47
+ if (stored.expireInSeconds === undefined) {
48
+ throw new Error("Queue has no expiry window");
49
+ }
50
+ if (!Number.isFinite(millisecondsPerJob) || millisecondsPerJob < 0) {
51
+ throw new RangeError("Job duration must be nonnegative and finite");
52
+ }
53
+ const required = (queue.workerOptions.batchSize ?? 10) * millisecondsPerJob;
54
+ if (required >= stored.expireInSeconds * 1000) {
55
+ throw new Error("Sequential batch work must fit within the queue expiry window");
56
+ }
57
+ },
58
+ queue.name,
59
+ ),
60
+ checkSchemaVersion: (expected: number) =>
61
+ testingOperation("checkSchemaVersion", async () => {
62
+ if ((await client().schemaVersion()) !== expected) {
63
+ throw new Error(`Expected pg-boss schema version ${expected}`);
64
+ }
65
+ }),
66
+ clientFactory,
67
+ expireAfter: (queue: QueueDefinition, attempts: number) =>
68
+ Effect.try({
69
+ catch: (original) => new PgBossTestingError({ operation: "expireAfter", original: Redacted.make(original), queue: queue.name }),
70
+ try: () => {
71
+ if (!Number.isSafeInteger(attempts) || attempts < 1) {
72
+ throw new RangeError("Attempts must be a positive integer");
73
+ }
74
+ controls.expirations.set(queue.name, attempts);
75
+ },
76
+ }),
77
+ failNextSend: (queue: QueueDefinition) => Effect.sync(() => controls.failures.add(queue.name)).pipe(Effect.asVoid),
78
+ jobs: testingReadout(client),
79
+ revision: signal.revision,
80
+ schemaVersion: testingOperation("schemaVersion", () => client().schemaVersion()),
81
+ waitForChange: signal.waitForChange,
82
+ };
83
+ }
84
+
85
+ export type PgBossTesting = ReturnType<typeof makePgBossTesting>;
@@ -1,48 +0,0 @@
1
- # North star
2
-
3
- ## The problem
4
-
5
- A durable job crosses two boundaries at once. Data leaves the producer's process and is read later by a worker; execution leaves the request's Effect context and is called later by pg-boss. When applications bridge those boundaries by hand, they repeat payload validation, dependency capture, queue registration and shutdown logic. A producer can drift from its worker's payload shape, a worker can rebuild services differently from the rest of the application, and a client can outlive the scope that should own it.
6
-
7
- The package exists to make that boundary one reusable path. pg-boss remains the PostgreSQL queue engine. Effect remains the application's language for services, errors and resources. The adapter should join those pieces without making the application learn another execution model.
8
-
9
- ## The ideal
10
-
11
- A feature declares one object Schema for a queue, then attaches an ordinary Effect handler. The producer uses its decoded type; the durable boundary uses its encoded type; the handler receives decoded data. Domain code never has to guess what an old or malformed stored payload means.
12
-
13
- A scheduled feature declares a name and cron expression, then attaches a payload-free Effect. Queue workers and scheduled workers become one list of registrations at the application's runtime boundary. The application's jobs Layer supplies the workers' services and owns the client that pg-boss calls through.
14
-
15
- An application that needs only queueing should not import a repository framework, transport or UI package. A worker that needs application services should declare them through ordinary Effect requirements. The jobs adapter should preserve those requirements rather than hiding them in a global runtime or making each callback rebuild its dependencies.
16
-
17
- ## What good looks like
18
-
19
- - The payload contract is authored once, including transformations between application and durable representations.
20
- - Invalid stored data is rejected before the worker's domain handler executes.
21
- - Worker services are supplied where the jobs Layer is built, and their requirements remain visible to TypeScript.
22
- - The client starts under an Effect scope. Startup failure, registration failure and scope release have a clear resource owner.
23
- - Client reuse is requested with a key. Applications choose the environments and registrations that should share a client.
24
- - pg-boss options remain pg-boss options. The package removes repeated boundary plumbing, not the engine's vocabulary.
25
- - Operational observations are data, such as queue counts; an application decides what those observations mean for its health endpoint and alerts.
26
- - Documentation distinguishes a fake client's observed calls from durable behavior demonstrated against PostgreSQL.
27
-
28
- ## Trade-offs
29
-
30
- **The durable contract wins over permissive input.** A queue Schema must describe object data on both sides. A malformed payload should not become a partially trusted value in domain code merely to keep a worker running.
31
-
32
- **Explicit dependencies win over callback convenience.** The jobs Layer requires the services its handlers use. Adding a hidden runtime or service locator would make a worker shorter while moving its real dependencies out of the type system.
33
-
34
- **Resource ownership wins over reuse.** Sharing a client can avoid duplicate starts across Layer builds, but a cache key is an application choice. It must not become a reason to ignore cleanup or silently infer environment policy.
35
-
36
- **A thin boundary wins over orchestration features.** pg-boss already owns queue execution and scheduling. A new helper earns its place by removing repeated application code at the Effect boundary, with tests for the semantics it preserves.
37
-
38
- **Proven behavior wins over a broader pitch.** Passing `retryBackoff: true` proves registration supplied that option. It does not prove retry timing. A fake client cannot establish a PostgreSQL delivery guarantee, and a PostgreSQL library test cannot establish application adoption or deployment behavior.
39
-
40
- ## What it deliberately leaves out
41
-
42
- - A replacement for pg-boss storage, cron interpretation, queue policy or retry execution.
43
- - Domain workers, authorization rules, external-provider interfaces and application logging or health policy.
44
- - Another general service declaration system; native Context and Layer stay available, and general declaration ergonomics belong to `effect-service`.
45
- - Application repositories or migration ownership; those belong to `effect-sql`.
46
- - Transactional enqueueing: jobs use a separate connection from application SQL transactions. Applications own any required outbox policy; after-commit changes have their own path through `effect-changes`.
47
- - A promise of exactly-once domain side effects or provider idempotency. Those need application-specific decisions and evidence.
48
- - Browser, device or offline synchronization behavior. A PostgreSQL queue boundary cannot establish those product contracts.
package/docs/roadmap.md DELETED
@@ -1,35 +0,0 @@
1
- # Roadmap
2
-
3
- This file owns the package's implementation inventory and open API choices. The [framework roadmap](https://github.com/ShivaeDev/platform/blob/main/docs/framework/roadmap.md#next-application-and-deployment-acceptance) owns jobs integration with application services and external providers, including deployment and device validation.
4
-
5
- ## Built
6
-
7
- - [x] `makePgBoss` retains its literal service identity in `PgBossDefinition` and its Layer type, so a different jobs Layer does not satisfy the required service. [Compiler evidence](https://github.com/ShivaeDev/platform/blob/main/packages/effect-pg-boss/src/server/service.typecheck.test.ts).
8
- - [x] Object payload Schema definitions, decoded enqueue types and queue workers. Type tests reject primitive schemas, missing fields and encoded values passed as decoded payloads.
9
- - [x] Encoding at enqueue and decoding before the handler. The PostgreSQL test round-trips a transformed payload and checks that malformed stored data reaches the dead-letter queue without calling the domain handler.
10
- - [x] Queue and schedule registration, with dead-letter queues, default retry settings and UTC schedule registration. The [PostgreSQL schedule test](../src/server/schedulePostgres.spec.ts) reads the stored cron, explicit UTC option, default retries and dead-letter queue through pg-boss. Fake-client tests also verify a retry-limit override.
11
- - [x] Producer-only queue definitions create queues and dead-letter queues through `Jobs.layer({ jobs: [Queue] })`, without starting a consumer. The [PostgreSQL producer test](../src/server/producerPostgres.spec.ts) enqueues a transformed payload and observes worker retry exhaustion and dead-letter delivery. The [starter producer](../../../examples/starter/src/server/noteProducer.ts) uses this API.
12
- - [x] Native pg-boss burst defaults (`batchSize: 10`, `burstWhenBatchFull: true`) and configurable batches and local concurrency. [PostgreSQL backlog evidence](../src/server/throughputPostgres.spec.ts) drains 30 jobs within 2.5 seconds.
13
- - [x] Handlers and scheduled effects can yield their owning jobs service without an external self requirement. [PostgreSQL follow-up evidence](../src/server/followUpPostgres.spec.ts) completes a worker chain and enqueues to a producer-only queue using one scoped client. All declared queues exist before consumers start.
14
- - [x] Worker and scheduled Effect service capture. Runtime and packed-consumer type tests preserve handler requirements.
15
- - [x] Scoped startup and shutdown, including cleanup when startup or registration fails.
16
- - [x] Explicit keyed client reuse and unkeyed per-build acquisition. Lifecycle tests cover two builds sharing one client and replacing the registered worker.
17
- - [x] Full pg-boss queue creation options, excluding the adapter's name and dead-letter setup. [PostgreSQL evidence](../src/server/notifyPostgres.spec.ts) verifies notification delivery to a waiting worker and immediate live counts after completion; [type tests](../src/server/service.typecheck.test.ts) enforce option ownership.
18
- - [x] Schema-decoded lookup by job ID and retry under the same ID for failed or retry-waiting jobs. [PostgreSQL evidence](../src/server/jobControlPostgres.spec.ts) verifies both states complete after a manual retry, missing lookups return `None` and duplicate-ID sends return `None`.
19
- - [x] Startup reconciles declared mutable queue settings and rejects immutable policy or partition changes with a typed error. [PostgreSQL restart evidence](../src/server/queueOptionsPostgres.spec.ts) checks expiry and retry-limit changes on an existing queue.
20
- - [x] Queue health totals for active, failed, queued, ready and dead-lettered counts. The [PostgreSQL health test](../src/server/healthPostgres.spec.ts) checks two populated queues and their totals after the pg-boss monitor refreshes its cached counts, including delayed jobs in queued counts.
21
- - [x] Background error events reach the captured reporting service and the listener is removed on release, verified with a [fake client](../src/server/edgeCases.spec.ts). Job AbortSignal cancellation interrupts the handler and leaves the rest of the batch to pg-boss expiry, verified with a [fake client](../src/server/settlement.spec.ts).
22
- - [x] Public defining-module imports and an installed-package type fixture.
23
-
24
- ## Next
25
-
26
- No further package API expansion is selected. Let the framework's jobs integration work identify the next package change, and preserve the existing payload and lifecycle contracts while it does. That integration's status stays in the framework roadmap.
27
-
28
- ## Decisions
29
-
30
- Enqueueing is separate from application SQL transactions. The package supplies no transactional enqueue adapter. Applications use transactions for database invariants and handle queue errors without failing those transactions. The starter enqueues after its note write transaction and logs queue failures; its [PostgreSQL queue-failure story](../../../examples/starter/src/server/queueFailure.spec.ts) proves that rejected enqueueing leaves create and edit writes saved. Its [rollback story](../../../examples/starter/src/server/committedJobs.spec.ts) also proves that an independently enqueued job survives an outer application rollback.
31
-
32
- ## Open questions
33
-
34
- - Should a shared client key be checked against constructor settings and registration changes? The lifecycle fixture uses the same Layer twice; it does not settle incompatible configurations under one key.
35
- - What evidence is needed before the guide makes stronger claims about actual cron execution? PostgreSQL tests verify schedule registration; cron execution timing remains untested.