delete()` semantics. * * - {@see self::count()} is exposed so diagnostics surfaces * and integration tests can assert on the pipeline outcome * without enumerating rows. Implementations SHOULD back this * with a `SELECT COUNT(*)` query rather than materializing * every row. * * - {@see self::findPaginated()} backs the * `GET /affilun/v1/audit/events` REST endpoint described in * `specifications/rest-api-spec.md` §12.4 and the future * audit admin page. The query VO carries the optional * column-level filter axes (`auditKind`, `actorId`, * `targetType`, `targetId`) and the pagination axis * (`page`, `perPage`); see {@see AuditEventListQuery} for * the exhaustive validation rules. * * The returned slice is ordered newest-first * (`ORDER BY created_at DESC, id DESC`); §12.4 does not * expose a sort parameter so the ordering is fixed at the * repository boundary. The total reported on the result * envelope reflects the filter set BEFORE pagination so REST * consumers can compute the page count without re-issuing * the query. * * - {@see self::countByAuditKind()} groups every stored row by * audit-kind and returns the per-kind counts. Used by the * §12.5 summary surface follow-up and by diagnostics that * want a quick "what kinds of audit events have been * recorded" overview without paginating the full table. * Statuses with zero occurrences are OMITTED from the * returned map; callers that need a fully-populated * histogram should default-fill against * {@see AuditEventListQuery::VALID_AUDIT_KINDS}. * * - All timestamps on {@see AuditEventRecord} are UTC * {@see \DateTimeImmutable} instances. Implementations are * responsible for converting to and from the MySQL * `Y-m-d H:i:s` representation on the storage boundary; * callers never see the string form through this port. * * Failure semantics: * * - Database-level errors (connection loss, constraint * violations, malformed rows) MUST surface as exceptions. * Implementations SHOULD wrap driver-specific errors in a * descriptive {@see \RuntimeException} so call sites can * catch at a single boundary without importing driver * classes. * * - "Not found" is a success condition for read methods: they * return null rather than throwing. This keeps the common * case (probe for an existing row, branch on absence) * allocation-free and free of exception control flow. * * - Delete is idempotent. {@see self::delete()} returns true * when a row was removed and false when none matched, * matching the `$wpdb->delete()` convention; neither * outcome is an error. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use DateTimeImmutable; use InvalidArgumentException; use RuntimeException; /** * Persistence port for `affilun_audit_events` rows. * * See the file-level docblock for the contract's append-only * scope, the record type it exchanges, the * {@see self::findPaginated()} ordering rule, and the not-found * / delete semantics. */ interface AuditEventRepository { /** * Persist the given audit-event record. * * This is an INSERT-ONLY operation. Implementations MUST: * * - Accept only records whose id is null. Records with a * non-null id raise {@see RuntimeException} because the * §10.1 append-only invariant rules out any in-place * mutation path. * * - Allocate the auto-increment primary key and return a * new record carrying the assigned id. Callers MUST use * the returned record (not the input) for any subsequent * operation so timestamps normalized to the storage * precision are not silently dropped. * * @param AuditEventRecord $record Record to persist (id MUST be null). * * @return AuditEventRecord The post-save record, always * carrying a non-null id. * * @throws RuntimeException When the underlying driver reports * an error or when `$record` already * carries a non-null id. */ public function save( AuditEventRecord $record ): AuditEventRecord; /** * Look up a record by its auto-increment primary key. * * Returns null when no row matches. Non-positive ids are a * programming error and implementations MAY raise * {@see InvalidArgumentException}; callers that only have an * optional id in hand should filter before calling. * * @param int $id Primary key to look up. * * @throws InvalidArgumentException When `$id` is non-positive. * @throws RuntimeException When the underlying driver * reports an error. */ public function findById( int $id ): ?AuditEventRecord; /** * Delete the row identified by the given id. * * Reserved for the §10.4 retention purge path; application- * layer audit writers MUST NOT call this during normal * operation. Idempotent: returns true when a row was removed, * false when no row matched. Neither outcome is an error. * * Non-positive ids are a programming error and * implementations MAY raise * {@see InvalidArgumentException}. * * @param int $id Primary key of the row to delete. * * @throws InvalidArgumentException When `$id` is non-positive. * @throws RuntimeException When the underlying driver * reports an error. */ public function delete( int $id ): bool; /** * Total number of rows currently stored in * `affilun_audit_events`. * * Used by diagnostics and integration tests to assert on the * pipeline outcome without enumerating rows. Implementations * SHOULD back this with a `SELECT COUNT(*)` query rather * than materializing every row. * * @throws RuntimeException When the underlying driver reports * an error. */ public function count(): int; /** * Look up a paginated, filtered slice of stored rows. * * Implements the `GET /affilun/v1/audit/events` query surface * described in `specifications/rest-api-spec.md` §12.4. The * query VO carries the optional column-level filter axes * (`auditKind`, `actorId`, `targetType`, `targetId`) and the * pagination axis (`page`, `perPage`); see * {@see AuditEventListQuery} for the exhaustive validation * rules. * * The returned slice is ordered newest-first * (`ORDER BY created_at DESC, id DESC`); §12.4 does not * expose a sort parameter, so the ordering is fixed at the * repository boundary. The total reported on the result * envelope reflects the filter set BEFORE pagination so REST * consumers can compute the page count without re-issuing * the query. * * @param AuditEventListQuery $query Validated list-query inputs. * * @throws RuntimeException When the underlying driver reports * an error or when a returned row * cannot be decoded into an * {@see AuditEventRecord}. */ public function findPaginated( AuditEventListQuery $query ): AuditEventListResult; /** * Group every stored row by `audit_kind` and return the * per-kind counts. * * Used by the §12.5 summary follow-up and by diagnostics * surfaces that want a quick "what kinds of audit events * have been recorded" overview without paginating the full * table. Kinds with zero occurrences are OMITTED from the * returned map; callers that need a fully-populated * histogram (every kind with a numeric count) should * default-fill against * {@see AuditEventListQuery::VALID_AUDIT_KINDS}. * * Implementations SHOULD back this with a single * `SELECT audit_kind, COUNT(*) ... GROUP BY audit_kind` * query rather than issuing one count per kind. * * @return array Map of audit-kind => count. * Kinds with zero occurrences are * omitted. * * @throws RuntimeException When the underlying driver reports * an error. */ public function countByAuditKind(): array; /** * Delete every row whose `created_at` is strictly older than * the supplied UTC cutoff and return the number of rows * removed. * * Phase 2 KI-006 follow-up: backs the §10.4 audit-retention * purge worker. The cutoff is a UTC instant computed by the * caller (typically `clock->now() - retentionWindow`); rows * whose `created_at` is **strictly** less than the cutoff are * removed, rows whose `created_at` equals the cutoff are * preserved (so a row landing on the boundary survives at * least one full sweep). * * Implementations SHOULD back this with a single * `DELETE FROM affilun_audit_events WHERE created_at < %s` query * rather than enumerating ids and re-issuing per-row deletes; * the §10.4 retention window is measured in months so a * single sweep can legitimately touch thousands of rows. * * Idempotent: calling the method twice with the same cutoff * leaves the table in the same end state (the second call * returns 0). Negative cutoffs are nonsensical for an * append-only table that only stores `created_at` instants * derived from {@see \Affilun\Domain\Clock::now()} * so implementations MAY treat them as the empty deletion * case rather than raising. * * @param DateTimeImmutable $cutoff UTC cutoff instant; rows * strictly older than this * are removed. * * @return int Number of rows actually deleted. * * @throws RuntimeException When the underlying driver reports * an error. */ public function deleteOlderThan( DateTimeImmutable $cutoff ): int; }