Compare commits

..

12 Commits

Author SHA1 Message Date
ac5b5f9246 Remove FrankenPHP targeting 2026-04-23 17:39:01 +03:00
1d97114915 Fix ConsoleApplication parsing 2026-04-21 14:21:33 +03:00
ece0147b3a Fix MongoHydrator BSON handling 2026-04-12 01:57:12 +03:00
d133898383 Enable PHP DI attributes 2026-04-12 01:50:07 +03:00
9f8c2b1959 Fix missing nbf 2026-04-12 01:40:54 +03:00
404e2089eb Remove deprecated method 2026-04-12 01:24:19 +03:00
58c9b298db Register Logger 2026-04-06 20:38:57 +03:00
72415949e3 Register Config and Environment 2026-04-06 20:30:34 +03:00
5e68f7dd64 Complete task 2026-04-06 18:47:38 +03:00
4857be3d41 Update knowledge base 2026-04-06 18:47:11 +03:00
2bbbdc0262 Autoload routes & services 2026-04-06 16:00:19 +03:00
ce5a85628c Add runtime exception on missing CORS config 2026-04-06 15:56:10 +03:00
27 changed files with 666 additions and 113 deletions

View File

@@ -1,14 +0,0 @@
# Pinecore Knowledge Base
Детальная документация по реализации. См. также [CLAUDE.md](../CLAUDE.md) для базового обзора.
## Разделы
- [Архитектура и жизненный цикл запроса](kb/architecture.md)
- [Ядро, Config, Environment, ContainerFactory](kb/bootstrap.md)
- [HTTP слой](kb/http.md)
- [Worker и запуск](kb/worker.md)
- [Аутентификация (JWT)](kb/auth.md)
- [Логирование](kb/logging.md)
- [ORM (MongoDB)](kb/orm.md)
- [Console](kb/console.md)

62
.claude/README.md Normal file
View File

@@ -0,0 +1,62 @@
# Knowledge Base Index
Полный индекс базы знаний проекта Pinecore. Все файлы в этой директории коммитятся в репозиторий.
## Быстрая навигация
| Что нужно | Файл |
|-----------|------|
| Я только что открыл проект | [`architecture/overview.md`](architecture/overview.md) |
| Как поднять worker / запустить? | [`development/commands.md`](development/commands.md) |
| Какой паттерн использовать? | [`development/patterns.md`](development/patterns.md) |
| Commit/branch/namespace conventions | [`development/conventions.md`](development/conventions.md) |
| Как отладить локально | [`development/testing.md`](development/testing.md) |
| Ядро, Config, Environment, ContainerFactory | [`architecture/bootstrap.md`](architecture/bootstrap.md) |
| HTTP: Request, Response, Router, Middleware | [`architecture/http.md`](architecture/http.md) |
| Worker и WorkerRunner | [`architecture/worker.md`](architecture/worker.md) |
| Auth (JWT, AuthMiddleware) | [`architecture/auth.md`](architecture/auth.md) |
| Логирование | [`architecture/logging.md`](architecture/logging.md) |
| ORM (MongoDB) | [`architecture/orm.md`](architecture/orm.md) |
| Console-команды | [`architecture/console.md`](architecture/console.md) |
| Почему архитектура именно такая | [`decisions/README.md`](decisions/README.md) |
| Контекст текущей задачи | [`tasks/active/`](tasks/active/) |
## Структура директории
```
.claude/
├── README.md # Этот файл — индекс
├── architecture/ # Стабильные архитектурные docs
│ ├── overview.md # Обзор системы и жизненный цикл запроса
│ ├── bootstrap.md # Kernel, Config, Environment, ContainerFactory
│ ├── http.md # HTTP слой: Request, Response, Router, Middleware
│ ├── worker.md # Worker entrypoint и WorkerRunner
│ ├── auth.md # Auth: JWT, AuthMiddleware
│ ├── logging.md # Логирование
│ ├── orm.md # ORM: MongoDB, entities, repositories
│ └── console.md # Console-команды
├── development/ # Практика разработки
│ ├── patterns.md # Переиспользуемые code patterns
│ ├── conventions.md # Соглашения: коммиты, ветки, неймспейсы
│ ├── commands.md # Команды запуска и утилиты
│ └── testing.md # Отладка и тестирование
├── decisions/ # Architecture Decision Records (ADR)
│ ├── README.md # Индекс ADR + как писать
│ └── ADR-NNN-*.md
└── tasks/ # Контекст задач (по одному файлу на ветку)
├── _template.md # Шаблон для новой задачи
├── active/ # Активные ветки
└── completed/ # Смерженные задачи
```
## Правила обновления
**Во время разработки в feature-ветке:**
- Пиши только в `tasks/active/<branch-name>.md` (свой файл)
- Можешь добавить `decisions/ADR-NNN.md` (новый файл — нет конфликтов)
- НЕ трогай `architecture/` и `development/` — только в main
**После мержа в main (интегратор):**
- Читает секцию "Merge Notes" в task-файле
- При необходимости обновляет `architecture/` и `development/`
- Перемещает: `tasks/active/<branch>.md``tasks/completed/<branch>.md`

