Skip to content

feat(scaffold): idempotency: spec block + scaffolder integration (#174)#179

Merged
tonydspaniard merged 1 commit into
masterfrom
feat/174-idempotency-spec-block
May 31, 2026
Merged

feat(scaffold): idempotency: spec block + scaffolder integration (#174)#179
tonydspaniard merged 1 commit into
masterfrom
feat/174-idempotency-spec-block

Conversation

@tonydspaniard
Copy link
Copy Markdown
Member

Closes #174. Part of #171. Depends on #172 + #173.

Summary

Spec-driven entry point for the idempotency primitive. A spec carrying:

idempotency:
  ttl: 24h
  scope: tenant
  mode: required

now scaffolds an Action that exposes the policy via a static idempotency() accessor:

public static function idempotency(): array
{
    return ['ttl' => '24h', 'scope' => 'tenant', 'mode' => 'required'];
}

The host application's IdempotencyKeyMiddleware (#173) consumes that metadata via IdempotencyConfiguration to build the per-endpoint policy from spec — no hand-wiring per route.

Pieces

  • Altair\Scaffold\Spec\Ast\IdempotencySpec — readonly value object with ttl + scope (default tenant) + mode (default optional).
  • Spec.idempotency + Spec::hasIdempotency() — backwards-compatible default null.
  • Parser::parseIdempotency() — reads the block; raises SpecParseException on missing ttl.
  • Validator::validateIdempotency() — enforces ttl pattern ^[0-9]+(ms|s|m|h|d)$, non-empty scope, and mode enum.
  • ActionEmitter — renders the static idempotency() accessor on the generated Action when the spec carries the block. When absent, output is byte-for-byte identical to today's scaffold.
  • Altair\Idempotency\Configuration\IdempotencyConfiguration — minimal DI binding: IdempotencyStoreInterface \xe2\x86\x92 InMemoryStore by default; hosts swap to ApcuStore / RedisStore by re-binding.

Why a static accessor and not a base-class slot

The current Altair\Http\Base\Action doesn't carry a middleware-pipeline slot. Modifying it would expand #174 well beyond the issue scope. A static accessor on the generated Action is the smallest invasive change — host middleware reads it from the resolved Action instance via IdempotencyConfiguration and configures the runtime IdempotencyKeyMiddleware accordingly.

The actual route-pipeline auto-wiring is intentionally left as a follow-up because the pattern depends on the host's router and middleware stack (which varies). #176 will document the wiring recipe; once a clear convention emerges across consumers a dedicated issue can tighten the auto-wiring.

Tests (+12)

  • IdempotencyParserTest (8) \xe2\x80\x94 happy path, defaults, missing-ttl raise, absent-block null, validator's ttl-pattern / mode / all-units / valid-block / aggregated-exception cases.
  • ActionEmitterIdempotencyTest (3) \xe2\x80\x94 accessor absent without block, accessor present + structurally correct with block, generated PHP tokenises cleanly.
  • SpecFixture::createUserWithIdempotency() added for downstream reuse.

Test plan

  • composer cs \xe2\x80\x94 green
  • composer stan \xe2\x80\x94 green
  • composer rector (full tree, no cache) \xe2\x80\x94 green
  • composer test \xe2\x80\x94 6287 tests (+12 new), 0 new failures
  • bin/altair manifest:generate \xe2\x80\x94 clean

Out of scope

Adds the spec-driven entry point for the idempotency primitive
delivered by #172 (storage) + #173 (middleware).

Spec block
- idempotency.ttl (required; ^[0-9]+(ms|s|m|h|d)$)
- idempotency.scope (optional; default 'tenant')
- idempotency.mode (optional; 'optional' | 'required'; default 'optional')

When absent, no metadata is emitted on the Action — pre-existing
scaffolds stay byte-for-byte identical.

Pieces
- IdempotencySpec AST node + Spec.idempotency + Spec::hasIdempotency()
- Parser reads the block; throws SpecParseException when ttl is missing.
- Validator enforces ttl pattern, non-empty scope, and mode enum.
- ActionEmitter adds a static idempotency() accessor on the generated
  Action when the spec carries the block, exposing the ttl/scope/mode
  policy for the host application's IdempotencyKeyMiddleware to
  consume via IdempotencyConfiguration.

Configuration
- New Altair\Idempotency\Configuration\IdempotencyConfiguration
  (univeros/idempotency) binds IdempotencyStoreInterface to
  InMemoryStore by default. Host applications swap to ApcuStore or
  RedisStore by re-binding after this Configuration applies.
- univeros/idempotency picks up univeros/configuration + univeros/container
  deps to support the binding.

Out of scope (per #171)
- The x-altair-idempotency round-trip activation — #175.
- Auto-wiring the middleware into the route pipeline — host's job in
  v1; will be tightened once a clear pattern emerges across consumers.
- docs/packages/idempotency.md + benchmark variant — #176.

Tests (+12)
- IdempotencyParserTest covers happy path, defaults, missing-ttl raise,
  absent-block null, validator's ttl-pattern / mode / good cases, and
  the aggregated SpecValidationException.
- ActionEmitterIdempotencyTest verifies the accessor is absent without
  the block, present and structurally correct with it, and that the
  generated PHP tokenises cleanly.
- SpecFixture::createUserWithIdempotency() added so future tests can
  reuse a canonical fixture.

Part of #171. Closes #174.
@tonydspaniard tonydspaniard merged commit 5a162af into master May 31, 2026
4 checks passed
@tonydspaniard tonydspaniard deleted the feat/174-idempotency-spec-block branch May 31, 2026 04:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

idempotency: spec block + scaffolder integration (AST + parser + validator + emitter wiring)

1 participant