Merge pull request 'feat/bunch-of-changes' (#29) from feat/bunch-of-changes into main
All checks were successful
Renovate / renovate (push) Successful in 1m22s

Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
2026-02-21 14:18:57 +00:00
10 changed files with 467 additions and 406 deletions

View File

@@ -3,21 +3,12 @@
/** /**
* Console Entry Point * Console Entry Point
*
* Bootstraps the application container and registers console commands
* from core and modules using lazy loading via Symfony Console.
*/ */
declare(strict_types=1); declare(strict_types=1);
use KTXC\Application; use KTXC\Server;
use KTXC\Kernel;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Command\LazyCommand;
// Check dependencies
if (!is_dir(dirname(__DIR__).'/vendor')) { if (!is_dir(dirname(__DIR__).'/vendor')) {
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n"); fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
exit(1); exit(1);
@@ -26,92 +17,12 @@ if (!is_dir(dirname(__DIR__).'/vendor')) {
require_once dirname(__DIR__).'/vendor/autoload.php'; require_once dirname(__DIR__).'/vendor/autoload.php';
try { try {
// Bootstrap the application $server = new Server(dirname(__DIR__));
$projectRoot = dirname(__DIR__); exit($server->runConsole());
$app = new Application($projectRoot);
// Boot kernel to initialize container and modules
$app->kernel()->boot();
// Get the container
$container = $app->container();
// Create Symfony Console Application
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
// Collect all command classes
$commandClasses = [];
// Collect commands from modules
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$moduleInstance = $module->instance();
// Skip if module instance is not available
if ($moduleInstance === null) {
continue;
}
// Check if module implements console command provider
if ($moduleInstance instanceof ModuleConsoleInterface) {
try {
$commands = $moduleInstance->registerCI();
foreach ($commands as $commandClass) {
if (!class_exists($commandClass)) {
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
continue;
}
$commandClasses[] = $commandClass;
}
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
}
}
}
// Register commands using lazy loading
foreach ($commandClasses as $commandClass) {
try {
// Use reflection to read #[AsCommand] attribute without instantiation
$reflection = new \ReflectionClass($commandClass);
$attributes = $reflection->getAttributes(\Symfony\Component\Console\Attribute\AsCommand::class);
if (empty($attributes)) {
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
continue;
}
// Get attribute instance
/** @var \Symfony\Component\Console\Attribute\AsCommand $commandAttr */
$commandAttr = $attributes[0]->newInstance();
// Create lazy command wrapper that defers instantiation
$lazyCommand = new LazyCommand(
$commandAttr->name,
[],
$commandAttr->description ?? '',
$commandAttr->hidden ?? false,
fn() => $container->get($commandClass) // Only instantiate when executed
);
$console->add($lazyCommand);
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
}
}
// Run the console application
$exitCode = $console->run();
exit($exitCode);
} catch (\Throwable $e) { } catch (\Throwable $e) {
fwrite(STDERR, "Fatal error: " . $e->getMessage() . "\n"); fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
if (isset($app) && $app->debug()) { if (isset($server) && $server->debug()) {
fwrite(STDERR, $e->getTraceAsString() . "\n"); fwrite(STDERR, $e->getTraceAsString()."\n");
} }
exit(1); exit(1);
} }

View File

@@ -1,216 +0,0 @@
<?php
namespace KTXC;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use Psr\Container\ContainerInterface;
/**
* Application class - entry point for the framework
* Handles configuration loading and kernel lifecycle
*/
class Application
{
private static $composerLoader = null;
private Kernel $kernel;
private array $config;
private string $rootDir;
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
{
$this->rootDir = $this->resolveProjectRoot($rootDir);
// Load configuration
$this->config = $this->loadConfig();
// Determine environment and debug mode
$environment = $environment ?? $this->config['environment'] ?? 'prod';
$debug = $debug ?? $this->config['debug'] ?? false;
// Create kernel with configuration
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
}
/**
* Run the application - handle incoming request and send response
*/
public function run(): void
{
try {
$request = Request::createFromGlobals();
$response = $this->handle($request);
$response->send();
$this->terminate();
} catch (\Throwable $e) {
// Last resort error handling for kernel initialization failures
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
$content = $this->kernel->debug()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: 'An error occurred. Please try again later.';
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
$response->send();
exit(1);
}
}
/**
* Handle a request
*/
public function handle(Request $request): Response
{
return $this->kernel->handle($request);
}
/**
* Terminate the application - process deferred events
*/
public function terminate(): void
{
$this->kernel->processEvents();
}
/**
* Get the kernel instance
*/
public function kernel(): Kernel
{
return $this->kernel;
}
/**
* Get the container instance
*/
public function container(): ContainerInterface
{
return $this->kernel->container();
}
/**
* Get the application root directory
*/
public function rootDir(): string
{
return $this->rootDir;
}
/**
* Get the modules directory
*/
public function moduleDir(): string
{
return $this->rootDir . '/modules';
}
/**
* Get configuration value
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
// Support dot notation: 'database.uri'
$keys = explode('.', $key);
$value = $this->config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get environment
*/
public function environment(): string
{
return $this->kernel->environment();
}
/**
* Check if debug mode is enabled
*/
public function debug(): bool
{
return $this->kernel->debug();
}
/**
* Load configuration from config directory
*/
protected function loadConfig(): array
{
$configFile = $this->rootDir . '/config/system.php';
if (!file_exists($configFile)) {
error_log('Configuration file not found: ' . $configFile);
return [];
}
$config = include $configFile;
if (!is_array($config)) {
throw new \RuntimeException('Configuration file must return an array');
}
return $config;
}
/**
* Resolve the project root directory.
*
* Some entrypoints may pass the public/ directory or another subdirectory.
* We walk up the directory tree until we find composer.json.
*/
private function resolveProjectRoot(string $startDir): string
{
$dir = rtrim($startDir, '/');
if ($dir === '') {
return $startDir;
}
// If startDir is a file path, use its directory.
if (is_file($dir)) {
$dir = dirname($dir);
}
$current = $dir;
while (true) {
if (is_file($current . '/composer.json')) {
return $current;
}
$parent = dirname($current);
if ($parent === $current) {
// Reached filesystem root
return $dir;
}
$current = $parent;
}
}
/**
* Set the Composer ClassLoader instance
*/
public static function setComposerLoader($loader): void
{
self::$composerLoader = $loader;
}
/**
* Get the Composer ClassLoader instance
*/
public static function getComposerLoader()
{
return self::$composerLoader;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Response;
/**
* StreamedNdJsonResponse streams an HTTP response as Newline Delimited JSON (NDJSON).
*
* Each item yielded by the provided iterable is serialized as a single JSON value
* followed by a newline character (\n). The response is flushed to the client every
* $flushInterval items so consumers can process records incrementally without waiting
* for the full payload.
*
* Content-Type is set to `application/x-ndjson` and `X-Accel-Buffering: no` is added
* by default to disable nginx proxy buffering.
*
* Example usage:
*
* function records(): \Generator {
* yield ['id' => 1, 'name' => 'Alice'];
* yield ['id' => 2, 'name' => 'Bob'];
* }
*
* return new StreamedNdJsonResponse(records());
*/
class StreamedNdJsonResponse extends StreamedResponse
{
/**
* @param iterable<mixed> $items Items to serialize; each becomes one JSON line
* @param int $flushInterval Flush to client after this many items (default 10)
* @param int $status HTTP status code (default 200)
* @param array<string, string|string[]> $headers Additional HTTP headers
* @param int $encodingOptions Flags passed to json_encode()
*/
public function __construct(
iterable $items,
int $flushInterval = 10,
int $status = 200,
array $headers = [],
private readonly int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
) {
parent::__construct(null, $status, $headers);
if (!$this->headers->get('Content-Type')) {
$this->headers->set('Content-Type', 'application/x-ndjson');
}
if (!$this->headers->has('X-Accel-Buffering')) {
$this->headers->set('X-Accel-Buffering', 'no');
}
$encodingOptions = $this->encodingOptions;
$this->setCallback(static function () use ($items, $flushInterval, $encodingOptions): void {
$count = 0;
foreach ($items as $item) {
echo json_encode($item, \JSON_THROW_ON_ERROR | $encodingOptions) . "\n";
$count++;
if ($count >= $flushInterval) {
@ob_flush();
flush();
$count = 0;
}
}
// final flush for any remaining buffered items
if ($count > 0) {
@ob_flush();
flush();
}
});
}
}

View File

@@ -26,7 +26,7 @@ class FileLogger implements LoggerInterface
if (!is_dir($logDir)) { if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true); @mkdir($logDir, 0775, true);
} }
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log'; $this->logFile = rtrim($logDir, '/').'/'.$channel.'.jsonl';
} }
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); } public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }

View File

@@ -0,0 +1,62 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
/**
* Simple file-based PSR-3 logger that writes plain-text lines.
*
* Each entry is formatted as:
* 2026-02-20 12:34:56.123456 message text
*/
class PlainFileLogger implements LoggerInterface
{
private string $logFile;
/**
* @param string $logDir Directory where log files are written
* @param string $channel Logical channel name (used in filename)
*/
public function __construct(string $logDir, string $channel = 'app')
{
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$this->logFile = rtrim($logDir, '/') . '/' . $channel . '.log';
}
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
public function log($level, $message, array $context = []): void
{
$dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));
$timestamp = $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s');
$line = $timestamp . ' ' . $this->interpolate((string) $message, $context) . PHP_EOL;
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
}
private function interpolate(string $message, array $context): string
{
if (!str_contains($message, '{')) {
return $message;
}
$replace = [];
foreach ($context as $key => $val) {
if (is_array($val) || (is_object($val) && !method_exists($val, '__toString'))) {
continue;
}
$replace['{' . $key . '}'] = (string) $val;
}
return strtr($message, $replace);
}
}

View File

@@ -2,91 +2,310 @@
namespace KTXC; namespace KTXC;
use KTXC\Injection\Container; use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Module\ModuleAutoloader;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
/** /**
* Legacy Server class - now a facade to Application * Server class - entry point for the framework
* @deprecated Use Application class directly * Handles configuration loading and kernel lifecycle
*/ */
class Server class Server
{ {
public const ENVIRONMENT_DEV = 'dev'; private static $composerLoader = null;
public const ENVIRONMENT_PROD = 'prod'; private static ?self $instance = null;
private Kernel $kernel;
private array $config;
private string $rootDir;
/** public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
* @deprecated Use Application instead
*/
public static function run(): void {
trigger_error('Server::run() is deprecated. Use Application class instead.', E_USER_DEPRECATED);
$projectRoot = dirname(dirname(__DIR__));
$app = new Application($projectRoot);
$app->run();
}
/**
* @deprecated Use Application::getInstance()->environment()
*/
public static function environment(): string {
return self::app()->environment();
}
/**
* @deprecated Use Application::getInstance()->debug()
*/
public static function debug(): bool {
return self::app()->debug();
}
/**
* @deprecated Use Application::getInstance()->kernel()
*/
public static function runtimeKernel(): Kernel {
return self::app()->kernel();
}
/**
* @deprecated Use Application::getInstance()->container()
*/
public static function runtimeContainer(): Container {
return self::app()->container();
}
/**
* @deprecated Use Application::getInstance()->rootDir()
*/
public static function runtimeRootLocation(): string {
return self::app()->rootDir();
}
/**
* @deprecated Use Application::getInstance()->moduleDir()
*/
public static function runtimeModuleLocation(): string {
return self::app()->moduleDir();
}
/**
* @deprecated Use Application::setComposerLoader()
*/
public static function setComposerLoader($loader): void {
Application::setComposerLoader($loader);
}
/**
* @deprecated Use Application::getComposerLoader()
*/
public static function getComposerLoader() {
return Application::getComposerLoader();
}
private static function app(): Application
{ {
throw new \RuntimeException( self::$instance = $this;
'Server class is deprecated and no longer functional. ' .
'Update your code to use Application class with proper dependency injection. ' . $this->rootDir = $this->resolveProjectRoot($rootDir);
'See the migration guide for details.'
); // Load configuration
$this->config = $this->loadConfig();
// Determine environment and debug mode
$environment = $environment ?? $this->config['environment'] ?? 'prod';
$debug = $debug ?? $this->config['debug'] ?? false;
// Create kernel with configuration
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
// Register module autoloader for both HTTP and CLI contexts
$moduleAutoloader = new ModuleAutoloader($this->moduleDir());
$moduleAutoloader->register();
} }
/**
* Run the application - handle incoming request and send response
*/
public function runHttp(): void
{
try {
$request = Request::createFromGlobals();
$response = $this->handle($request);
$response->send();
$this->terminate();
} catch (\Throwable $e) {
// Last resort error handling for kernel initialization failures
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
$content = $this->kernel->debug()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: 'An error occurred. Please try again later.';
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
$response->send();
exit(1);
}
}
/**
* Run as a console application (CLI runtime).
*/
public function runConsole(): int
{
$this->kernel()->boot();
$container = $this->container();
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$instance = $module->instance();
if (!$instance instanceof ModuleConsoleInterface) {
continue;
}
try {
foreach ($instance->registerCI() as $commandClass) {
if (!class_exists($commandClass)) {
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
continue;
}
$this->registerLazyCommand($console, $container, $commandClass);
}
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
}
}
return $console->run();
}
/**
* Handle a request
*/
public function handle(Request $request): Response
{
return $this->kernel->handle($request);
}
/**
* Terminate the application - process deferred events
*/
public function terminate(): void
{
$this->kernel->processEvents();
}
/**
* Get the kernel instance
*/
public function kernel(): Kernel
{
return $this->kernel;
}
/**
* Get the container instance
*/
public function container(): ContainerInterface
{
return $this->kernel->container();
}
/**
* Get the application root directory
*/
public function rootDir(): string
{
return $this->rootDir;
}
/**
* Get the modules directory
*/
public function moduleDir(): string
{
return $this->rootDir . '/modules';
}
public function varDir(): string
{
return $this->rootDir . '/var';
}
public function logDir(): string
{
return $this->varDir() . '/logs';
}
/**
* Get configuration value
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
// Support dot notation: 'database.uri'
$keys = explode('.', $key);
$value = $this->config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get environment
*/
public function environment(): string
{
return $this->kernel->environment();
}
/**
* Check if debug mode is enabled
*/
public function debug(): bool
{
return $this->kernel->debug();
}
/**
* Load configuration from config directory
*/
protected function loadConfig(): array
{
$configFile = $this->rootDir . '/config/system.php';
if (!file_exists($configFile)) {
error_log('Configuration file not found: ' . $configFile);
return [];
}
$config = include $configFile;
if (!is_array($config)) {
throw new \RuntimeException('Configuration file must return an array');
}
return $config;
}
/**
* Resolve the project root directory.
*
* Some entrypoints may pass the public/ directory or another subdirectory.
* We walk up the directory tree until we find composer.json.
*/
private function resolveProjectRoot(string $startDir): string
{
$dir = rtrim($startDir, '/');
if ($dir === '') {
return $startDir;
}
// If startDir is a file path, use its directory.
if (is_file($dir)) {
$dir = dirname($dir);
}
$current = $dir;
while (true) {
if (is_file($current . '/composer.json')) {
return $current;
}
$parent = dirname($current);
if ($parent === $current) {
// Reached filesystem root
return $dir;
}
$current = $parent;
}
}
/**
* Set the Composer ClassLoader instance
*/
public static function setComposerLoader($loader): void
{
self::$composerLoader = $loader;
}
/**
* Get the Composer ClassLoader instance
*/
public static function getComposerLoader()
{
return self::$composerLoader;
}
/**
* Get the current Application instance
*/
public static function getInstance(): ?self
{
return self::$instance;
}
/**
* Register a single command via lazy loading using its #[AsCommand] attribute.
*/
private function registerLazyCommand(
ConsoleApplication $console,
ContainerInterface $container,
string $commandClass
): void {
try {
$ref = new \ReflectionClass($commandClass);
$attrs = $ref->getAttributes(AsCommand::class);
if (empty($attrs)) {
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
return;
}
$attr = $attrs[0]->newInstance();
$console->add(new LazyCommand(
$attr->name,
[],
$attr->description ?? '',
$attr->hidden ?? false,
fn() => $container->get($commandClass)
));
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
}
}
} }