View File

@@ -41,16 +41,31 @@ ContainerFactory::build(Environment $env, Config $config, string $basePath): Con
- Autowiring включён всегда - Autowiring включён всегда
- В prod: `$builder->enableCompilation($basePath . '/var/cache/prod/')` - В prod: `$builder->enableCompilation($basePath . '/var/cache/prod/')`
- Загружает `$basePath/config/services.php` — файл должен вернуть `callable($builder, $config, $basePath)` - Автоматически регистрирует `Config::class`, `Environment::class` и `LoggerInterface::class`
в контейнере — любой класс может получить их через DI без ручного биндинга в `services.php`
- `LoggerInterface` по умолчанию резолвится в `CompositeLogger([StdoutLogger])`;
если задан `log.file` — добавляется `FileLogger`
- Загружает `$basePath/config/routes.php` (если есть) — файл возвращает `RouteDefinition[]`,
фреймворк автоматически создаёт `Router` и регистрирует его в контейнере
- Загружает `$basePath/config/services.php` (если есть) — файл возвращает
`callable($builder, $config, $basePath)`; загружается после routes.php и может переопределить
любые определения, включая `Router::class`
**Пример `config/services.php`:** **`config/routes.php`** (конвенция, предпочтительный способ):
```php
use Pronchev\Pinecore\Http\RouteDefinition;
return [
new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class]),
new RouteDefinition('POST', '/users', CreateUserController::class, [AuthMiddleware::class]),
];
```
**`config/services.php`** (для DI-биндингов и переопределений):
```php ```php
return function (ContainerBuilder $builder, Config $config, string $basePath): void { return function (ContainerBuilder $builder, Config $config, string $basePath): void {
$builder->addDefinitions([ $builder->addDefinitions([
UserProviderInterface::class => fn($c) => $c->get(UserRepository::class), UserProviderInterface::class => fn($c) => $c->get(UserRepository::class),
Router::class => fn() => new Router([
new RouteDefinition('GET', '/users/{id}', GetUserController::class, [AuthMiddleware::class]),
]),
]); ]);
}; };
``` ```

View File

@@ -68,3 +68,21 @@ $response->emit(): void // http_response_code + headers + e
throw new HttpException('Forbidden', 403); throw new HttpException('Forbidden', 403);
// → перехватывается в HttpApplication::dispatch() → Response::error(message, code) // → перехватывается в HttpApplication::dispatch() → Response::error(message, code)
``` ```
## HttpApplication — конфигурационные зависимости
`HttpApplication` требует наличия ключа `cors` в `config/app.php`. Конфиг читается при каждом
запросе (в т.ч. OPTIONS-preflight). Если ключ отсутствует — бросается
`\RuntimeException('app.cors config is missing')`.
```php
// config/app.php
return [
'cors' => [
'origins' => '*',
'methods' => 'GET, POST, PUT, DELETE, OPTIONS',
'headers' => 'Content-Type, Authorization',
'max_age' => '86400',
],
];
```

View File

