effect-mq 0.3.1 → 0.3.2
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.
- package/README.md +29 -1
- package/dist/testing/TestJobStore.d.ts +95 -0
- package/dist/testing/TestJobStore.d.ts.map +1 -0
- package/dist/testing/TestJobStore.js +88 -0
- package/dist/testing/TestJobStore.js.map +1 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +6 -0
- package/dist/testing/index.js.map +1 -1
- package/package.json +1 -1
- package/src/testing/TestJobStore.ts +143 -0
- package/src/testing/index.ts +7 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ One package, tree-shakeable modules:
|
|
|
16
16
|
| `effect-mq` | `Job`, `JobStore`, `MemoryJobStore`, `Worker` | — |
|
|
17
17
|
| `effect-mq/drizzle-postgres` | drizzle-postgres schema factories + the Postgres `JobStore` | `drizzle-orm` (v1), `@effect/sql-pg` |
|
|
18
18
|
| `effect-mq/redis` | the Redis `JobStore` (Lua-script atomicity) | a `Redis` service (`@effect/platform-node`/`-bun`) |
|
|
19
|
-
| `effect-mq/testing` |
|
|
19
|
+
| `effect-mq/testing` | `TestJobStore` (assert enqueues in unit tests) + the driver conformance suite | `@effect/vitest` (conformance only) |
|
|
20
20
|
|
|
21
21
|
## Five-minute tour
|
|
22
22
|
|
|
@@ -510,6 +510,34 @@ handler).
|
|
|
510
510
|
and `historySweepInterval`; drizzle-postgres additionally takes the table
|
|
511
511
|
instances (+ `validate`), Redis a key `prefix`.
|
|
512
512
|
|
|
513
|
+
## Testing your app
|
|
514
|
+
|
|
515
|
+
Unit-test that services enqueue correctly — no worker, no boilerplate, and
|
|
516
|
+
payloads come back **decoded through the job's schema** (so `Redacted`,
|
|
517
|
+
`DateTime`, and branded values are real instances, not stored JSON):
|
|
518
|
+
|
|
519
|
+
```ts
|
|
520
|
+
import { TestJobStore } from "effect-mq/testing"
|
|
521
|
+
|
|
522
|
+
it.effect("signup enqueues a welcome email", () =>
|
|
523
|
+
Effect.gen(function*() {
|
|
524
|
+
yield* SignupService.register({ email: "ada@example.com" })
|
|
525
|
+
|
|
526
|
+
const emails = yield* TestJobStore.enqueuedOf(SendEmail)
|
|
527
|
+
expect(emails).toHaveLength(1)
|
|
528
|
+
expect(emails[0]?.payload.to).toBe("ada@example.com")
|
|
529
|
+
expect(emails[0]?.state).toBe("waiting")
|
|
530
|
+
}).pipe(Effect.provide(TestJobStore.layer)))
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
`TestJobStore.layer` provides a fresh in-memory store as both the default
|
|
534
|
+
`JobStore` (for the code under test) and the inspection service; jobs just
|
|
535
|
+
accumulate in `waiting`/`delayed` since nothing claims them. Named stores
|
|
536
|
+
use `TestJobStore.layerFor(Durable)`. Records surface scheduling detail
|
|
537
|
+
(`state`, `priority`, `runAt`, `metadata`, `dedupeKey`), and the raw store
|
|
538
|
+
is exposed for simulating claims/acks. No `@effect/vitest` required — it is
|
|
539
|
+
plain Effect, so it works with any test runner.
|
|
540
|
+
|
|
513
541
|
## Writing a storage driver
|
|
514
542
|
|
|
515
543
|
Implement the `JobStore` service (one atomic seam: `enqueue`, `claim`, `ack`,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test harness for asserting what your services enqueue, without running
|
|
3
|
+
* a worker.
|
|
4
|
+
*
|
|
5
|
+
* Provide `TestJobStore.layer` in a unit test and jobs enqueued by the code
|
|
6
|
+
* under test simply accumulate in `waiting`/`delayed` (nothing claims them).
|
|
7
|
+
* `enqueuedOf` returns them with payloads **decoded through the job's own
|
|
8
|
+
* schema** — you assert against the typed values your service produced, not
|
|
9
|
+
* the encoded JSON the store persists:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { TestJobStore } from "effect-mq/testing"
|
|
13
|
+
*
|
|
14
|
+
* it.effect("signup enqueues a welcome email", () =>
|
|
15
|
+
* Effect.gen(function*() {
|
|
16
|
+
* yield* SignupService.register({ email: "ada@example.com" })
|
|
17
|
+
*
|
|
18
|
+
* const emails = yield* TestJobStore.enqueuedOf(SendEmail)
|
|
19
|
+
* expect(emails).toHaveLength(1)
|
|
20
|
+
* expect(emails[0]?.payload.to).toBe("ada@example.com")
|
|
21
|
+
* expect(emails[0]?.state).toBe("waiting")
|
|
22
|
+
* }).pipe(Effect.provide(TestJobStore.layer)))
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Jobs bound to named stores use `TestJobStore.layerFor(Durable)` instead.
|
|
26
|
+
* The raw `JobStore` service is also exposed (as `.store`) for advanced
|
|
27
|
+
* scenarios — simulating claims/acks, reading `counts()`, and so on.
|
|
28
|
+
*
|
|
29
|
+
* @since 0.3.2
|
|
30
|
+
*/
|
|
31
|
+
import * as JobStore from "../JobStore.ts";
|
|
32
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
33
|
+
/**
|
|
34
|
+
* The minimal structural view of a `Job.make` class that `enqueuedOf`
|
|
35
|
+
* needs: its tag and its JSON payload codec.
|
|
36
|
+
*
|
|
37
|
+
* @since 0.3.2
|
|
38
|
+
*/
|
|
39
|
+
export interface AnyJobDefinition {
|
|
40
|
+
readonly _tag: string;
|
|
41
|
+
readonly payloadJsonSchema: Schema.Top & {
|
|
42
|
+
readonly DecodingServices: never;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A stored job with its payload decoded back to the definition's payload
|
|
47
|
+
* type.
|
|
48
|
+
*
|
|
49
|
+
* @since 0.3.2
|
|
50
|
+
*/
|
|
51
|
+
export interface EnqueuedJob<Payload> extends Omit<JobStore.JobRecord, "payload"> {
|
|
52
|
+
readonly payload: Payload;
|
|
53
|
+
}
|
|
54
|
+
declare const TestJobStore_base: Context.ServiceClass<TestJobStore, "effect-mq/testing/TestJobStore", {
|
|
55
|
+
store: JobStore.Service;
|
|
56
|
+
enqueued: (name?: string) => Effect.Effect<JobStore.JobRecord[], never, never>;
|
|
57
|
+
enqueuedOf: <J extends AnyJobDefinition>(job: J) => Effect.Effect<EnqueuedJob<J["payloadJsonSchema"]["Type"]>[], never, never>;
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Inspection API over the test store. `enqueued(name?)` returns raw records
|
|
61
|
+
* oldest-first; `enqueuedOf(JobClass)` additionally decodes payloads through
|
|
62
|
+
* the definition's schema.
|
|
63
|
+
*
|
|
64
|
+
* @since 0.3.2
|
|
65
|
+
*/
|
|
66
|
+
export declare class TestJobStore extends TestJobStore_base {
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A fresh in-memory store provided as BOTH the default `JobStore` (for the
|
|
70
|
+
* code under test) and the `TestJobStore` inspection service (for the
|
|
71
|
+
* assertions).
|
|
72
|
+
*
|
|
73
|
+
* @since 0.3.2
|
|
74
|
+
*/
|
|
75
|
+
export declare const layer: Layer.Layer<JobStore.JobStore | TestJobStore>;
|
|
76
|
+
/**
|
|
77
|
+
* Like `layer`, for jobs bound to a `JobStore.named(...)` key.
|
|
78
|
+
*
|
|
79
|
+
* @since 0.3.2
|
|
80
|
+
*/
|
|
81
|
+
export declare const layerFor: <Id>(store: Context.Key<Id, JobStore.Service>) => Layer.Layer<Id | TestJobStore>;
|
|
82
|
+
/**
|
|
83
|
+
* Convenience accessors so tests don't have to `yield* TestJobStore` first.
|
|
84
|
+
*
|
|
85
|
+
* @since 0.3.2
|
|
86
|
+
*/
|
|
87
|
+
export declare const enqueuedOf: <J extends AnyJobDefinition>(job: J) => Effect.Effect<Array<EnqueuedJob<J["payloadJsonSchema"]["Type"]>>, never, TestJobStore>;
|
|
88
|
+
/**
|
|
89
|
+
* Raw records (optionally filtered by job name), oldest-first.
|
|
90
|
+
*
|
|
91
|
+
* @since 0.3.2
|
|
92
|
+
*/
|
|
93
|
+
export declare const enqueued: (name?: string) => Effect.Effect<Array<JobStore.JobRecord>, never, TestJobStore>;
|
|
94
|
+
export {};
|
|
95
|
+
//# sourceMappingURL=TestJobStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TestJobStore.d.ts","sourceRoot":"","sources":["../../src/testing/TestJobStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAA;AAE1C,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAEvD;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC,GAAG,GAAG;QAAE,QAAQ,CAAC,gBAAgB,EAAE,KAAK,CAAA;KAAE,CAAA;CAC9E;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW,CAAC,OAAO,CAAE,SAAQ,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC/E,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAC1B;;;sBAmBmB,MAAM;iBACX,CAAC,SAAS,gBAAgB,OAAO,CAAC;;AAWjD;;;;;;GAMG;AACH,qBAAa,YAAa,SAAQ,iBAEjC;CAAG;AAEJ;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,GAAG,YAAY,CAK/D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,GAAI,EAAE,SAClB,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,QAAQ,CAAC,OAAO,CAAC,KACvC,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,YAAY,CAM7B,CAAA;AAEH;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,gBAAgB,OAC9C,CAAC,KACL,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,YAAY,CAC5B,CAAA;AAE5D;;;;GAIG;AACH,eAAO,MAAM,QAAQ,UACZ,MAAM,KACZ,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,YAAY,CACJ,CAAA"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test harness for asserting what your services enqueue, without running
|
|
3
|
+
* a worker.
|
|
4
|
+
*
|
|
5
|
+
* Provide `TestJobStore.layer` in a unit test and jobs enqueued by the code
|
|
6
|
+
* under test simply accumulate in `waiting`/`delayed` (nothing claims them).
|
|
7
|
+
* `enqueuedOf` returns them with payloads **decoded through the job's own
|
|
8
|
+
* schema** — you assert against the typed values your service produced, not
|
|
9
|
+
* the encoded JSON the store persists:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { TestJobStore } from "effect-mq/testing"
|
|
13
|
+
*
|
|
14
|
+
* it.effect("signup enqueues a welcome email", () =>
|
|
15
|
+
* Effect.gen(function*() {
|
|
16
|
+
* yield* SignupService.register({ email: "ada@example.com" })
|
|
17
|
+
*
|
|
18
|
+
* const emails = yield* TestJobStore.enqueuedOf(SendEmail)
|
|
19
|
+
* expect(emails).toHaveLength(1)
|
|
20
|
+
* expect(emails[0]?.payload.to).toBe("ada@example.com")
|
|
21
|
+
* expect(emails[0]?.state).toBe("waiting")
|
|
22
|
+
* }).pipe(Effect.provide(TestJobStore.layer)))
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Jobs bound to named stores use `TestJobStore.layerFor(Durable)` instead.
|
|
26
|
+
* The raw `JobStore` service is also exposed (as `.store`) for advanced
|
|
27
|
+
* scenarios — simulating claims/acks, reading `counts()`, and so on.
|
|
28
|
+
*
|
|
29
|
+
* @since 0.3.2
|
|
30
|
+
*/
|
|
31
|
+
import * as JobStore from "../JobStore.js";
|
|
32
|
+
import * as MemoryJobStore from "../MemoryJobStore.js";
|
|
33
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
34
|
+
const drainList = (store, name) => Effect.gen(function* () {
|
|
35
|
+
const all = [];
|
|
36
|
+
let cursor = undefined;
|
|
37
|
+
while (true) {
|
|
38
|
+
const page = yield* store.list({ name, cursor, limit: 200 }).pipe(Effect.orDie);
|
|
39
|
+
all.push(...page.items);
|
|
40
|
+
if (page.cursor === undefined)
|
|
41
|
+
break;
|
|
42
|
+
cursor = page.cursor;
|
|
43
|
+
}
|
|
44
|
+
// list() is newest-first; flip to oldest-first for natural reading.
|
|
45
|
+
// Ties (same-instant enqueues) order by id, not submission order.
|
|
46
|
+
return all.toReversed();
|
|
47
|
+
});
|
|
48
|
+
const makeApi = (store) => ({
|
|
49
|
+
store,
|
|
50
|
+
enqueued: (name) => drainList(store, name),
|
|
51
|
+
enqueuedOf: (job) => drainList(store, job._tag).pipe(Effect.flatMap(Effect.forEach((record) => Schema.decodeUnknownEffect(job.payloadJsonSchema)(record.payload).pipe(Effect.orDie, Effect.map((payload) => ({ ...record, payload }))))))
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Inspection API over the test store. `enqueued(name?)` returns raw records
|
|
55
|
+
* oldest-first; `enqueuedOf(JobClass)` additionally decodes payloads through
|
|
56
|
+
* the definition's schema.
|
|
57
|
+
*
|
|
58
|
+
* @since 0.3.2
|
|
59
|
+
*/
|
|
60
|
+
export class TestJobStore extends Context.Service()("effect-mq/testing/TestJobStore") {
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A fresh in-memory store provided as BOTH the default `JobStore` (for the
|
|
64
|
+
* code under test) and the `TestJobStore` inspection service (for the
|
|
65
|
+
* assertions).
|
|
66
|
+
*
|
|
67
|
+
* @since 0.3.2
|
|
68
|
+
*/
|
|
69
|
+
export const layer = Layer.effectContext(Effect.map(MemoryJobStore.makeWith(), (store) => Context.make(JobStore.JobStore, store).pipe(Context.add(TestJobStore, TestJobStore.of(makeApi(store))))));
|
|
70
|
+
/**
|
|
71
|
+
* Like `layer`, for jobs bound to a `JobStore.named(...)` key.
|
|
72
|
+
*
|
|
73
|
+
* @since 0.3.2
|
|
74
|
+
*/
|
|
75
|
+
export const layerFor = (store) => Layer.effectContext(Effect.map(MemoryJobStore.makeWith(), (memory) => Context.make(store, memory).pipe(Context.add(TestJobStore, TestJobStore.of(makeApi(memory))))));
|
|
76
|
+
/**
|
|
77
|
+
* Convenience accessors so tests don't have to `yield* TestJobStore` first.
|
|
78
|
+
*
|
|
79
|
+
* @since 0.3.2
|
|
80
|
+
*/
|
|
81
|
+
export const enqueuedOf = (job) => Effect.flatMap(TestJobStore, (api) => api.enqueuedOf(job));
|
|
82
|
+
/**
|
|
83
|
+
* Raw records (optionally filtered by job name), oldest-first.
|
|
84
|
+
*
|
|
85
|
+
* @since 0.3.2
|
|
86
|
+
*/
|
|
87
|
+
export const enqueued = (name) => Effect.flatMap(TestJobStore, (api) => api.enqueued(name));
|
|
88
|
+
//# sourceMappingURL=TestJobStore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TestJobStore.js","sourceRoot":"","sources":["../../src/testing/TestJobStore.ts"],"names":[],"mappings":"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"}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,cAAc,kBAAkB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,cAAc,kBAAkB,CAAA;AAEhC;;;;GAIG;AACH,OAAO,KAAK,YAAY,MAAM,mBAAmB,CAAA"}
|
package/dist/testing/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,cAAc,kBAAkB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,cAAc,kBAAkB,CAAA;AAEhC;;;;GAIG;AACH,OAAO,KAAK,YAAY,MAAM,mBAAmB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-mq",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Effect-native background jobs: schema-first definitions, storage-agnostic queue core, worker runtime, and a Postgres store through drizzle.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Adam Rankin",
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test harness for asserting what your services enqueue, without running
|
|
3
|
+
* a worker.
|
|
4
|
+
*
|
|
5
|
+
* Provide `TestJobStore.layer` in a unit test and jobs enqueued by the code
|
|
6
|
+
* under test simply accumulate in `waiting`/`delayed` (nothing claims them).
|
|
7
|
+
* `enqueuedOf` returns them with payloads **decoded through the job's own
|
|
8
|
+
* schema** — you assert against the typed values your service produced, not
|
|
9
|
+
* the encoded JSON the store persists:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { TestJobStore } from "effect-mq/testing"
|
|
13
|
+
*
|
|
14
|
+
* it.effect("signup enqueues a welcome email", () =>
|
|
15
|
+
* Effect.gen(function*() {
|
|
16
|
+
* yield* SignupService.register({ email: "ada@example.com" })
|
|
17
|
+
*
|
|
18
|
+
* const emails = yield* TestJobStore.enqueuedOf(SendEmail)
|
|
19
|
+
* expect(emails).toHaveLength(1)
|
|
20
|
+
* expect(emails[0]?.payload.to).toBe("ada@example.com")
|
|
21
|
+
* expect(emails[0]?.state).toBe("waiting")
|
|
22
|
+
* }).pipe(Effect.provide(TestJobStore.layer)))
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Jobs bound to named stores use `TestJobStore.layerFor(Durable)` instead.
|
|
26
|
+
* The raw `JobStore` service is also exposed (as `.store`) for advanced
|
|
27
|
+
* scenarios — simulating claims/acks, reading `counts()`, and so on.
|
|
28
|
+
*
|
|
29
|
+
* @since 0.3.2
|
|
30
|
+
*/
|
|
31
|
+
import * as JobStore from "../JobStore.ts"
|
|
32
|
+
import * as MemoryJobStore from "../MemoryJobStore.ts"
|
|
33
|
+
import { Context, Effect, Layer, Schema } from "effect"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The minimal structural view of a `Job.make` class that `enqueuedOf`
|
|
37
|
+
* needs: its tag and its JSON payload codec.
|
|
38
|
+
*
|
|
39
|
+
* @since 0.3.2
|
|
40
|
+
*/
|
|
41
|
+
export interface AnyJobDefinition {
|
|
42
|
+
readonly _tag: string
|
|
43
|
+
readonly payloadJsonSchema: Schema.Top & { readonly DecodingServices: never }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A stored job with its payload decoded back to the definition's payload
|
|
48
|
+
* type.
|
|
49
|
+
*
|
|
50
|
+
* @since 0.3.2
|
|
51
|
+
*/
|
|
52
|
+
export interface EnqueuedJob<Payload> extends Omit<JobStore.JobRecord, "payload"> {
|
|
53
|
+
readonly payload: Payload
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const drainList = (store: JobStore.Service, name?: string) =>
|
|
57
|
+
Effect.gen(function*() {
|
|
58
|
+
const all: Array<JobStore.JobRecord> = []
|
|
59
|
+
let cursor: string | undefined = undefined
|
|
60
|
+
while (true) {
|
|
61
|
+
const page: JobStore.ListResult = yield* store.list({ name, cursor, limit: 200 }).pipe(Effect.orDie)
|
|
62
|
+
all.push(...page.items)
|
|
63
|
+
if (page.cursor === undefined) break
|
|
64
|
+
cursor = page.cursor
|
|
65
|
+
}
|
|
66
|
+
// list() is newest-first; flip to oldest-first for natural reading.
|
|
67
|
+
// Ties (same-instant enqueues) order by id, not submission order.
|
|
68
|
+
return all.toReversed()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const makeApi = (store: JobStore.Service) => ({
|
|
72
|
+
store,
|
|
73
|
+
enqueued: (name?: string) => drainList(store, name),
|
|
74
|
+
enqueuedOf: <J extends AnyJobDefinition>(job: J) =>
|
|
75
|
+
drainList(store, job._tag).pipe(
|
|
76
|
+
Effect.flatMap(Effect.forEach((record) =>
|
|
77
|
+
Schema.decodeUnknownEffect(job.payloadJsonSchema)(record.payload).pipe(
|
|
78
|
+
Effect.orDie,
|
|
79
|
+
Effect.map((payload): EnqueuedJob<J["payloadJsonSchema"]["Type"]> => ({ ...record, payload }))
|
|
80
|
+
)
|
|
81
|
+
))
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Inspection API over the test store. `enqueued(name?)` returns raw records
|
|
87
|
+
* oldest-first; `enqueuedOf(JobClass)` additionally decodes payloads through
|
|
88
|
+
* the definition's schema.
|
|
89
|
+
*
|
|
90
|
+
* @since 0.3.2
|
|
91
|
+
*/
|
|
92
|
+
export class TestJobStore extends Context.Service<TestJobStore, ReturnType<typeof makeApi>>()(
|
|
93
|
+
"effect-mq/testing/TestJobStore"
|
|
94
|
+
) {}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A fresh in-memory store provided as BOTH the default `JobStore` (for the
|
|
98
|
+
* code under test) and the `TestJobStore` inspection service (for the
|
|
99
|
+
* assertions).
|
|
100
|
+
*
|
|
101
|
+
* @since 0.3.2
|
|
102
|
+
*/
|
|
103
|
+
export const layer: Layer.Layer<JobStore.JobStore | TestJobStore> = Layer.effectContext(
|
|
104
|
+
Effect.map(MemoryJobStore.makeWith(), (store) =>
|
|
105
|
+
Context.make(JobStore.JobStore, store).pipe(
|
|
106
|
+
Context.add(TestJobStore, TestJobStore.of(makeApi(store)))
|
|
107
|
+
))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Like `layer`, for jobs bound to a `JobStore.named(...)` key.
|
|
112
|
+
*
|
|
113
|
+
* @since 0.3.2
|
|
114
|
+
*/
|
|
115
|
+
export const layerFor = <Id>(
|
|
116
|
+
store: Context.Key<Id, JobStore.Service>
|
|
117
|
+
): Layer.Layer<Id | TestJobStore> =>
|
|
118
|
+
Layer.effectContext(
|
|
119
|
+
Effect.map(MemoryJobStore.makeWith(), (memory) =>
|
|
120
|
+
Context.make(store, memory).pipe(
|
|
121
|
+
Context.add(TestJobStore, TestJobStore.of(makeApi(memory)))
|
|
122
|
+
))
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Convenience accessors so tests don't have to `yield* TestJobStore` first.
|
|
127
|
+
*
|
|
128
|
+
* @since 0.3.2
|
|
129
|
+
*/
|
|
130
|
+
export const enqueuedOf = <J extends AnyJobDefinition>(
|
|
131
|
+
job: J
|
|
132
|
+
): Effect.Effect<Array<EnqueuedJob<J["payloadJsonSchema"]["Type"]>>, never, TestJobStore> =>
|
|
133
|
+
Effect.flatMap(TestJobStore, (api) => api.enqueuedOf(job))
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Raw records (optionally filtered by job name), oldest-first.
|
|
137
|
+
*
|
|
138
|
+
* @since 0.3.2
|
|
139
|
+
*/
|
|
140
|
+
export const enqueued = (
|
|
141
|
+
name?: string
|
|
142
|
+
): Effect.Effect<Array<JobStore.JobRecord>, never, TestJobStore> =>
|
|
143
|
+
Effect.flatMap(TestJobStore, (api) => api.enqueued(name))
|