View File

@@ -1,22 +1,11 @@
<?php <?php
use KTXC\Application; use KTXC\Server;
use KTXC\Module\ModuleAutoloader;
// Capture Composer ClassLoader instance // Capture Composer ClassLoader instance for compatibility
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php'; $composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
// Determine project root (one level up from this file) $server = new Server(dirname(__DIR__));
$projectRoot = dirname(__DIR__); Server::setComposerLoader($composerLoader);
// Create and run application $server->runHttp();
$app = new Application($projectRoot);
// Store composer loader for compatibility
Application::setComposerLoader($composerLoader);
// Register custom module autoloader for lazy loading
$moduleAutoloader = new ModuleAutoloader($app->moduleDir());
$moduleAutoloader->register();
$app->run();

View File

@@ -391,6 +391,9 @@ abstract class MessagePropertiesBaseAbstract extends NodePropertiesBaseAbstract
if (!empty($this->data['attachments'])) { if (!empty($this->data['attachments'])) {
$data[self::JSON_PROPERTY_ATTACHMENTS] = $this->data['attachments']; $data[self::JSON_PROPERTY_ATTACHMENTS] = $this->data['attachments'];
} }
if (!empty($this->data['flags'])) {
$data[self::JSON_PROPERTY_FLAGS] = $this->data['flags'];
}
$data[self::JSON_PROPERTY_SUBJECT] = $this->data['subject'] ?? null; $data[self::JSON_PROPERTY_SUBJECT] = $this->data['subject'] ?? null;
$data[self::JSON_PROPERTY_BODY] = $this->data['body'] ?? null; $data[self::JSON_PROPERTY_BODY] = $this->data['body'] ?? null;