@@ -3,13 +3,13 @@
## Общая схема ## Общая схема
``` ```
HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop HTTP → /worker.php → worker loop
Kernel::boot($basePath) [один раз при старте] Kernel::boot($basePath) [один раз при старте]
→ Environment::detect() // читает APP_ENV, дефолт 'dev' → Environment::detect() // читает APP_ENV, дефолт 'dev'
→ Config::load($configDir) // config/*.php + config/env/{env}.php + config/env/local.php → Config::load($configDir) // config/*.php + config/env/{env}.php + config/env/local.php
→ ContainerFactory::build() // PHP-DI, в prod компилируется в var/cache/prod/ → ContainerFactory::build() // PHP-DI, в prod компилируется в var/cache/prod/
loop (WorkerRunner::run()): loop (WorkerRunner::run($loop)):
frankenphp_handle_request(fn() => $loop(fn() => // адаптер приложения, возвращает keepRunning
HttpApplication::handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER) HttpApplication::handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER)
→ Request::fromGlobals() // парсит метод, путь, заголовки, JSON body → Request::fromGlobals() // парсит метод, путь, заголовки, JSON body
→ OPTIONS? → CORS headers + 204 // preflight, без роутинга → OPTIONS? → CORS headers + 204 // preflight, без роутинга
@@ -32,7 +32,7 @@ HTTP → Caddy (:80) → /worker.php → FrankenPHP worker loop
| `src/Environment.php` | `Environment` | Читает `APP_ENV`, метод `isProd()` | | `src/Environment.php` | `Environment` | Читает `APP_ENV`, метод `isProd()` |
| `src/Config.php` | `Config` | Загружает `config/*.php`, deep-merge с env-оверрайдами; `get('a.b.c')` | | `src/Config.php` | `Config` | Загружает `config/*.php`, deep-merge с env-оверрайдами; `get('a.b.c')` |
| `src/ContainerFactory.php` | `ContainerFactory` | Строит PHP-DI контейнер; в prod включает compilation | | `src/ContainerFactory.php` | `ContainerFactory` | Строит PHP-DI контейнер; в prod включает compilation |
| `src/Http/WorkerRunner.php` | `WorkerRunner` | Цикл FrankenPHP, MAX_REQUESTS, gc | | `src/Http/WorkerRunner.php` | `WorkerRunner` | Worker-цикл, MAX_REQUESTS, gc |
| `src/Http/HttpApplication.php` | `HttpApplication` | CORS, роутинг, dispatch, обработка исключений | | `src/Http/HttpApplication.php` | `HttpApplication` | CORS, роутинг, dispatch, обработка исключений |
| `src/Http/Request.php` | `Request` | Иммутабельный; `body()`, `get(key)`, `withContext()` | | `src/Http/Request.php` | `Request` | Иммутабельный; `body()`, `get(key)`, `withContext()` |
| `src/Http/Response.php` | `Response` | `json()`, `error()`, `withHeader()`, `emit()` | | `src/Http/Response.php` | `Response` | `json()`, `error()`, `withHeader()`, `emit()` |

View File

@@ -0,0 +1,75 @@
# Worker и запуск
## Entrypoint (`worker.php` приложения)
Фреймворк запускается удобным для пользователя способом. `WorkerRunner::run()` без аргументов обрабатывает один запрос и завершается (классический SAPI/FPM/CGI). Чтобы крутить worker-петлю, приложение передаёт свой адаптер:
```php
use Pronchev\Pinecore\Kernel;
use Pronchev\Pinecore\Http\WorkerRunner;
require __DIR__ . '/vendor/autoload.php';
Kernel::boot(__DIR__);
$runner = Kernel::container()->get(WorkerRunner::class);
// Один запрос:
$runner->run();
// Или worker-петля (пример для FrankenPHP):
$runner->run(fn ($handler) => frankenphp_handle_request($handler));
```
Адаптер получает `callable $handler` (обработать один запрос) и возвращает `bool` — продолжать ли цикл. `MAX_REQUESTS`, `$app->terminate()` и `gc_collect_cycles()` отрабатывает сам `WorkerRunner` между итерациями. `WorkerRunner` резолвится через DI autowiring — конфигурировать не нужно.
## WorkerRunner (`src/Http/WorkerRunner.php`)
```php
final class WorkerRunner
{
public function __construct(
private readonly HttpApplication $app,
private readonly ExceptionHandler $exceptionHandler,
) {}
public function run(?callable $loop = null): void
{
if ($loop === null) {
$this->handle();
$this->app->terminate();
return;
}
$handler = fn () => $this->handle();
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0); // 0 = без лимита
for ($n = 0; !$maxRequests || $n < $maxRequests; ++$n) {
$keepRunning = $loop($handler);
$this->app->terminate(); // хук: закрытие ресурсов (сейчас пустой)
gc_collect_cycles();
if (!$keepRunning) break;
}
}
private function handle(): void
{
try {
$this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
} catch (\Throwable $e) {
$this->exceptionHandler->handleException($e); // critical лог
}
}
}
```
## Env-переменные
| Переменная | По умолчанию | Описание |
|---|---|---|
| `APP_ENV` | `dev` | Среда исполнения |
| `MAX_REQUESTS` | `0` | Лимит запросов на воркер (0 = без лимита) |
| `LOG_LEVEL` | `debug` | Минимальный уровень логирования |
| `LOG_FILE` | — | Путь к файлу лога (активирует FileLogger) |
| `JWT_SECRET` | обязателен | HMAC-ключ для JWT |

View File

