delete()` convention; neither outcome is an error. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use RuntimeException; /** * Persistence port for `affilun_product_meta` rows. * * See the file-level docblock for the contract's scope, the record * type it exchanges, and its insert-vs-update / not-found / delete * semantics. */ interface ProductMetaRepository { /** * Persist the given record. * * Implementations decide insert vs. update based on whether the * supplied record carries an id: * * - Null id: insert. The implementation assigns the auto- * increment primary key and returns a new record carrying * the assigned id. The returned record reflects any other * column defaults the database applied (for example * timestamps normalized to the storage precision). * * - Non-null id: update. The implementation writes every column * on the record to the matching row. The returned record is * the canonical post-save shape; callers MUST use the returned * record (not the input) for any subsequent operation so * `updated_at` and any implementation-normalized values are * not silently dropped. * * A save that targets a non-null id which no longer exists in the * database MUST raise a {@see RuntimeException} rather than * silently inserting a new row with a caller-controlled id — the * application's update-after-delete race should be a visible * error, not a data-integrity surprise. * * The `(product_id, meta_key)` composite unique axis from §5.3 * MUST be honored by implementations. A save that would violate * the constraint (insert a duplicate axis, or move an existing * row onto an axis already occupied by another row) MUST raise a * {@see RuntimeException}. * * @param ProductMetaRecord $record Record to persist. * * @return ProductMetaRecord The post-save record, always carrying * a non-null id. * * @throws RuntimeException When the underlying driver reports an * error, when the * `(product_id, meta_key)` unique * constraint is violated, or when an * update targets a row that no longer * exists. */ public function save( ProductMetaRecord $record ): ProductMetaRecord; /** * 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 RuntimeException When the underlying driver reports an * error. */ public function findById( int $id ): ?ProductMetaRecord; /** * Look up a record by its `(product_id, meta_key)` composite * unique key from §5.3. * * Returns null when no row matches. Non-positive product ids and * empty meta keys are programming errors and implementations MAY * raise {@see \InvalidArgumentException}. * * This is the primary lookup path for the "upsert by product + * key" pattern: callers probe for an existing row, branch on * absence, and either insert a fresh record or update the * returned one. The composite axis itself is enforced by the * underlying unique constraint, so this method is consistent * with {@see self::save()} by construction. * * @param int $productId Owning `affilun_products.id` reference. * @param string $metaKey Meta key. * * @throws RuntimeException When the underlying driver reports an * error. */ public function findByProductAndKey( int $productId, string $metaKey ): ?ProductMetaRecord; /** * Delete the row identified by the given id. * * 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}. * * Note: cascade semantics for "delete every meta row owned by * product X when X itself is removed" are NOT part of this * contract. Callers that need cascade today are responsible for * enumerating ids and calling this method per row; a dedicated * `deleteByProduct()` helper will be added when the Phase 9 sync * cascade materializes. * * @param int $id Primary key of the row to delete. * * @throws RuntimeException When the underlying driver reports an * error. */ public function delete( int $id ): bool; /** * Total number of rows currently stored in `affilun_product_meta`. * * 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; }