commit14
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__.'/src/WarehouseFeed.php';
|
||||
require_once __DIR__.'/src/WarehouseInbox.php';
|
||||
require_once __DIR__.'/src/WarehousePull.php';
|
||||
require_once __DIR__.'/src/WarehouseTransportFailure.php';
|
||||
require_once __DIR__.'/src/WarehouseHttpSource.php';
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Integration;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Validates the existing v1 wire contract, independently of Laravel. */
|
||||
final class WarehouseFeed
|
||||
{
|
||||
/** @param array<string,mixed> $page */
|
||||
public static function validate(array $page, int $tenant): void
|
||||
{
|
||||
self::check(($page['schema'] ?? null) === 'sgc.warehouse.fulfilment-feed.v1', 'Versão de feed incompatível.');
|
||||
self::check(isset($page['items']) && is_array($page['items']) && array_is_list($page['items']) && count($page['items']) <= 100, 'Página inválida.');
|
||||
self::check(is_bool($page['has_more'] ?? null), 'Indicador de paginação inválido.');
|
||||
self::check(is_string($page['next_cursor'] ?? null) && strlen($page['next_cursor']) > 0 && strlen($page['next_cursor']) <= 4096, 'Cursor inválido.');
|
||||
self::check(! $page['has_more'] || count($page['items']) > 0, 'Uma página vazia não pode prometer continuação.');
|
||||
$previous = 0;
|
||||
foreach ($page['items'] as $event) {
|
||||
self::check(is_array($event), 'Evento inválido.');
|
||||
self::event($event, $tenant);
|
||||
self::check($event['event_id'] > $previous, 'Eventos fora de ordem ou repetidos na página.');
|
||||
$previous = $event['event_id'];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $event */
|
||||
private static function event(array $event, int $tenant): void
|
||||
{
|
||||
self::check(($event['schema'] ?? null) === 'sgc.warehouse.fulfilment-event.v1' && ($event['tenant_id'] ?? null) === $tenant, 'Evento de outra versão ou empresa.');
|
||||
self::positive($event['event_id'] ?? null);
|
||||
self::positive($event['event_version'] ?? null);
|
||||
self::check(($event['idempotency_key'] ?? null) === 'sgc:'.$tenant.':warehouse-event:'.$event['event_id'], 'Identidade do evento inválida.');
|
||||
self::check(in_array($event['action'] ?? null, ['deliver', 'receive'], true), 'Acção incompatível.');
|
||||
self::check(is_string($event['occurred_at'] ?? null) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/D', $event['occurred_at']) === 1, 'Data do evento inválida.');
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:sP', $event['occurred_at']);
|
||||
$errors = \DateTimeImmutable::getLastErrors();
|
||||
self::check($date !== false && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0)), 'Data do evento inexistente.');
|
||||
self::actor($event['actor'] ?? null);
|
||||
$request = $event['request'] ?? null;
|
||||
self::check(is_array($request), 'Requisição inválida.');
|
||||
self::positive($request['id'] ?? null);
|
||||
self::check(($request['reference'] ?? null) === 'RA-'.$request['id'] && ($request['document_type'] ?? null) === 'internal_warehouse_request', 'Documento de origem inválido.');
|
||||
self::check(array_key_exists('work_context_key', $request) && in_array($request['work_context_key'], ['procurement.internal', null], true), 'Contexto incompatível.');
|
||||
self::actor($request['requester'] ?? null);
|
||||
self::registry($request['warehouse'] ?? null);
|
||||
self::registry($request['cost_center'] ?? null);
|
||||
self::check(is_string($request['destination'] ?? null), 'Destino inválido.');
|
||||
self::check(isset($event['lines']) && is_array($event['lines']) && array_is_list($event['lines']) && count($event['lines']) <= 50, 'Linhas inválidas.');
|
||||
self::check(array_key_exists('delivery_event_key', $event), 'Referência à entrega em falta.');
|
||||
if ($event['action'] === 'deliver') {
|
||||
self::check(count($event['lines']) > 0 && $event['delivery_event_key'] === null, 'Entrega sem linhas ou com referência indevida.');
|
||||
$indexes = [];
|
||||
foreach ($event['lines'] as $line) {
|
||||
self::check(is_array($line) && is_int($line['request_line_index'] ?? null) && $line['request_line_index'] >= 0, 'Linha de origem inválida.');
|
||||
self::check(! isset($indexes[$line['request_line_index']]), 'Linha de origem repetida.');
|
||||
$indexes[$line['request_line_index']] = true;
|
||||
self::registry($line['item'] ?? null);
|
||||
self::check(is_string($line['item']['unit'] ?? null) && is_string($line['destination'] ?? null), 'Unidade ou destino em falta.');
|
||||
self::positive($line['quantity_milli'] ?? null);
|
||||
self::positive($line['stock_operation_id'] ?? null);
|
||||
self::check(in_array($line['use'] ?? null, ['internal', 'equipment'], true) && array_key_exists('equipment', $line), 'Utilização inválida.');
|
||||
if ($line['use'] === 'equipment') {
|
||||
self::check(is_array($line['equipment']), 'Equipamento em falta.');
|
||||
self::positive($line['equipment']['id'] ?? null);
|
||||
self::check(is_string($line['equipment']['code'] ?? null) && is_string($line['equipment']['description'] ?? null), 'Equipamento inválido.');
|
||||
} else {
|
||||
self::check($line['equipment'] === null, 'Equipamento incompatível com utilização interna.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self::check($event['lines'] === [] && is_string($event['delivery_event_key'])
|
||||
&& preg_match('/^sgc:'.preg_quote((string) $tenant, '/').':warehouse-event:[1-9][0-9]*$/D', $event['delivery_event_key']) === 1, 'Confirmação sem referência válida ou com nova saída.');
|
||||
}
|
||||
$payload = $event;
|
||||
unset($payload['payload_sha256']);
|
||||
self::check(is_string($event['payload_sha256'] ?? null) && hash_equals(hash('sha256', self::json($payload)), $event['payload_sha256']), 'Conteúdo do evento alterado.');
|
||||
}
|
||||
|
||||
/** @param array<mixed> $value */
|
||||
public static function json(array $value): string
|
||||
{
|
||||
return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
private static function positive(mixed $value): void
|
||||
{
|
||||
self::check(is_int($value) && $value > 0, 'Identificador ou quantidade inválidos.');
|
||||
}
|
||||
|
||||
private static function actor(mixed $value): void
|
||||
{
|
||||
self::check(is_array($value), 'Interveniente em falta.');
|
||||
self::positive($value['id'] ?? null);
|
||||
self::check(is_string($value['name'] ?? null), 'Nome do interveniente em falta.');
|
||||
}
|
||||
|
||||
private static function registry(mixed $value): void
|
||||
{
|
||||
self::actor($value);
|
||||
self::check(is_string($value['code'] ?? null), 'Código do cadastro em falta.');
|
||||
}
|
||||
|
||||
private static function check(bool $valid, string $message): void
|
||||
{
|
||||
if (! $valid) {
|
||||
throw new InvalidArgumentException($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Integration;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
use SensitiveParameter;
|
||||
use Throwable;
|
||||
|
||||
/** Fixed, read-only endpoint using an existing authorised source session. */
|
||||
final class WarehouseHttpSource
|
||||
{
|
||||
private string $endpoint;
|
||||
|
||||
private bool $directLoopback;
|
||||
|
||||
/** @var Closure():string */
|
||||
private Closure $sessionCookie;
|
||||
|
||||
/** @param callable():string $sessionCookie */
|
||||
public function __construct(
|
||||
#[SensitiveParameter] string $baseUrl,
|
||||
private int $sourceTenant,
|
||||
#[SensitiveParameter] callable $sessionCookie,
|
||||
bool $allowLoopbackHttp = false,
|
||||
private int $timeoutMs = 15000,
|
||||
private int $connectTimeoutMs = 5000,
|
||||
private int $maxResponseBytes = 8388608,
|
||||
) {
|
||||
$parts = parse_url($baseUrl);
|
||||
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])
|
||||
|| isset($parts['user']) || isset($parts['pass'])
|
||||
|| isset($parts['query']) || isset($parts['fragment'])
|
||||
|| preg_match('/[\x00-\x20\x7f\\\\]/', $baseUrl)
|
||||
|| ! preg_match('!^(?:/[A-Za-z0-9._~-]+)*/?$!D', $parts['path'] ?? '')
|
||||
|| in_array('..', explode('/', $parts['path'] ?? ''), true)
|
||||
|| in_array('.', explode('/', $parts['path'] ?? ''), true)
|
||||
|| ! ($parts['scheme'] === 'https' || ($allowLoopbackHttp && $parts['scheme'] === 'http' && in_array(strtolower($parts['host']), ['127.0.0.1', '[::1]'], true)))) {
|
||||
throw new InvalidArgumentException('Configure uma origem HTTPS sem credenciais, query ou fragmento. HTTP só é permitido explicitamente em loopback.');
|
||||
}
|
||||
if ($sourceTenant <= 0 || $timeoutMs < 100 || $timeoutMs > 120000 || $connectTimeoutMs < 100 || $connectTimeoutMs > $timeoutMs
|
||||
|| $maxResponseBytes < 1024 || $maxResponseBytes > 16777216) {
|
||||
throw new InvalidArgumentException('Âmbito ou limites de transporte inválidos.');
|
||||
}
|
||||
if (! extension_loaded('curl') || ! defined('CURLOPT_PROTOCOLS_STR')) {
|
||||
throw new InvalidArgumentException('O transporte de integração exige PHP cURL com selecção de protocolos.');
|
||||
}
|
||||
$this->endpoint = rtrim($baseUrl, '/').'/stock/integracao/entregas';
|
||||
$provider = Closure::fromCallable($sessionCookie);
|
||||
$this->sessionCookie = static fn (): string => $provider();
|
||||
$this->directLoopback = in_array(strtolower($parts['host']), ['127.0.0.1', '[::1]'], true);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function __invoke(?string $cursor, int $limit): array
|
||||
{
|
||||
if ($limit < 1 || $limit > 100 || ($cursor !== null && (strlen($cursor) === 0 || strlen($cursor) > 4096))) {
|
||||
throw new InvalidArgumentException('Cursor ou limite de leitura inválidos.');
|
||||
}
|
||||
try {
|
||||
$cookie = ($this->sessionCookie)();
|
||||
} catch (Throwable) {
|
||||
throw new WarehouseTransportFailure('credentials');
|
||||
}
|
||||
if (strlen($cookie) === 0 || strlen($cookie) > 16384 || preg_match('/[^\x20-\x7e]/', $cookie)) {
|
||||
throw new WarehouseTransportFailure('credentials');
|
||||
}
|
||||
$query = ['limit' => $limit];
|
||||
if ($cursor !== null) {
|
||||
$query['cursor'] = $cursor;
|
||||
}
|
||||
$handle = curl_init($this->endpoint.'?'.http_build_query($query, encoding_type: PHP_QUERY_RFC3986));
|
||||
if ($handle === false) {
|
||||
throw new WarehouseTransportFailure('connection');
|
||||
}
|
||||
$body = '';
|
||||
$oversized = false;
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_HTTPGET => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0,
|
||||
CURLOPT_PROTOCOLS_STR => 'http,https', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs, CURLOPT_TIMEOUT_MS => $this->timeoutMs,
|
||||
CURLOPT_ENCODING => '', CURLOPT_HTTPHEADER => ['Accept: application/json', 'Cookie: '.$cookie],
|
||||
CURLOPT_WRITEFUNCTION => function ($handle, string $chunk) use (&$body, &$oversized): int {
|
||||
if (strlen($body) + strlen($chunk) > $this->maxResponseBytes) {
|
||||
$oversized = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
$body .= $chunk;
|
||||
|
||||
return strlen($chunk);
|
||||
},
|
||||
]);
|
||||
if ($this->directLoopback) {
|
||||
curl_setopt($handle, CURLOPT_PROXY, '');
|
||||
}
|
||||
$ok = curl_exec($handle);
|
||||
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||
if ($oversized) {
|
||||
throw new WarehouseTransportFailure('response_size', $status ?: null);
|
||||
}
|
||||
if ($ok === false) {
|
||||
throw new WarehouseTransportFailure(curl_errno($handle) === CURLE_OPERATION_TIMEDOUT ? 'timeout' : 'connection', $status ?: null);
|
||||
}
|
||||
if ($status !== 200) {
|
||||
$category = match (true) {
|
||||
$status >= 300 && $status < 400 => 'redirect',
|
||||
in_array($status, [401, 419], true) => 'authentication',
|
||||
$status === 403 => 'authorization',
|
||||
in_array($status, [409, 422], true) => 'cursor',
|
||||
$status === 429 || $status >= 500 => 'remote_unavailable',
|
||||
default => 'http',
|
||||
};
|
||||
throw new WarehouseTransportFailure($category, $status);
|
||||
}
|
||||
$type = curl_getinfo($handle, CURLINFO_CONTENT_TYPE);
|
||||
if (! is_string($type) || ! preg_match('~^application/json(?:\s*;.*)?$~iD', $type)) {
|
||||
throw new WarehouseTransportFailure('response_contract', 200);
|
||||
}
|
||||
try {
|
||||
$page = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
|
||||
if (! is_array($page)) {
|
||||
throw new InvalidArgumentException;
|
||||
}
|
||||
WarehouseFeed::validate($page, $this->sourceTenant);
|
||||
} catch (Throwable) {
|
||||
throw new WarehouseTransportFailure('response_contract', 200);
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/** @return array<string,int|string> */
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return ['endpoint' => $this->endpoint, 'source_tenant' => $this->sourceTenant, 'session' => '[protected]'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Integration;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/** Durable receiving journal, not a stock/approval engine or remote transport. */
|
||||
final class WarehouseInbox
|
||||
{
|
||||
/** @var array<string,int|string> */
|
||||
private array $scope;
|
||||
|
||||
public function __construct(private PDO $db, string $source, int $sourceTenant, int $destinationTenant, string $consumer)
|
||||
{
|
||||
if (! in_array($db->getAttribute(PDO::ATTR_DRIVER_NAME), ['sqlite', 'pgsql'], true) || $db->getAttribute(PDO::ATTR_ERRMODE) !== PDO::ERRMODE_EXCEPTION
|
||||
|| ! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/D', $source)
|
||||
|| ! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/D', $consumer)
|
||||
|| $sourceTenant <= 0 || $destinationTenant <= 0) {
|
||||
throw new InvalidArgumentException('Ligação ou âmbito de integração inválidos.');
|
||||
}
|
||||
$this->scope = ['source_id' => $source, 'source_tenant_id' => $sourceTenant, 'destination_tenant_id' => $destinationTenant, 'consumer_id' => $consumer];
|
||||
}
|
||||
|
||||
/** Explicit host migration input; construction/consumption never installs schema.
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function schema(): array
|
||||
{
|
||||
return ['CREATE TABLE sgc_exchange_streams (source_id VARCHAR(100) NOT NULL, source_tenant_id BIGINT NOT NULL, destination_tenant_id BIGINT NOT NULL, consumer_id VARCHAR(100) NOT NULL, version BIGINT NOT NULL, cursor TEXT NULL, last_event_id BIGINT NOT NULL, last_page_sha256 VARCHAR(64) NULL, PRIMARY KEY (source_id, source_tenant_id, destination_tenant_id, consumer_id))',
|
||||
'CREATE TABLE sgc_exchange_facts (source_id VARCHAR(100) NOT NULL, source_tenant_id BIGINT NOT NULL, destination_tenant_id BIGINT NOT NULL, event_key VARCHAR(200) NOT NULL, event_id BIGINT NOT NULL, request_id BIGINT NOT NULL, action VARCHAR(20) NOT NULL, delivery_event_key VARCHAR(200) NULL, payload_sha256 VARCHAR(64) NOT NULL, payload TEXT NOT NULL, recorded_at VARCHAR(40) NOT NULL, PRIMARY KEY (source_id, source_tenant_id, destination_tenant_id, event_key), UNIQUE (source_id, source_tenant_id, destination_tenant_id, delivery_event_key))'];
|
||||
}
|
||||
|
||||
/** @return array{version:int,cursor:?string,last_event_id:int} */
|
||||
public function checkpoint(): array
|
||||
{
|
||||
$row = $this->stream(false);
|
||||
|
||||
return ['version' => $row === false ? 0 : (int) $row['version'], 'cursor' => $row === false ? null : $row['cursor'], 'last_event_id' => $row === false ? 0 : (int) $row['last_event_id']];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $page
|
||||
* @return array{inserted:int,replayed:bool,version:int,cursor:string,has_more:bool}
|
||||
*/
|
||||
public function accept(array $page, int $expectedVersion, ?string $requestedCursor): array
|
||||
{
|
||||
WarehouseFeed::validate($page, (int) $this->scope['source_tenant_id']);
|
||||
if ($expectedVersion < 0 || $this->db->inTransaction()) {
|
||||
throw new InvalidArgumentException('Versão inválida ou transacção externa activa.');
|
||||
}
|
||||
$digest = hash('sha256', WarehouseFeed::json($page));
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$this->execute('INSERT INTO sgc_exchange_streams (source_id, source_tenant_id, destination_tenant_id, consumer_id, version, cursor, last_event_id) VALUES (:source_id, :source_tenant_id, :destination_tenant_id, :consumer_id, 0, NULL, 0) ON CONFLICT DO NOTHING', $this->scope);
|
||||
$stream = $this->stream(true);
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException('Ponto de leitura indisponível.');
|
||||
}
|
||||
if ((int) $stream['version'] !== $expectedVersion) {
|
||||
if ((int) $stream['version'] === $expectedVersion + 1 && $stream['last_page_sha256'] === $digest) {
|
||||
$this->db->commit();
|
||||
|
||||
return ['inserted' => 0, 'replayed' => true, 'version' => (int) $stream['version'], 'cursor' => $stream['cursor'], 'has_more' => $page['has_more']];
|
||||
}
|
||||
throw new RuntimeException('A leitura avançou noutro executor. Releia o ponto guardado.');
|
||||
}
|
||||
if ($stream['cursor'] !== $requestedCursor) {
|
||||
throw new RuntimeException('A página não corresponde ao ponto de leitura solicitado.');
|
||||
}
|
||||
$last = (int) $stream['last_event_id'];
|
||||
$inserted = 0;
|
||||
foreach ($page['items'] as $event) {
|
||||
if ($event['event_id'] <= $last) {
|
||||
throw new RuntimeException('Página antiga: o ponto guardado não pode recuar.');
|
||||
}
|
||||
$existing = $this->fact($event['idempotency_key']);
|
||||
if ($existing !== null && $existing['payload_sha256'] !== $event['payload_sha256']) {
|
||||
throw new RuntimeException('A mesma identidade tem conteúdo diferente. Reconciliação necessária.');
|
||||
}
|
||||
if ($event['action'] === 'receive') {
|
||||
$delivery = $this->fact($event['delivery_event_key']);
|
||||
if ($delivery === null || $delivery['action'] !== 'deliver' || $delivery['request'] !== $event['request'] || $delivery['event_id'] >= $event['event_id']) {
|
||||
throw new RuntimeException('Confirmação sem entrega de origem reconciliada.');
|
||||
}
|
||||
}
|
||||
if ($existing === null) {
|
||||
$bindings = $this->factScope();
|
||||
$bindings += ['event_key' => $event['idempotency_key'], 'event_id' => $event['event_id'], 'request_id' => $event['request']['id'], 'action' => $event['action'], 'delivery_event_key' => $event['delivery_event_key'],
|
||||
'payload_sha256' => $event['payload_sha256'], 'payload' => WarehouseFeed::json($event), 'recorded_at' => gmdate('c')];
|
||||
$written = $this->execute('INSERT INTO sgc_exchange_facts (source_id, source_tenant_id, destination_tenant_id, event_key, event_id, request_id, action, delivery_event_key, payload_sha256, payload, recorded_at) VALUES (:source_id, :source_tenant_id, :destination_tenant_id, :event_key, :event_id, :request_id, :action, :delivery_event_key, :payload_sha256, :payload, :recorded_at) ON CONFLICT DO NOTHING', $bindings);
|
||||
// Another stream may have inserted this identity concurrently.
|
||||
$saved = $this->fact($event['idempotency_key']);
|
||||
if ($saved === null || $saved['payload_sha256'] !== $event['payload_sha256']) {
|
||||
throw new RuntimeException('Conflito de conteúdo entre consumidores.');
|
||||
}
|
||||
$inserted += $written->rowCount();
|
||||
}
|
||||
$last = $event['event_id'];
|
||||
}
|
||||
$statement = $this->execute('UPDATE sgc_exchange_streams SET version = version + 1, cursor = :cursor, last_event_id = :last_event_id, last_page_sha256 = :digest WHERE '.$this->streamWhere().' AND version = :expected_version',
|
||||
$this->scope + ['cursor' => $page['next_cursor'], 'last_event_id' => $last, 'digest' => $digest, 'expected_version' => $expectedVersion]);
|
||||
if ($statement->rowCount() !== 1) {
|
||||
throw new RuntimeException('O ponto de leitura mudou durante a recepção.');
|
||||
}
|
||||
$this->db->commit();
|
||||
|
||||
return ['inserted' => $inserted, 'replayed' => false, 'version' => $expectedVersion + 1, 'cursor' => $page['next_cursor'], 'has_more' => $page['has_more']];
|
||||
} catch (Throwable $e) {
|
||||
$this->rollbackOpenTransaction();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function fact(string $key): ?array
|
||||
{
|
||||
$row = $this->execute('SELECT payload FROM sgc_exchange_facts WHERE source_id = :source_id AND source_tenant_id = :source_tenant_id AND destination_tenant_id = :destination_tenant_id AND event_key = :event_key', $this->factScope() + ['event_key' => $key])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row === false ? null : json_decode($row['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|false */
|
||||
private function stream(bool $lock): array|false
|
||||
{
|
||||
return $this->execute('SELECT version, cursor, last_event_id, last_page_sha256 FROM sgc_exchange_streams WHERE '.$this->streamWhere().($lock && $this->db->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : ''), $this->scope)->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/** @return literal-string */
|
||||
private function streamWhere(): string
|
||||
{
|
||||
return 'source_id = :source_id AND source_tenant_id = :source_tenant_id AND destination_tenant_id = :destination_tenant_id AND consumer_id = :consumer_id';
|
||||
}
|
||||
|
||||
/** @return array<string,int|string> */
|
||||
private function factScope(): array
|
||||
{
|
||||
return array_diff_key($this->scope, ['consumer_id' => true]);
|
||||
}
|
||||
|
||||
/** @param literal-string $sql
|
||||
* @param array<string,int|string|null> $bindings
|
||||
*/
|
||||
private function execute(string $sql, array $bindings): \PDOStatement
|
||||
{
|
||||
$statement = $this->db->prepare($sql);
|
||||
$statement->execute($bindings);
|
||||
|
||||
return $statement;
|
||||
}
|
||||
|
||||
private function rollbackOpenTransaction(): void
|
||||
{
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Integration;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Bounded orchestration; authenticated transport/host configuration stay outside. */
|
||||
final class WarehousePull
|
||||
{
|
||||
public function __construct(private WarehouseInbox $inbox) {}
|
||||
|
||||
/** @param callable(?string,int):array<string,mixed> $fetch
|
||||
* @return array{pages:int,inserted:int,replayed_pages:int,has_more:bool,checkpoint:array{version:int,cursor:?string,last_event_id:int}}
|
||||
*/
|
||||
public function run(callable $fetch, int $maxPages = 1, int $pageSize = 50): array
|
||||
{
|
||||
if ($maxPages < 1 || $maxPages > 100 || $pageSize < 1 || $pageSize > 100) {
|
||||
throw new InvalidArgumentException('Limites de leitura inválidos.');
|
||||
}
|
||||
$pages = 0;
|
||||
$inserted = 0;
|
||||
$replayed = 0;
|
||||
do {
|
||||
$checkpoint = $this->inbox->checkpoint();
|
||||
$page = $fetch($checkpoint['cursor'], $pageSize);
|
||||
$result = $this->inbox->accept($page, $checkpoint['version'], $checkpoint['cursor']);
|
||||
$pages++;
|
||||
$inserted += $result['inserted'];
|
||||
$replayed += $result['replayed'] ? 1 : 0;
|
||||
} while ($result['has_more'] && $pages < $maxPages);
|
||||
|
||||
return ['pages' => $pages, 'inserted' => $inserted, 'replayed_pages' => $replayed, 'has_more' => $result['has_more'], 'checkpoint' => $this->inbox->checkpoint()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Integration;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** Sanitised transport diagnosis; never contains credentials or response bodies. */
|
||||
final class WarehouseTransportFailure extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $category, public readonly ?int $httpStatus = null)
|
||||
{
|
||||
parent::__construct(match ($category) {
|
||||
'credentials' => 'A sessão autorizada está indisponível ou é inválida.',
|
||||
'authentication' => 'A origem exige uma sessão autenticada válida.',
|
||||
'authorization' => 'A origem recusou o acesso aos eventos neste âmbito.',
|
||||
'redirect' => 'A origem devolveu um redireccionamento. Confirme a ligação e a sessão.',
|
||||
'cursor' => 'A origem recusou o cursor ou os parâmetros da leitura.',
|
||||
'timeout' => 'A consulta à origem excedeu o tempo permitido.',
|
||||
'remote_unavailable' => 'A origem está temporariamente indisponível.',
|
||||
'response_size' => 'A resposta da origem excedeu o limite de leitura.',
|
||||
'response_contract' => 'A resposta da origem não corresponde ao contrato de entregas.',
|
||||
default => 'Não foi possível consultar a origem de integração.',
|
||||
});
|
||||
}
|
||||
|
||||
public function retryable(): bool
|
||||
{
|
||||
return in_array($this->category, ['timeout', 'connection', 'remote_unavailable'], true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user