@@ -0,0 +1,34 @@
# Architecture Decision Records (ADR)
Здесь хранятся записи об архитектурных решениях. Помогают понять **почему** код устроен именно так.
## Индекс
| ADR | Название | Статус |
|-----|----------|--------|
| — | Пока нет ADR | — |
## Как писать ADR
Создай новый файл `ADR-NNN-brief-title.md` (NNN = следующий номер) с таким шаблоном:
```markdown
# ADR-NNN: Краткое название
## Status
Accepted
## Context
Какая проблема стояла. Какие альтернативы рассматривались.
## Decision
Что решили.
## Consequences
Что это даёт. Что ограничивает.
```
**Правила**:
- ADR — только **новые файлы**. Существующие ADR не правятся (только Status меняется на "Superseded by ADR-XXX").
- Добавляй ADR прямо из feature-ветки — это не вызывает конфликтов.
- После мержа — обновить этот индекс (строка в таблице).

View File

@@ -0,0 +1,54 @@
# Commands Reference
## Запуск worker
Фреймворк запускается удобным для пользователя способом — способ запуска выбирает само приложение. Воркер-скрипт приложения подключается к фреймворку через `WorkerRunner`:
```php
// worker.php — один запрос (классический SAPI)
$runner->run();
// worker.php — worker-петля, адаптер инжектится приложением
$runner->run(fn ($handler) => frankenphp_handle_request($handler));
```
Адаптер получает `callable $handler` и возвращает `bool` (продолжать ли цикл). `MAX_REQUESTS`, `terminate()` и `gc_collect_cycles()` `WorkerRunner` делает сам между итерациями.
---
## Console-команды
```bash
php bin/console <command> [options]
```
Команды регистрируются в контейнере как сервисы с тегом (см. `architecture/console.md`).
---
## Composer
```bash
composer install # Установить зависимости из lock
composer update # Обновить зависимости
composer dump-autoload # Пересоздать autoloader
```
---
## Docker (если используется в приложении)
```bash
docker compose up -d # Запустить сервисы
docker compose build # Пересобрать образ
docker compose logs -f # Логи в реальном времени
```
---
## Git shortcuts
```bash
# Создать task-файл для новой ветки
cp .claude/tasks/_template.md .claude/tasks/active/<branch-name>.md
```

View File

@@ -0,0 +1,75 @@
# Conventions
## Commit Messages
```
[type] Краткое описание в imperative form
```
Примеры:
- `Add autoload for routes and services`
- `Fix missing CORS config exception`
- `Refactor WorkerRunner startup sequence`
---
## Branch Naming
| Тип | Паттерн | Пример |
|-----|---------|--------|
| Feature | `feature/description` | `feature/jwt-refresh` |
| Fix | `fix/description` | `fix/cors-header` |
| Refactor | `refactor/description` | `refactor/kernel-bootstrap` |
| Knowledge base / tooling | `kebab-case` | `knowledge-base` |
---
## PHP Namespaces
| Компонент | Namespace |
|-----------|-----------|
| Core | `Pronchev\Pinecore\` |
| HTTP | `Pronchev\Pinecore\Http\` |
| Auth | `Pronchev\Pinecore\Auth\` |
| ORM | `Pronchev\Pinecore\Orm\` |
| Log | `Pronchev\Pinecore\Log\` |
| Console | `Pronchev\Pinecore\Console\` |
| Model | `Pronchev\Pinecore\Model\` |
---
## File & Class Naming
- PSR-4: файл = имя класса, PascalCase (`WorkerRunner.php` содержит `class WorkerRunner`)
- Атрибуты ORM: PascalCase (`#[Collection]`, `#[Field]`)
- Interfaces: суффикс `Interface` (`MiddlewareInterface`)
---
## Работа с задачами (.claude/tasks/)
При старте новой ветки:
```bash
cp .claude/tasks/_template.md .claude/tasks/active/<branch-name>.md
```
Правила:
1. Во время разработки — редактируй только свой task-файл
2. Можно добавить `decisions/ADR-NNN.md` (новый файл — нет конфликтов)
3. Не трогай `architecture/` и `development/` — только в main
При мерже в main:
- Читай секцию "Merge Notes" в task-файле
- Обновляй стабильные docs если нужно
- Перемести: `tasks/active/<branch>.md``tasks/completed/<branch>.md`
---
## Когда писать ADR
Создай `decisions/ADR-NNN-title.md` когда:
- Вводится новый архитектурный паттерн
- Принято нетривиальное решение (и кто-то может спросить "почему так?")
- Отвергнута очевидная альтернатива
ADR — только новые файлы, никогда не правь существующие.