View File

@@ -37,6 +37,7 @@ interface MessagePropertiesBaseInterface extends NodePropertiesBaseInterface {
public const JSON_PROPERTY_SNIPPET = 'snippet'; public const JSON_PROPERTY_SNIPPET = 'snippet';
public const JSON_PROPERTY_BODY = 'body'; public const JSON_PROPERTY_BODY = 'body';
public const JSON_PROPERTY_ATTACHMENTS = 'attachments'; public const JSON_PROPERTY_ATTACHMENTS = 'attachments';
public const JSON_PROPERTY_FLAGS = 'flags';
public const JSON_PROPERTY_TAGS = 'tags'; public const JSON_PROPERTY_TAGS = 'tags';
/** /**

View File

@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace KTXF\Mail\Service; namespace KTXF\Mail\Service;
use Generator;
use KTXF\Mail\Collection\CollectionBaseInterface; use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Object\AddressInterface;
use KTXF\Resource\Delta\Delta; use KTXF\Resource\Delta\Delta;
@@ -168,6 +169,21 @@ interface ServiceBaseInterface extends ResourceServiceBaseInterface {
*/ */
public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array; public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array;
/**
* Lists messages in a collection
*
* @since 2025.05.01
*
* @param string|int $collection Collection ID
* @param IFilter|null $filter Optional filter criteria
* @param ISort|null $sort Optional sort order
* @param IRange|null $range Optional pagination
* @param array|null $properties Optional message properties to fetch
*
* @return Generator Yields messages one by one as EntityBaseInterface
*/
public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator;
/** /**
* Creates a filter builder for messages * Creates a filter builder for messages
* *