flush()`). * * Design notes: * * - The contract is deliberately narrow: it exposes only the * reverse-lookup surface the cache invalidator needs * ({@see self::findEmbedIdsByProductId()}) plus the minimal * write path used by the embed-editing surface * ({@see self::replaceEmbedItems()}, * {@see self::deleteByEmbedId()}). A future slice that needs to * enumerate items for an embed (for diagnostics, REST surfaces, * or the editor preview) can extend this contract without * re-shaping the existing methods. * * - {@see self::replaceEmbedItems()} owns the §7.1 "delete-then- * insert" semantics: the Phase 9 design treats the junction * row as immutable per insert (no `updated_at` column on the * table; see `database-schema.md` §7.2), so the editing * surface MUST clear the existing rows for an embed and * re-insert the desired set rather than diff-and-update. * Implementations SHOULD perform the delete + insert pair * inside a single transaction when the underlying driver * supports it so a partial failure does not leave the embed * half-updated. * * - {@see self::findEmbedIdsByProductId()} returns a `list` * of embed ids ordered by ascending embed id. The ordering is * NOT a §7.3 spec requirement; it is fixed at the repository * boundary so the cache invalidator's per-embed `forgetByPrefix()` * loop is deterministic and tests can assert on the exact * iteration order. * * - The contract speaks in primitive arrays of integers rather * than dedicated record types because the junction row carries * no domain-meaningful state beyond the (embed_id, product_id, * sort_order) triple. Wrapping that in a record type would add * ceremony without enabling new behavior; the persistence * layer's repository ports (per `EmbedRepository`, * `ProductRepository`) reserve record types for tables that * carry richer state. * * - The `(embed_id, product_id)` uniqueness constraint declared * in §7.3 is enforced by the schema; implementations SHOULD * surface duplicate-insert failures as * {@see \RuntimeException} rather than silently swallowing * them, so a programming error in the editing surface * (re-inserting a (embed_id, product_id) pair before the * replace path runs) fails loudly. * * 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. * * - "No matching items" is a success condition for read methods: * {@see self::findEmbedIdsByProductId()} returns an empty list * rather than null or throwing. This keeps the common case * (probe for embed references on every sync, branch on * emptiness) allocation-free and free of exception control * flow. * * - {@see self::deleteByEmbedId()} is idempotent: it returns the * count of removed rows, with zero indicating "nothing * matched". Neither outcome is an error. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use InvalidArgumentException; use RuntimeException; /** * Persistence port for `affilun_embed_items` rows. * * See the file-level docblock for the contract's scope, the * delete-then-insert semantics of {@see self::replaceEmbedItems()}, * and the reverse-lookup ordering rule on * {@see self::findEmbedIdsByProductId()}. */ interface EmbedItemRepository { /** * Find every embed id that references the supplied product. * * Backs the KI-002 per-product render-cache invalidation * sweep: the cache invalidator iterates the returned ids and * issues a targeted `forgetByPrefix('affilun:render:single:embed:N:')` * / `forgetByPrefix('affilun:render:grid:embed:N:')` per id rather * than flushing the whole render cache. * * Returns an empty list when no embed references the product, * which is the typical case for products only embedded * directly in post bodies via the Gutenberg block (the * post-body path does not materialize a `affilun_embeds` row per * `database-schema.md` §6.1 / §7.1). Callers MUST treat the * empty list as a normal outcome and SHOULD NOT fall back to * a broad render-cache flush — the post-body render keys are * scoped under the `affilun:render:*:adhoc:*` prefix family, * which the invalidator handles separately. * * Ordering: ascending embed id, fixed at the repository * boundary so the cache-invalidator loop and its tests are * deterministic. The §7.3 schema does not require a specific * order for this lookup; the repository owns the choice. * * @param int $productId Local `affilun_products.id` to look up. * Must be a positive integer. * * @return list Ascending embed ids. * * @throws InvalidArgumentException When `$productId` is non-positive. * @throws RuntimeException When the underlying driver * reports an error. */ public function findEmbedIdsByProductId( int $productId ): array; /** * Replace the items associated with `$embedId` with the * supplied ordered product-id list. * * Implements the §7.1 "delete-then-insert" semantics: every * existing junction row for `$embedId` is removed, then the * supplied product ids are inserted with `sort_order` taken * from their position in the list (0-based). An empty * `$productIds` list clears the embed's items. * * Implementations SHOULD execute the delete + insert pair * inside a single transaction when the driver supports it so * a partial failure does not leave the embed half-updated. * * Returns the number of rows inserted (i.e. `count($productIds)` * on a successful call). Callers can compare this against the * input length to detect a partial-write anomaly the driver * reported as success. * * Duplicate product ids in `$productIds` MUST raise * {@see InvalidArgumentException} BEFORE any DB write so the * caller observes the validation failure with the original * embed state intact. * * @param int $embedId Positive `affilun_embeds.id`. * @param list $productIds Ordered product ids; positions * become `sort_order` values. * * @return int Number of inserted rows. * * @throws InvalidArgumentException When `$embedId` is non-positive, * when `$productIds` contains a * non-positive integer, or when * `$productIds` contains * duplicates. * @throws RuntimeException When the underlying driver * reports an error. */ public function replaceEmbedItems( int $embedId, array $productIds ): int; /** * Remove every junction row for the supplied embed id. * * Idempotent: returns the number of rows removed, with zero * indicating "no rows matched". Neither outcome is an error. * * Used by the embed-deletion path (per the * {@see EmbedRepository::delete()} contract note that callers * are responsible for removing child rows before deleting the * parent embed). * * @param int $embedId Positive `affilun_embeds.id`. * * @throws InvalidArgumentException When `$embedId` is non-positive. * @throws RuntimeException When the underlying driver * reports an error. */ public function deleteByEmbedId( int $embedId ): int; /** * Total number of rows currently stored in `affilun_embed_items`. * * 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; }