*/ private array $records = array(); /** * Next auto-increment id to allocate on insert. * * Starts at 1 and advances monotonically with every * successful insert. Mirrors the MySQL `AUTO_INCREMENT` * convention: ids are never reused, even after a delete. */ private int $nextId = 1; /** * Construct the repository, optionally seeded with a set of * already-persisted records. * * Seeding is primarily useful for tests that need to exercise * "existing environment" code paths without driving a * sequence of {@see self::save()} calls first. Seed records * MUST carry a non-null id; their ids are honored verbatim, * and the repository's auto-increment cursor advances past * the highest seeded id so subsequent inserts cannot collide * with a seeded row. * * @param list $seed Records to preload. * * @throws InvalidArgumentException When a seed record has no * id or when two seeds * share an id. */ public function __construct( array $seed = array() ) { foreach ( $seed as $record ) { $id = $record->id(); if ( $id === null ) { $message = 'InMemoryAuditEventRepository seed records must carry a non-null id.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } if ( array_key_exists( $id, $this->records ) ) { $message = sprintf( 'InMemoryAuditEventRepository seed contains duplicate id %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } $this->records[ $id ] = $record; if ( $id >= $this->nextId ) { $this->nextId = $id + 1; } } } /** * Persist the record and return its post-save form. * * INSERT-ONLY: rejects records that already carry a non-null * id with {@see \RuntimeException} per the §10.1 append-only * invariant. * * @param AuditEventRecord $record Record to persist (id MUST be null). * * @throws RuntimeException When `$record` already carries a * non-null id. */ public function save( AuditEventRecord $record ): AuditEventRecord { if ( $record->id() !== null ) { $message = sprintf( 'InMemoryAuditEventRepository::save() rejects records with a non-null id (got %d); ' . 'affilun_audit_events is append-only per database-schema.md §10.1.', $record->id() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new RuntimeException( $message ); } $id = $this->nextId; ++$this->nextId; $persisted = $record->withId( $id ); $this->records[ $id ] = $persisted; return $persisted; } /** * Look up a record by its primary key. * * Returns null when no row matches; raises * {@see InvalidArgumentException} for non-positive ids so a * programming error at the call site surfaces loudly instead * of silently missing. * * @param int $id Primary key to look up. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function findById( int $id ): ?AuditEventRecord { if ( $id <= 0 ) { $message = sprintf( 'InMemoryAuditEventRepository::findById() requires a positive id, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } return $this->records[ $id ] ?? null; } /** * Remove 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. * * Non-positive ids raise {@see InvalidArgumentException} so * a programming error at the call site surfaces loudly * instead of silently missing. * * @param int $id Primary key of the row to delete. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function delete( int $id ): bool { if ( $id <= 0 ) { $message = sprintf( 'InMemoryAuditEventRepository::delete() requires a positive id, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( $id, $this->records ) ) { return false; } unset( $this->records[ $id ] ); return true; } /** * Total number of rows currently stored. */ public function count(): int { return count( $this->records ); } /** * Return every stored record, ordered by ascending primary key. * * Exposed purely as a convenience for test assertions that * want to enumerate the repository contents without driving * a paginated listing. Production code MUST NOT rely on this * method because it has no counterpart on the * {@see AuditEventRepository} contract and the Wpdb-backed * implementation does not offer it. * * @return list */ public function all(): array { $ordered = $this->records; ksort( $ordered, SORT_NUMERIC ); return array_values( $ordered ); } /** * Next auto-increment id the repository would allocate on * the following insert. * * Exposed purely as a convenience for test assertions that * want to probe the internal cursor without issuing a dummy * insert. Like {@see self::all()}, production code MUST NOT * rely on this. */ public function nextId(): int { return $this->nextId; } /** * Look up a paginated, filtered slice of stored rows. * * See {@see AuditEventRepository::findPaginated()} for the * contract. Filtering is applied per non-null axis on * `$query`; the remaining rows are sorted by descending id * (a proxy for `created_at DESC, id DESC` since this double * allocates ids monotonically) and sliced to the requested * page. * * @param AuditEventListQuery $query Validated list-query inputs. */ public function findPaginated( AuditEventListQuery $query ): AuditEventListResult { $matching = array(); foreach ( $this->records as $record ) { if ( ! $this->matchesQuery( $record, $query ) ) { continue; } $matching[] = $record; } usort( $matching, static function ( AuditEventRecord $a, AuditEventRecord $b ): int { return ( $b->id() ?? 0 ) <=> ( $a->id() ?? 0 ); } ); $total = count( $matching ); $offset = $query->offset(); $perPage = $query->perPage(); $slice = array_slice( $matching, $offset, $perPage ); /** @var list $slice */ return new AuditEventListResult( $slice, $total, $query->page(), $perPage ); } /** * Group every stored row by `audit_kind` and return the * per-kind counts. * * See {@see AuditEventRepository::countByAuditKind()} for * the contract; in particular, kinds with zero occurrences * are omitted so callers default-fill against * {@see AuditEventListQuery::VALID_AUDIT_KINDS}. * * @return array */ public function countByAuditKind(): array { $counts = array(); foreach ( $this->records as $record ) { $kind = $record->auditKind(); $counts[ $kind ] = ( $counts[ $kind ] ?? 0 ) + 1; } return $counts; } /** * Whether `$record` satisfies every non-null filter axis on * `$query`. * * Phase 2 KI-006 follow-up: the `request_id` / `correlation_id` * / `job_id` axes are honored by JSON-decoding the record's * `context_json` slot and comparing against the matching key. * The Wpdb-backed implementation honors the same axes via * `JSON_EXTRACT(context_json, '$.{key}')` so both * implementations stay behaviorally equivalent. * * @param AuditEventRecord $record Record under test. * @param AuditEventListQuery $query Filter axes. */ private function matchesQuery( AuditEventRecord $record, AuditEventListQuery $query ): bool { if ( $query->auditKind() !== null && $record->auditKind() !== $query->auditKind() ) { return false; } if ( $query->actorId() !== null && $record->actorId() !== $query->actorId() ) { return false; } if ( $query->targetType() !== null && $record->targetType() !== $query->targetType() ) { return false; } if ( $query->targetId() !== null && $record->targetId() !== $query->targetId() ) { return false; } $needsContextLookup = $query->requestId() !== null || $query->correlationId() !== null || $query->jobId() !== null; if ( ! $needsContextLookup ) { return true; } $context = self::decodeContext( $record->contextJson() ); if ( $query->requestId() !== null && self::contextStringValue( $context, 'request_id' ) !== $query->requestId() ) { return false; } if ( $query->correlationId() !== null && self::contextStringValue( $context, 'correlation_id' ) !== $query->correlationId() ) { return false; } if ( $query->jobId() !== null && self::contextStringValue( $context, 'job_id' ) !== $query->jobId() ) { return false; } return true; } /** * Decode a record's `context_json` slot into an associative * array, returning an empty array when the slot is null, * empty, malformed, or decodes to a non-object payload. * * Returning an empty array on every degenerate input keeps * the per-axis comparison branches in {@see self::matchesQuery()} * free of null-safety boilerplate; a missing key is then a * straightforward `array_key_exists()` miss. * * @param string|null $contextJson Raw JSON payload from * {@see AuditEventRecord::contextJson()}. * * @return array */ private static function decodeContext( ?string $contextJson ): array { if ( $contextJson === null || $contextJson === '' ) { return array(); } $decoded = json_decode( $contextJson, true ); if ( ! is_array( $decoded ) ) { return array(); } if ( $decoded !== array() && array_is_list( $decoded ) ) { return array(); } /** @var array $decoded */ return $decoded; } /** * Read the named slot from a decoded `context_json` map and * return it as a string, or null when the slot is absent or * non-string. * * Mirrors the value-coercion rule the §12.4 envelope * projection applies on the read side * ({@see \Affilun\Application\Audit\UseCase\ListAuditEventsUseCase}) * so the in-memory filter and the projected row see the * same set of values for the same key. * * @param array $context Decoded context map. * @param string $key Slot name to read. */ private static function contextStringValue( array $context, string $key ): ?string { if ( ! array_key_exists( $key, $context ) ) { return null; } $value = $context[ $key ]; if ( ! is_string( $value ) ) { return null; } return $value; } /** * Delete every stored record whose `created_at` is strictly * older than the supplied UTC cutoff and return the number * of records removed. * * Phase 2 KI-006 follow-up: implements the §10.4 retention * purge contract documented on * {@see AuditEventRepository::deleteOlderThan()}. * * Strict-less-than comparison rule: a record whose * `created_at` exactly equals the cutoff is preserved (so a * row landing on the boundary survives at least one full * sweep before disappearing on the next tick). The cutoff is * normalized to UTC before comparison so a non-UTC clock * cannot smuggle in an off-by-one drift. * * The auto-increment cursor is intentionally left untouched * after a purge, mirroring the MySQL `AUTO_INCREMENT` * convention also enforced by {@see self::delete()}: ids are * never reused, even after rows disappear from the table. * * @param DateTimeImmutable $cutoff UTC cutoff instant. * * @return int Number of rows removed. */ public function deleteOlderThan( DateTimeImmutable $cutoff ): int { $normalizedCutoff = $cutoff->setTimezone( new DateTimeZone( 'UTC' ) ); $removed = 0; foreach ( $this->records as $id => $record ) { if ( $record->createdAt() < $normalizedCutoff ) { unset( $this->records[ $id ] ); ++$removed; } } return $removed; } }