diff --git a/.changeset/dual-runtime-layers.md b/.changeset/dual-runtime-layers.md new file mode 100644 index 0000000..4a97b69 --- /dev/null +++ b/.changeset/dual-runtime-layers.md @@ -0,0 +1,9 @@ +--- +"@effectmq/core": minor +--- + +Document Bun plus node-redis as a supported composition. + +`TaskEngine.layer` stays the live graph. `TaskEngine.layerNoDeps` is the +compose path for a custom `RedisPool` or `BunCrypto`. Bun's built-in +`RedisClient` is not the supported adapter yet. diff --git a/CLAUDE.md b/CLAUDE.md index 2e520d2..e17d63c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Flat `src/` with a strict layering, top to bottom: - **`TaskEvent.ts`** — public queue lifecycle event schemas. - **`EngineRecord.ts` / `MessagePack.ts` / `RetrySchedule.ts`** — internal Redis record, binary codec, and retry-schedule concepts. -Wiring: `TaskEngine.layer()` is the complete zero-requirement Node live graph and intentionally retains Redis operational services. `TaskEngine.layerNoDeps()` is the custom-client layer that requires `RedisPool`. +Wiring: `TaskEngine.layer()` is the live graph (`NodeRedisPool` + `NodeCrypto`). `TaskEngine.layerNoDeps()` requires `RedisPool` for custom clients and for Bun plus `BunCrypto`. Run the program with `NodeRuntime` or `BunRuntime`. `Worker` provides built-in bounded local concurrency, lease supervision, maintenance, and graceful draining. It does not provide distributed/global diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3461e9..3b8a043 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,9 +37,9 @@ Production modules import supported narrow `effect/*` subpaths. Public Effect-returning functions pin exact success, error, and service channels and use `Effect.fnUntraced` for reusable generator implementations. Services are `Context.Service` classes with `@effectmq/core/` identifiers; optional -fiber-local values are `Context.Reference`s. Use `TaskEngine.layerNoDeps()` for -custom Redis composition and reserve `TaskEngine.layer()` for the complete Node -live graph. +fiber-local values are `Context.Reference`s. Use `TaskEngine.layer()` for the +live graph. Use `TaskEngine.layerNoDeps()` when the application supplies +`RedisPool`. Edit `src/lua/taskEngine.lua`, then run `pnpm gen:lua`; never hand-edit the generated TypeScript module. CI rejects generated drift. Add committed golden diff --git a/README.md b/README.md index 085ff5c..23fb4f0 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,18 @@ a scheduled report. It provides: ## Install ```bash +# Node pnpm add @effectmq/core@0.3.0-rc.0 effect@4.0.0-beta.107 @effect/platform-node@4.0.0-beta.107 + +# Bun (node-redis is bundled; add platform-bun for BunRuntime and BunCrypto) +bun add @effectmq/core@0.3.0-rc.0 effect@4.0.0-beta.107 @effect/platform-bun@4.0.0-beta.107 ``` > [!IMPORTANT] > effectmq currently targets the Effect 4 beta and is not compatible with the > stable Effect 3 release. Pin the versions shown above. Node.js 22.19 or newer -> is required; CI verifies Node.js 22 and 24. - -The package includes its pooled `NodeRedisPool` implementation. -`@effect/platform-node` is only needed by these examples for `NodeRuntime`. +> is required; CI verifies Node.js 22 and 24. Bun plus node-redis is a +> supported composition. It is not yet a CI platform. --- @@ -78,9 +80,9 @@ Redis startup and expected output, follow the ## Runtime setup -`TaskEngine.layer()` is the complete Node live graph: it provides the engine, -cryptographic identity generation, and the retained Redis pool, role, and -health services: +`TaskEngine.layer()` is the live graph: engine, cryptographic identity, and +the Redis pool, role, and health services. Run it with `NodeRuntime` or +`BunRuntime`. The Redis adapter stays `NodeRedisPool` on both. ```ts import { TaskEngine } from "@effectmq/core"; @@ -90,8 +92,30 @@ const AppLayer = TaskEngine.layer({ }); ``` -Use `TaskEngine.layerNoDeps()` when composing a custom `RedisPool` -implementation. `NodeRedisPool.layer()` remains available independently and +Bun plus node-redis uses the same `TaskEngine.layer` call and +`BunRuntime.runMain`. Swap `NodeCrypto` for `BunCrypto` when you compose +yourself through `TaskEngine.layerNoDeps()`. Bun's built-in `RedisClient` +is not the supported adapter yet. It has no binary `send`. + +```ts +import { Effect, Layer } from "effect"; +import { BunCrypto, BunRuntime } from "@effect/platform-bun"; +import { NodeRedisPool, TaskEngine } from "@effectmq/core"; + +const AppLayer = TaskEngine.layerNoDeps().pipe( + Layer.provideMerge( + Layer.merge( + NodeRedisPool.layer({ url: "redis://localhost:6379" }), + BunCrypto.layer, + ), + ), +); + +Effect.void.pipe(Effect.provide(AppLayer), BunRuntime.runMain); +``` + +Use `TaskEngine.layerNoDeps()` when you bring your own `RedisPool`. +`NodeRedisPool.layer()` remains available independently and accepts node-redis client options. It establishes separate producer, worker, and maintenance pools when the Layer starts. It supports standalone Redis and Sentinel; Redis Cluster fails startup because queue transitions use multi-key diff --git a/apps/docs/content/docs/reference/redis-and-runtime.mdx b/apps/docs/content/docs/reference/redis-and-runtime.mdx index 190dc4d..1897bae 100644 --- a/apps/docs/content/docs/reference/redis-and-runtime.mdx +++ b/apps/docs/content/docs/reference/redis-and-runtime.mdx @@ -10,8 +10,8 @@ TaskEngine.layer({ engine?, redis? }): Layer ``` Provides `TaskEngine`, `RedisPool`, `RedisConnectionRoles`, -`RedisConnectionHealth`, Effect Redis, and Crypto. It is the standard Node.js -live graph. +`RedisConnectionHealth`, Effect Redis, and Crypto. Run it with `NodeRuntime` +or `BunRuntime`. Engine configuration: @@ -27,8 +27,25 @@ Engine configuration: TaskEngine.layerNoDeps(config?): Layer ``` -Requires an ambient custom `RedisPool`. It does not provide connection roles, -health, Effect Redis, or Crypto. +Requires an ambient `RedisPool`. It does not provide connection roles, health, +Effect Redis, or Crypto. + +Bun plus `BunCrypto` is this composition: + +```ts +import { Layer } from "effect" +import { BunCrypto } from "@effect/platform-bun" +import { NodeRedisPool, TaskEngine } from "@effectmq/core" + +const AppLayer = TaskEngine.layerNoDeps().pipe( + Layer.provideMerge( + Layer.merge( + NodeRedisPool.layer({ url: "redis://127.0.0.1:6379" }), + BunCrypto.layer + ) + ) +) +``` ## `NodeRedisPool.layer` diff --git a/apps/docs/content/docs/tutorials/getting-started.mdx b/apps/docs/content/docs/tutorials/getting-started.mdx index d343b17..6143457 100644 --- a/apps/docs/content/docs/tutorials/getting-started.mdx +++ b/apps/docs/content/docs/tutorials/getting-started.mdx @@ -73,14 +73,8 @@ Create `src/main.ts`: ```ts import { NodeRuntime } from "@effect/platform-node" -import { - NodeRedisPool, - Task, - TaskEngine, - TaskQueue, - Worker -} from "@effectmq/core" -import { Console, Effect, Layer, Schema } from "effect" +import { Task, TaskEngine, TaskQueue, Worker } from "@effectmq/core" +import { Console, Effect, Schema } from "effect" const Greet = Task.make({ name: "greet", @@ -93,11 +87,9 @@ const Greet = Task.make({ const greetings = TaskQueue.make("tutorial-greetings", Greet) -const EngineLive = TaskEngine.layer().pipe( - Layer.provideMerge( - NodeRedisPool.layer({ url: "redis://127.0.0.1:6379" }) - ) -) +const EngineLive = TaskEngine.layer({ + redis: { url: "redis://127.0.0.1:6379" } +}) const worker = Worker.make( greetings, diff --git a/docs/api-reference.md b/docs/api-reference.md index 8b12134..568c59e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -72,10 +72,13 @@ coordination, ordinary removal, and administrative force removal. Prefer `TaskQueue`, `Worker`, and `Scheduler` unless building tooling or an alternate runtime. -- `TaskEngine.layer(config?)` is the zero-requirement Node live graph and retains - Redis operational services in its output. -- `TaskEngine.layerNoDeps(config?)` requires an ambient `RedisPool` for custom - client compositions. +- `TaskEngine.layer({ engine?, redis? })` is the live graph and retains Redis + operational services plus Crypto in its output. +- `TaskEngine.layerNoDeps(config?)` requires an ambient `RedisPool`. Use it for + a custom client or for Bun plus `BunCrypto`. +- Bun plus node-redis is `TaskEngine.layer` plus `BunRuntime`, or + `TaskEngine.layerNoDeps` composed with `NodeRedisPool.layer` and + `BunCrypto.layer`. - Invalid configuration and Redis reply shapes use structured typed errors; diagnostic strings are retained only as causes. @@ -93,6 +96,6 @@ version, schema, value, size, and count errors. `Observability` exports Effect metrics for depth/age/backlogs, Redis errors/reconnects/script reloads, ownership loss, and retention failure. -Stable subpaths are `./NodeRedisPool`, `./Observability`, `./RedisPool`, -`./Scheduler`, `./StorageProtocol`, `./Task`, `./TaskEngine`, `./TaskEvent`, -`./TaskQueue`, `./TaskRecord`, and `./Worker`. +Stable subpaths are `./NodeRedisPool`, `./Observability`, +`./RedisPool`, `./Scheduler`, `./StorageProtocol`, `./Task`, `./TaskEngine`, +`./TaskEvent`, `./TaskQueue`, `./TaskRecord`, and `./Worker`. diff --git a/docs/architecture.md b/docs/architecture.md index 8f2fb82..11c2785 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,9 +7,10 @@ package subpaths: processing, and scheduling APIs; - `TaskRecord` owns durable typed task records and `TaskEvent` owns lifecycle events; -- `TaskEngine` owns atomic queue storage behavior; -- `RedisPool`, `NodeRedisPool`, `StorageProtocol`, and `Observability` own the - external client, live Node adapter, value protocol, and metrics boundaries. +- `TaskEngine` owns atomic queue storage behavior and the live graph; +- `RedisPool`, `NodeRedisPool`, `StorageProtocol`, and + `Observability` own the external client, node-redis adapter, value + protocol, and metrics boundaries. Internal modules are deliberately not package subpaths. `MessagePack` owns the binary transform, `EngineRecord` owns Redis-facing record schemas, @@ -27,11 +28,14 @@ TaskEvent -> TaskRecord + EngineRecord + MessagePack NodeRedisPool -> RedisPool + RedisReadiness + Observability ``` -`TaskEngine.layer()` is the standard zero-requirement Node live graph. It -retains `TaskEngine`, `RedisPool`, `RedisConnectionRoles`, -`RedisConnectionHealth`, Effect Redis, and Crypto services. Custom Redis +`TaskEngine.layer()` is the live graph. It retains `TaskEngine`, `RedisPool`, +`RedisConnectionRoles`, `RedisConnectionHealth`, Effect Redis, and Crypto. +`TaskEngine.layerNoDeps()` requires an ambient `RedisPool`. Bun plus +node-redis uses `TaskEngine.layer` with `BunRuntime`, or `layerNoDeps` +composed with `NodeRedisPool.layer` and `BunCrypto.layer`. Custom Redis integrations provide `RedisPool` to `TaskEngine.layerNoDeps()`. Public queue declarations use named exact aliases for success, typed failure, and required services. The strict test compiler pins `complete`, `completeOne`, -`decodeTask`, `wait`, `execute`, and both TaskEngine layer modes. +`decodeTask`, `wait`, `execute`, both TaskEngine layer modes, and the Bun plus +node-redis composition. diff --git a/openspec/specs/effect-module-architecture/spec.md b/openspec/specs/effect-module-architecture/spec.md index 55a4a5e..b494fde 100644 --- a/openspec/specs/effect-module-architecture/spec.md +++ b/openspec/specs/effect-module-architecture/spec.md @@ -41,16 +41,20 @@ Project runtime services SHALL use class-based service declarations with stable - **THEN** it receives the documented absent default without requiring an extra layer ### Requirement: Service layers communicate dependency ownership -A service module SHALL expose `layerNoDeps` for construction that still requires upstream services and `layer` for the standard live composition with those dependencies supplied. The live layer SHALL retain upstream outputs only when they are intentionally part of its documented public service graph. +`TaskEngine.layer` SHALL provide the live graph and SHALL retain Redis operational services plus Crypto. `TaskEngine.layerNoDeps` SHALL require an ambient `RedisPool` and SHALL NOT select a process runtime or Redis client. A Bun plus node-redis application SHALL run `TaskEngine.layer` under `BunRuntime`, or compose `TaskEngine.layerNoDeps` with `NodeRedisPool.layer` and `BunCrypto.layer`. #### Scenario: Application supplies a custom Redis pool -- **WHEN** an application uses the dependency-free engine layer constructor +- **WHEN** an application uses `TaskEngine.layer` or `TaskEngine.layerNoDeps` - **THEN** the type system requires the Redis pool service from the application -#### Scenario: Application uses the standard live layer -- **WHEN** an application uses the fully wired engine layer +#### Scenario: Application uses the live layer +- **WHEN** an application uses `TaskEngine.layer` - **THEN** it receives the documented engine and Redis operational services with no unresolved requirements +#### Scenario: Application uses Bun with node-redis +- **WHEN** an application composes `TaskEngine.layerNoDeps` with `NodeRedisPool.layer` and `BunCrypto.layer` +- **THEN** the composed layer has no unresolved requirements + ### Requirement: Effect implementation style preserves contracts Reusable Effectful functions SHALL use the project's traceable function wrapper convention and public or recursive functions SHALL declare exact return contracts. Production modules SHALL import Effect APIs through stable narrow subpaths. diff --git a/package.json b/package.json index c1217e5..6e73287 100644 --- a/package.json +++ b/package.json @@ -113,11 +113,18 @@ "author": "", "license": "MIT", "peerDependencies": { + "@effect/platform-bun": ">=4.0.0-beta.107", "effect": ">=4.0.0-beta.107" }, + "peerDependenciesMeta": { + "@effect/platform-bun": { + "optional": true + } + }, "devDependencies": { "@biomejs/biome": "2.5.1", "@changesets/cli": "^2.31.0", + "@effect/platform-bun": "4.0.0-beta.107", "@effect/vitest": "4.0.0-beta.107", "@testcontainers/redis": "^12.0.3", "@types/node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41c9a41..f69298c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@changesets/cli': specifier: ^2.31.0 version: 2.31.0(@types/node@22.20.0) + '@effect/platform-bun': + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107) '@effect/vitest': specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) @@ -223,6 +226,11 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@effect/platform-bun@4.0.0-beta.107': + resolution: {integrity: sha512-nDKutCpgr+xHQX7tgN8Cq6JXtj96GqiElKaJ7AwAkZYl2q9f6onc/3aJgl6CyH/DlUoidw6YaxeU+UaTRmMN1g==} + peerDependencies: + effect: ^4.0.0-beta.107 + '@effect/platform-node-shared@4.0.0-rc.110': resolution: {integrity: sha512-P8EZloxS7RtCOSL3VSBPyqSenUm93xLbXf9jkWoy67cibZ2wgZ9nAou9iN8f1i4vzgxUsWtDuy6BEmg67np6Zw==} engines: {node: '>=18.0.0'} @@ -3945,6 +3953,14 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@effect/platform-bun@4.0.0-beta.107(effect@4.0.0-beta.107)': + dependencies: + '@effect/platform-node-shared': 4.0.0-rc.110(effect@4.0.0-beta.107) + effect: 4.0.0-beta.107 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@effect/platform-node-shared@4.0.0-rc.110(effect@4.0.0-beta.107)': dependencies: '@types/ws': 8.18.1 diff --git a/scripts/check-architecture.ts b/scripts/check-architecture.ts index 3148f15..2a1d333 100644 --- a/scripts/check-architecture.ts +++ b/scripts/check-architecture.ts @@ -25,6 +25,17 @@ const visit = (directory: string) => { if (/\bDate\.now\(\)|\bcrypto\.randomUUID\(/.test(text)) { failures.push(`${relative(root, path)} reads ambient time or randomness`); } + const rel = relative(root, path); + const mayImportRuntime = + rel === "src/TaskEngine.ts" || + rel === "src/NodeRedisPool.ts" || + rel.startsWith("src/cli/"); + if ( + !mayImportRuntime && + /from ["'](?:redis|@effect\/platform-node(?:\/[^"']*)?)["']/.test(text) + ) { + failures.push(`${rel} imports redis or @effect/platform-node`); + } } }; diff --git a/src/PublicContracts.test.ts b/src/PublicContracts.test.ts index f72efad..d017f10 100644 --- a/src/PublicContracts.test.ts +++ b/src/PublicContracts.test.ts @@ -1,9 +1,13 @@ +import * as BunCrypto from "@effect/platform-bun/BunCrypto"; import * as Context from "effect/Context"; +import type * as Crypto from "effect/Crypto"; import type * as Effect from "effect/Effect"; -import type * as Layer from "effect/Layer"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import type * as Stream from "effect/Stream"; +import type * as Redis from "effect/unstable/persistence/Redis"; import { expect, it } from "vitest"; +import * as NodeRedisPool from "./NodeRedisPool.js"; import type * as RedisPool from "./RedisPool.js"; import * as TaskEngine from "./TaskEngine.js"; import * as TaskQueue from "./TaskQueue.js"; @@ -150,12 +154,29 @@ const compilePublicContracts = () => { const layerNoDeps = TaskEngine.layerNoDeps(); const liveLayer = TaskEngine.layer(); + const bunNodeRedisLayer = TaskEngine.layerNoDeps().pipe( + Layer.provideMerge(Layer.merge(NodeRedisPool.layer(), BunCrypto.layer)), + ); type LayerNoDepsRequirement = Expect< Equal, RedisPool.RedisPool> >; type LiveLayerRequirement = Expect< Equal, never> >; + type LiveLayerSuccess = Expect< + Equal< + Layer.Success, + | TaskEngine.TaskEngine + | RedisPool.RedisPool + | RedisPool.RedisConnectionRoles + | NodeRedisPool.RedisConnectionHealth + | Redis.Redis + | Crypto.Crypto + > + >; + type BunNodeRedisRequirement = Expect< + Equal, never> + >; return undefined as unknown as | CompleteSuccess @@ -179,7 +200,9 @@ const compilePublicContracts = () => { | RejectAnyServices | FailedEventError | LayerNoDepsRequirement - | LiveLayerRequirement; + | LiveLayerRequirement + | LiveLayerSuccess + | BunNodeRedisRequirement; }; it("pins public Effect and Layer channels at compile time", () => { diff --git a/src/TaskEngine.ts b/src/TaskEngine.ts index 5fb0604..cbe0494 100644 --- a/src/TaskEngine.ts +++ b/src/TaskEngine.ts @@ -20,6 +20,7 @@ import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import type * as Redis from "effect/unstable/persistence/Redis"; import { type EngineTask, type EngineTaskInsert, @@ -29,9 +30,13 @@ import { } from "./EngineRecord.js"; import taskEngineScript from "./lua/taskEngine.js"; import { UnknownFromMsgpack } from "./MessagePack.js"; -import * as NodeRedisPool from "./NodeRedisPool.js"; import * as Observability from "./Observability.js"; -import { RedisPool, type RedisPoolService } from "./RedisPool.js"; +import * as NodeRedisPool from "./NodeRedisPool.js"; +import { + type RedisConnectionRoles, + RedisPool, + type RedisPoolService, +} from "./RedisPool.js"; import { type Event, EventSchema } from "./TaskEvent.js"; const TypeId = "~effectmq/TaskEngine" as const; @@ -1346,8 +1351,8 @@ export const make = (config?: TaskEngineConfig) => }); /** - * Provides {@link TaskEngine} from an ambient {@link RedisPool} service. - * Use this for custom Redis implementations and test layers. + * Provides {@link TaskEngine} from an ambient {@link RedisPool}. + * Use this for custom Redis implementations, tests, and Bun plus node-redis. * * @category Layers * @since 0.1.0 @@ -1355,17 +1360,38 @@ export const make = (config?: TaskEngineConfig) => export const layerNoDeps = (config?: TaskEngineConfig) => Layer.effect(TaskEngine, make(config)); -/** Configuration for the standard Node.js live service graph. */ +/** + * Configuration for the standard live service graph. + * + * @category Configuration + * @since 0.1.0 + */ export interface LiveConfig { readonly engine?: TaskEngineConfig; readonly redis?: NodeRedisPool.RedisConfig; } /** - * Provides a complete Node.js live graph: Redis connections, connection - * roles and health, Crypto, and the task engine. + * Provides the live graph: Redis connections, connection roles and health, + * Crypto, and the task engine. + * + * @category Layers + * @since 0.1.0 */ -export const layer = (config: LiveConfig = {}) => +export const layer = ( + config: LiveConfig = {}, +): Layer.Layer< + | TaskEngine + | RedisPool + | RedisConnectionRoles + | NodeRedisPool.RedisConnectionHealth + | Redis.Redis + | Crypto.Crypto, + | TaskEngineConfigurationError + | Redis.RedisError + | NodeRedisPool.UnsupportedRedisTopology + | NodeRedisPool.InvalidRedisConfiguration +> => layerNoDeps(config.engine).pipe( Layer.provideMerge( Layer.merge(NodeRedisPool.layer(config.redis), NodeCrypto.layer),