View File

@@ -0,0 +1,111 @@
# Code Patterns
Переиспользуемые паттерны кодовой базы. Используй существующие — не изобретай новые без необходимости.
---
## 1. Kernel — статический контейнер
```php
// Инициализация (один раз при старте воркера)
Kernel::init($container);
// Получение сервиса
$service = Kernel::getContainer()->get(MyService::class);
```
Kernel хранит единственный инстанс контейнера на весь процесс воркера.
---
## 2. ContainerFactory — сборка контейнера
```php
$factory = new ContainerFactory($config, $environment);
$container = $factory->build();
```
Autoload: все файлы из `routes/` и `services/` подхватываются автоматически.
Не регистрируй маршруты и сервисы вручную в `index.php` — кладёт файлы в соответствующие директории.
---
## 3. Router и RouteDefinition
```php
// routes/api.php
return [
new RouteDefinition('GET', '/users/{id}', UserController::class, 'show'),
];
```
Router находит нужный маршрут по методу и пути, извлекает параметры и вызывает контроллер.
---
## 4. Middleware — цепочка обработки запроса
```php
class MyMiddleware implements MiddlewareInterface {
public function handle(Request $request, callable $next): Response {
// до
$response = $next($request);
// после
return $response;
}
}
```
Middleware регистрируются в контейнере и применяются ко всем запросам в воркере.
---
## 5. ORM — атрибуты сущностей
```php
#[Collection('users')]
class UserEntity {
#[Field('_id')]
public string $id;
#[Field('name')]
public string $name;
}
```
Repository наследует базовый класс и получает методы `find`, `findOne`, `save`, `delete` из коробки.
---
## 6. JWT Auth
```php
// AuthMiddleware автоматически валидирует Bearer-токен
// В контроллере — получить текущего пользователя из request
$user = $request->getAttribute('user');
```
`JwtService` отвечает за выпуск и верификацию токенов. Конфиг: секрет и TTL через `Config`.
---
## 7. Config — конфигурация
```php
$value = Config::get('key');
$value = Config::get('section.key', $default);
```
Конфиг загружается из `environment.php` (или `.env`). Доступен глобально через статический метод.
---
## 8. WorkerRunner — цикл обработки запросов
```php
// Внутри worker.php
$runner = new WorkerRunner($kernel);
$runner->run(); // worker loop: запрос → роутинг → middleware → контроллер → ответ
```
`WorkerRunner` перехватывает исключения и возвращает корректный HTTP-ответ даже при ошибке.

View File

@@ -0,0 +1,56 @@
# Testing & Debugging
## Запуск воркера локально
Запусти воркер удобным для тебя способом (worker-рантайм, FPM, CLI — по выбору приложения). Убедись, что переменные окружения заданы (`.env` или `environment.php`).
---
## Отладка запросов
Включи подробное логирование через конфиг:
```php
Config::get('log.level') // DEBUG для максимального вывода
```
Логи пишутся через `LoggerInterface` (Monolog). Смотри `architecture/logging.md` для деталей.
---
## Частые проблемы
### Воркер не стартует
- Проверь конфигурацию выбранного рантайма и путь до `worker.php`
- Проверь, что `Kernel::init()` вызван до первого запроса
### Маршрут не найден (404)
- Убедись, что файл с `RouteDefinition` лежит в `routes/` (autoload подхватывает автоматически)
- Проверь HTTP-метод и путь: параметры вида `{id}` чувствительны к паттерну
### JWT не валидируется
- Проверь, что `Config::get('auth.secret')` не пустой
- Убедись, что `AuthMiddleware` зарегистрирован в контейнере
- Время жизни токена — `Config::get('auth.ttl')`
### Ошибка CORS
- `CorsMiddleware` требует явного конфига (список разрешённых origins)
- Если конфиг не задан — бросается `RuntimeException` при старте
### MongoDB не подключается
- Проверь DSN в конфиге: `Config::get('mongodb.dsn')`
- Проверь, что MongoDB-сервис запущен и доступен из воркера
---
## Инспекция контейнера
```php
// В dev-режиме: посмотреть все зарегистрированные сервисы
$ids = Kernel::getContainer()->getServiceIds();
```

View File

