Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agent/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Machine-readable descriptions of every framework sub-package, generated by `univ
| [univeros/filesystem](packages/filesystem.md) | `Altair\Filesystem` | The Altair Filesystem package. |
| [univeros/happen](packages/happen.md) | `Altair\Happen` | The Altair Event package. |
| [univeros/http](packages/http.md) | `Altair\Http` | The Altair Http package. |
| [univeros/idempotency](packages/idempotency.md) | `Altair\Idempotency` | Stripe-style Idempotency-Key primitive for Univeros: storage contract, adapters, and (in companion packages) PSR-15 middleware + spec block. |
| [univeros/index](packages/index.md) | `Altair\Index` | bin/altair index — a symbol-usage index built from the PHP AST plus spec awareness. Answers find-usages, implementers, callers-of, dead-code, and refactor-impact queries in milliseconds, as deterministic JSON for agents and CI. SQLite-backed. |
| [univeros/introspection](packages/introspection.md) | `Altair\Introspection` | What's wired into this project right now? CLI commands + inspectors for the Container, routes, listeners, middleware, manifests, specs, and config. |
| [univeros/mcp](packages/mcp.md) | `Altair\Mcp` | Model Context Protocol server: exposes the framework's capabilities as MCP tools so any MCP-capable agent can drive an Altair project natively. |
Expand Down
26 changes: 26 additions & 0 deletions .agent/packages/idempotency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# univeros/idempotency · Altair\Idempotency

**Purpose:** Stripe-style Idempotency-Key primitive for Univeros: storage contract, adapters, and (in companion packages) PSR-15 middleware + spec block.

## Public contracts

| Interface | Method | Returns | Notes |
|---|---|---|---|
| `IdempotencyStoreInterface` | `claim(string, string, int)` | `StoredResponse\|null` | |
| | `complete(string, StoredResponse, int)` | `void` | |
| | `get(string)` | `StoredResponse\|null` | |
| | `release(string)` | `void` | |

## Concrete classes

- `ApcuStore` _(final)_ — implements `IdempotencyStoreInterface`
- `InMemoryStore` _(final)_ — implements `IdempotencyStoreInterface`
- `RedisStore` _(final)_ — implements `IdempotencyStoreInterface`
- `StoredResponse` _(final)_

## Tests as documentation

- `tests/Idempotency/Storage/ApcuStoreTest.php`
- `tests/Idempotency/Storage/InMemoryStoreTest.php`
- `tests/Idempotency/Storage/RedisStoreTest.php`
- `tests/Idempotency/Storage/StoredResponseTest.php`
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"univeros/filesystem": "self.version",
"univeros/happen": "self.version",
"univeros/http": "self.version",
"univeros/idempotency": "self.version",
"univeros/index": "self.version",
"univeros/introspection": "self.version",
"univeros/mcp": "self.version",
Expand Down
74 changes: 74 additions & 0 deletions src/Altair/Idempotency/Contracts/IdempotencyStoreInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Idempotency\Contracts;

use Altair\Idempotency\Storage\StoredResponse;

/**
* Backing store for the idempotency middleware.
*
* Implementations coordinate three operations:
*
* - {@see self::claim()} atomically reserves a key for in-progress
* execution. The return value disambiguates the three meaningful
* states: `null` = the caller now owns the key and must execute;
* `StoredResponse with inProgress=true` = another caller is already
* executing; `StoredResponse with inProgress=false` = a previous
* execution already completed and the cached response can be
* replayed.
*
* - {@see self::complete()} persists the captured response under a key
* previously claimed by the same caller. Overwrites the in-progress
* marker.
*
* - {@see self::release()} drops a claim — used when the handler threw
* and the framework does not want to stick callers with a cached 5xx
* for the whole TTL window.
*
* Adapters that cannot guarantee atomic claim ({@see self::claim()}'s
* primary contract) should throw {@see \Altair\Idempotency\Exception\IdempotencyException}
* at construction time rather than silently degrading.
*/
interface IdempotencyStoreInterface
{
/**
* Atomically reserve a key with the given request hash, or return
* the existing record when the key has been seen before.
*
* @param string $key Idempotency-Key header value (already validated).
* @param string $requestHash SHA-256 of the request body bytes.
* @param int $ttlSeconds Lifetime of the claim in seconds.
*
* @return ?StoredResponse `null` when the caller now owns the key
* (must execute the request); a `StoredResponse`
* when the key was already present (replay or
* in-progress, distinguished by `inProgress`).
*/
public function claim(string $key, string $requestHash, int $ttlSeconds): ?StoredResponse;

/**
* Persist the captured response under a previously-claimed key.
* The TTL is re-applied so a completed entry survives at least
* `$ttlSeconds` from the moment of completion.
*/
public function complete(string $key, StoredResponse $response, int $ttlSeconds): void;

/**
* Drop the claim on a key without persisting a completed response.
*/
public function release(string $key): void;

/**
* Read the current entry for a key, or `null` if absent / expired.
*/
public function get(string $key): ?StoredResponse;
}
22 changes: 22 additions & 0 deletions src/Altair/Idempotency/Exception/IdempotencyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Idempotency\Exception;

use RuntimeException;

/**
* Raised by an {@see \Altair\Idempotency\Contracts\IdempotencyStoreInterface}
* adapter when an irrecoverable storage failure occurs — connection lost,
* backend missing, etc. Recoverable conditions (key already claimed,
* payload mismatch) are signalled by return value, not exception.
*/
class IdempotencyException extends RuntimeException {}
87 changes: 87 additions & 0 deletions src/Altair/Idempotency/Storage/ApcuStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Idempotency\Storage;

use Altair\Idempotency\Contracts\IdempotencyStoreInterface;
use Altair\Idempotency\Exception\IdempotencyException;

/**
* Single-host idempotency store backed by APCu's shared memory cache.
*
* `apcu_add` is the atomic claim primitive: it inserts a key only when
* absent, returning `false` when the key already exists. That is what
* makes concurrent claims for the same key safe.
*
* The key namespace is configurable so multiple applications sharing
* one APCu instance do not collide; default `altair.idem.`.
*
* Throws {@see IdempotencyException} at construction time when the
* APCu extension is not available — the store is unusable in that
* environment and silently degrading would mask production bugs.
*/
final readonly class ApcuStore implements IdempotencyStoreInterface
{
public function __construct(private string $keyPrefix = 'altair.idem.')
{
if (!\function_exists('apcu_add')) {
throw new IdempotencyException('ApcuStore requires the apcu extension; install or enable ext-apcu, or pick a different IdempotencyStore implementation.');
}
}

public function claim(string $key, string $requestHash, int $ttlSeconds): ?StoredResponse
{
$fullKey = $this->qualify($key);
$now = time();
$entry = StoredResponse::inProgress($requestHash, $now);

if (apcu_add($fullKey, $entry->toJson(), $ttlSeconds)) {
return null;
}

return $this->fetch($fullKey);
}

public function complete(string $key, StoredResponse $response, int $ttlSeconds): void
{
$fullKey = $this->qualify($key);
$stored = apcu_store($fullKey, $response->toJson(), $ttlSeconds);
if ($stored !== true) {
throw new IdempotencyException(\sprintf("ApcuStore::complete() failed to write key '%s'.", $fullKey));
}
}

public function release(string $key): void
{
apcu_delete($this->qualify($key));
}

public function get(string $key): ?StoredResponse
{
return $this->fetch($this->qualify($key));
}

private function fetch(string $fullKey): ?StoredResponse
{
$success = false;
$raw = apcu_fetch($fullKey, $success);
if (!$success || !\is_string($raw)) {
return null;
}

return StoredResponse::fromJson($raw);
}

private function qualify(string $key): string
{
return $this->keyPrefix . $key;
}
}
85 changes: 85 additions & 0 deletions src/Altair/Idempotency/Storage/InMemoryStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Idempotency\Storage;

use Altair\Idempotency\Contracts\IdempotencyStoreInterface;

/**
* Process-local store. Suitable for tests and single-worker scripts;
* never for production HTTP. Entries live in a plain PHP array and are
* subject to a soft TTL: `get()` returns `null` for expired entries
* and lazily removes them.
*
* The clock is injectable so tests can advance time deterministically.
*/
final class InMemoryStore implements IdempotencyStoreInterface
{
/** @var array<string, array{response: StoredResponse, expires_at: int}> */
private array $entries = [];

/** @var callable(): int */
private $clock;

/**
* @param ?callable(): int $clock Returns Unix seconds. Defaults to `time()`.
*/
public function __construct(?callable $clock = null)
{
$this->clock = $clock ?? time(...);
}

public function claim(string $key, string $requestHash, int $ttlSeconds): ?StoredResponse
{
$existing = $this->get($key);
if ($existing instanceof StoredResponse) {
return $existing;
}

$now = ($this->clock)();
$this->entries[$key] = [
'response' => StoredResponse::inProgress($requestHash, $now),
'expires_at' => $now + $ttlSeconds,
];

return null;
}

public function complete(string $key, StoredResponse $response, int $ttlSeconds): void
{
$now = ($this->clock)();
$this->entries[$key] = [
'response' => $response,
'expires_at' => $now + $ttlSeconds,
];
}

public function release(string $key): void
{
unset($this->entries[$key]);
}

public function get(string $key): ?StoredResponse
{
if (!isset($this->entries[$key])) {
return null;
}

$entry = $this->entries[$key];
if ($entry['expires_at'] <= ($this->clock)()) {
unset($this->entries[$key]);

return null;
}

return $entry['response'];
}
}
Loading
Loading