@@ -1,59 +0,0 @@
# Worker и запуск
## Entrypoint (`worker.php` приложения)
FrankenPHP запускается как `frankenphp run --config Caddyfile` и сам стартует PHP-воркеры.
```php
use Pronchev\Pinecore\Kernel;
use Pronchev\Pinecore\Http\WorkerRunner;
require __DIR__ . '/vendor/autoload.php';
Kernel::boot(__DIR__);
Kernel::container()->get(WorkerRunner::class)->run();
```
`WorkerRunner` резолвится через DI autowiring — конфигурировать не нужно.
## WorkerRunner (`src/Http/WorkerRunner.php`)
```php
final class WorkerRunner
{
public function __construct(
private readonly HttpApplication $app,
private readonly ExceptionHandler $exceptionHandler,
) {}
public function run(): void
{
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0); // 0 = без лимита
for ($n = 0; !$maxRequests || $n < $maxRequests; ++$n) {
$keepRunning = frankenphp_handle_request(function (): void {
try {
$this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
} catch (\Throwable $e) {
$this->exceptionHandler->handleException($e); // critical лог
}
});
$this->app->terminate(); // хук: закрытие ресурсов (сейчас пустой)
gc_collect_cycles();
if (!$keepRunning) break; // FrankenPHP сигнализирует об остановке
}
}
}
```
## Env-переменные
| Переменная | По умолчанию | Описание |
|---|---|---|
| `APP_ENV` | `dev` | Среда исполнения |
| `MAX_REQUESTS` | `0` | Лимит запросов на воркер (0 = без лимита) |
| `LOG_LEVEL` | `debug` | Минимальный уровень логирования |
| `LOG_FILE` | — | Путь к файлу лога (активирует FileLogger) |
| `JWT_SECRET` | обязателен | HMAC-ключ для JWT |

View File

@@ -0,0 +1,42 @@
# Task: Краткое название
## Branch
`branch-name`
## Status
<!-- In Progress | Blocked | Ready for Review | Merged -->
In Progress
## Objective
Что и зачем делается. Один абзац: проблема, решение, ожидаемый результат.
## Scope
- [ ] Компонент/файл A
- [ ] Компонент/файл B
## Approach
Технические решения, принятые для этой задачи.
Ссылки на файлы архитектуры (например: `.claude/architecture/http.md`).
## Files Changed
<!-- Список заполняй по ходу работы -->
- `src/...` — что изменено
## Patterns Used / Introduced
<!-- Какие паттерны из .claude/development/patterns.md использованы -->
<!-- Какие новые паттерны введены -->
- Использован: `WorkerRunner` (см. `.claude/development/patterns.md`)
## Decisions & Gotchas
<!-- Решения, о которых должны знать следующие разработчики -->
<!-- Если решение значимое — создай .claude/decisions/ADR-NNN-title.md -->
## Testing Done
<!-- Как проверено -->
## Merge Notes
<!-- Что интегратор должен сделать после мержа в main -->
<!-- Большинство задач: ничего не требуется — удали ненужное -->
- [ ] Обновить `.claude/architecture/...`
- [ ] Обновить `.claude/development/...`
- [ ] Добавить `decisions/ADR-NNN.md` для [решения]

View File

View File

@@ -0,0 +1,10 @@
# Task: привести базу знаний к структуре из другого проекта.
## Branch
`knowledge-base`
## Status
In Progress
## Objective
В репозитории /home/pronchev/source/bitbucket.pltrm.net/trends выработана система накопления базы знаний о проекте и механизм развития проекта с параллельным поддержание базы знаний в актуальном состоянии. Нужно перенести принципы работы и структуру базы знаний в текущий проект.

View File

@@ -4,7 +4,7 @@ Guidance for Claude Code when working with this repository.
## Package ## Package
`pronchev/pinecore` — minimal PHP framework for FrankenPHP long-running workers. `pronchev/pinecore` — minimal PHP framework for long-running workers.
**Namespace:** `Pronchev\Pinecore\``src/` (PSR-4) **Namespace:** `Pronchev\Pinecore\``src/` (PSR-4)
@@ -12,16 +12,16 @@ No lint or test commands configured yet.
## Knowledge base ## Knowledge base
Detailed implementation docs: [.claude/INDEX.md](.claude/INDEX.md) Full index: [.claude/README.md](.claude/README.md)
- [Architecture & request lifecycle](.claude/kb/architecture.md) - [Architecture & request lifecycle](.claude/architecture/overview.md)
- [Bootstrap: Kernel, Config, Environment, ContainerFactory](.claude/kb/bootstrap.md) - [Bootstrap: Kernel, Config, Environment, ContainerFactory](.claude/architecture/bootstrap.md)
- [HTTP layer: Request, Response, Router, Middleware](.claude/kb/http.md) - [HTTP layer: Request, Response, Router, Middleware](.claude/architecture/http.md)
- [Worker entrypoint & WorkerRunner](.claude/kb/worker.md) - [Worker entrypoint & WorkerRunner](.claude/architecture/worker.md)
- [Auth: JWT, AuthMiddleware](.claude/kb/auth.md) - [Auth: JWT, AuthMiddleware](.claude/architecture/auth.md)
- [Logging](.claude/kb/logging.md) - [Logging](.claude/architecture/logging.md)
- [ORM: MongoDB, entities, repositories](.claude/kb/orm.md) - [ORM: MongoDB, entities, repositories](.claude/architecture/orm.md)
- [Console](.claude/kb/console.md) - [Console](.claude/architecture/console.md)
## Code Style ## Code Style

View File

@@ -1,6 +1,6 @@
{ {
"name": "pronchev/pinecore", "name": "pronchev/pinecore",
"description": "Minimal PHP framework for FrankenPHP long-running workers", "description": "Minimal PHP framework for long-running workers",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"require": { "require": {

View File

@@ -28,8 +28,8 @@ final class JwtService
$signer = new Sha256(); $signer = new Sha256();
$key = InMemory::plainText($secret); $key = InMemory::plainText($secret);
$this->jwtConfig = Configuration::forSymmetricSigner($signer, $key); $jwtConfig = Configuration::forSymmetricSigner($signer, $key);
$this->jwtConfig->setValidationConstraints( $this->jwtConfig = $jwtConfig->withValidationConstraints(
new SignedWith($signer, $key), new SignedWith($signer, $key),
new StrictValidAt(new class implements ClockInterface { new StrictValidAt(new class implements ClockInterface {
public function now(): DateTimeImmutable public function now(): DateTimeImmutable
@@ -45,6 +45,7 @@ final class JwtService
$now = new DateTimeImmutable(); $now = new DateTimeImmutable();
$token = $this->jwtConfig->builder() $token = $this->jwtConfig->builder()
->issuedAt($now) ->issuedAt($now)
->canOnlyBeUsedAfter($now)
->expiresAt($now->modify("+{$this->accessTtl} seconds")) ->expiresAt($now->modify("+{$this->accessTtl} seconds"))
->relatedTo($userId) ->relatedTo($userId)
->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey()); ->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey());

View File

@@ -13,13 +13,30 @@ final class ConsoleApplication
public function run(array $argv): int public function run(array $argv): int
{ {
$commandStr = $argv[1] ?? null; if (!isset($argv[1]) || $argv[1] === 'help') {
if ($commandStr === null || $commandStr === 'help') {
$this->printHelp($argv[2] ?? null); $this->printHelp($argv[2] ?? null);
return 0; return 0;
} }
// Split argv (after script name) into positional command tokens and option tokens.
// Positional tokens form the command string matched against command signatures
// (e.g. "users:create admin"). Tokens from the first "--" onwards are options.
$positional = [];
$optionTokens = [];
$inOptions = false;
for ($i = 1, $n = count($argv); $i < $n; $i++) {
$tok = $argv[$i];
if (!$inOptions && str_starts_with($tok, '--')) {
$inOptions = true;
}
if ($inOptions) {
$optionTokens[] = $tok;
} else {
$positional[] = $tok;
}
}
$commandStr = implode(' ', $positional);
$match = $this->router->match($commandStr); $match = $this->router->match($commandStr);
if (!$match->found) { if (!$match->found) {
@@ -31,7 +48,7 @@ final class ConsoleApplication
$input = ConsoleInput::parse( $input = ConsoleInput::parse(
$commandStr, $commandStr,
$match->pathParams, $match->pathParams,
array_slice($argv, 2), $optionTokens,
$match->definition->options, $match->definition->options,
); );
$output = new ConsoleOutput(); $output = new ConsoleOutput();

View File

@@ -4,6 +4,11 @@ namespace Pronchev\Pinecore;
use DI\Container; use DI\Container;
use DI\ContainerBuilder; use DI\ContainerBuilder;
use Pronchev\Pinecore\Http\Router;
use Pronchev\Pinecore\Log\CompositeLogger;
use Pronchev\Pinecore\Log\FileLogger;
use Pronchev\Pinecore\Log\StdoutLogger;
use Psr\Log\LoggerInterface;
class ContainerFactory class ContainerFactory
{ {
@@ -19,7 +24,27 @@ class ContainerFactory
$builder->enableCompilation($cacheDir); $builder->enableCompilation($cacheDir);
} }
$builder->useAutowiring(true); $builder->useAttributes(true);
$builder->addDefinitions([
Config::class => $config,
Environment::class => $env,
LoggerInterface::class => function ($c) use ($config) {
$loggers = [$c->get(StdoutLogger::class)];
if ($config->get('log.file')) {
$loggers[] = $c->get(FileLogger::class);
}
return new CompositeLogger($loggers);
},
]);
$routesFile = $basePath . '/config/routes.php';
if (file_exists($routesFile)) {
$routes = require $routesFile;
$builder->addDefinitions([
Router::class => fn() => new Router($routes),
]);
}
$servicesFile = $basePath . '/config/services.php'; $servicesFile = $basePath . '/config/services.php';
if (file_exists($servicesFile)) { if (file_exists($servicesFile)) {

View File

@@ -78,7 +78,7 @@ final class HttpApplication
/** @return array<string, string> */ /** @return array<string, string> */
private function corsHeaders(): array private function corsHeaders(): array
{ {
$cors = $this->config->get('app.cors'); $cors = $this->corsConfig();
return [ return [
'Access-Control-Allow-Origin' => $cors['origins'], 'Access-Control-Allow-Origin' => $cors['origins'],
'Access-Control-Allow-Methods' => $cors['methods'], 'Access-Control-Allow-Methods' => $cors['methods'],
@@ -88,10 +88,20 @@ final class HttpApplication
private function emitCorsHeaders(): void private function emitCorsHeaders(): void
{ {
$cors = $this->config->get('app.cors'); $cors = $this->corsConfig();
header('Access-Control-Allow-Origin: ' . $cors['origins']); header('Access-Control-Allow-Origin: ' . $cors['origins']);
header('Access-Control-Allow-Methods: ' . $cors['methods']); header('Access-Control-Allow-Methods: ' . $cors['methods']);
header('Access-Control-Allow-Headers: ' . $cors['headers']); header('Access-Control-Allow-Headers: ' . $cors['headers']);
header('Access-Control-Max-Age: ' . $cors['max_age']); header('Access-Control-Max-Age: ' . $cors['max_age']);
} }
/** @return array<string, mixed> */
private function corsConfig(): array
{
$cors = $this->config->get('app.cors');
if (!is_array($cors)) {
throw new \RuntimeException('app.cors config is missing');
}
return $cors;
}
} }

View File

@@ -11,18 +11,19 @@ final class WorkerRunner
private readonly ExceptionHandler $exceptionHandler, private readonly ExceptionHandler $exceptionHandler,
) {} ) {}
public function run(): void public function run(?callable $loop = null): void
{ {
if ($loop === null) {
$this->handle();
$this->app->terminate();
return;
}
$handler = fn () => $this->handle();
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0); $maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
for ($n = 0; !$maxRequests || $n < $maxRequests; ++$n) { for ($n = 0; !$maxRequests || $n < $maxRequests; ++$n) {
$keepRunning = frankenphp_handle_request(function (): void { $keepRunning = $loop($handler);
try {
$this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
} catch (\Throwable $e) {
$this->exceptionHandler->handleException($e);
}
});
$this->app->terminate(); $this->app->terminate();
gc_collect_cycles(); gc_collect_cycles();
@@ -32,4 +33,13 @@ final class WorkerRunner
} }
} }
} }
private function handle(): void
{
try {
$this->app->handleRequest($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
} catch (\Throwable $e) {
$this->exceptionHandler->handleException($e);
}
}
} }

View File

@@ -20,9 +20,7 @@ final class MongoHydrator
$propName = $field->property->getName(); $propName = $field->property->getName();
$value = $doc[$key] ?? null; $value = $doc[$key] ?? null;
if ($value instanceof \MongoDB\Model\BSONArray) { $value = $this->normalizeBson($value);
$value = $value->getArrayCopy();
}
if ($field->isEmbedded && $value !== null) { if ($field->isEmbedded && $value !== null) {
$value = $this->hydrate($field->embeddedClass, $this->toArray($value)); $value = $this->hydrate($field->embeddedClass, $this->toArray($value));
@@ -72,6 +70,19 @@ final class MongoHydrator
return $result; return $result;
} }
private function normalizeBson(mixed $value): mixed
{
if ($value instanceof \MongoDB\Model\BSONArray) {
return array_map($this->normalizeBson(...), $value->getArrayCopy());
}
if ($value instanceof BSONDocument) {
return array_map($this->normalizeBson(...), iterator_to_array($value));
}
return $value;
}
private function toArray(array|BSONDocument $doc): array private function toArray(array|BSONDocument $doc): array
{ {
if ($doc instanceof BSONDocument) { if ($doc instanceof BSONDocument) {