$npx -y skills add Lonsdale201/wp-agent-skills --skill bd-source-adapterAdd a new source adapter to better-data — code that reads from a WordPress data store the library doesn't cover yet (comments, attachments, transients, custom tables). Mirror the canonical shape PostSource / UserSource / TermSource use — a non-final class with static hydrate(int|
| 1 | # better-data: Adding a source adapter |
| 2 | |
| 3 | For library maintainers integrating a new WordPress data store with better-data — comments, attachments, transients, custom tables, taxonomy hierarchies. Sources hydrate stored data into typed DataObjects via `AttributeDrivenHydrator`; the work is wiring up the bridge between WP's storage idioms and the hydrator's closure contract. |
| 4 | |
| 5 | ## Misconception this skill corrects |
| 6 | |
| 7 | > "I'll call `get_post_meta($id, $key, true)` directly — empty string means 'not set'." |
| 8 | |
| 9 | Wrong. WordPress `get_meta($key, true)` returns `''` (empty string) for both "key does not exist" AND "key exists with empty-string value". The `AttributeDrivenHydrator` distinguishes these because the **default-value fallback** at [src/Internal/AttributeDrivenHydrator.php:90-95](AttributeDrivenHydrator.php) needs to know "missing → use the parameter's default" vs "present-but-empty → coerce as empty string". Without the distinction, every nullable meta-backed field with a non-null default falls back incorrectly. |
| 10 | |
| 11 | The contract: the closure you pass to the hydrator returns: |
| 12 | |
| 13 | - `null` → key does not exist (hydrator falls back to parameter default / parameter nullable) |
| 14 | - the stored value (including `''`) → key exists, hydrator coerces |
| 15 | |
| 16 | Verified pattern from [src/Source/PostSource.php:91-93](PostSource.php): |
| 17 | |
| 18 | ```php |
| 19 | if (\function_exists('metadata_exists') |
| 20 | && !\metadata_exists('post', $postId, $key)) { |
| 21 | return null; // key does not exist |
| 22 | } |
| 23 | return \get_post_meta($postId, $key, true); // exists; could be '' |
| 24 | ``` |
| 25 | |
| 26 | Other AI-prone misconceptions: |
| 27 | |
| 28 | - "I'll instantiate `WP_User` directly with `new WP_User($id)` in a hot loop." Wrong — bypasses object cache. Use `get_user_by('id', $id)` so the per-request cache participates. |
| 29 | - "Bulk hydration is just `array_map($id => hydrate($id))`." Wrong — without prewarming, that's N+1 queries. Always call `update_meta_cache(...)` (and `_prime_post_caches` for posts) first. |
| 30 | - "I'll write the WP-touching code in `src/Internal/`." Wrong — `src/Internal/` is the WP-free engine zone (so it's unit-testable without a WP runtime). Source adapters live in `src/Source/` and CAN call WP functions. |
| 31 | |
| 32 | ## When to use this skill |
| 33 | |
| 34 | Trigger when ANY of the following is true: |
| 35 | |
| 36 | - Creating a new file under `src/Source/`. |
| 37 | - The diff adds a `hydrate(...)` / `hydrateMany(...)` method that reads from a WP table. |
| 38 | - Adding a `::fromComment($id)` / `::fromAttachment($id)` etc. shortcut to `HasWpSources`. |
| 39 | - Reviewing a PR that calls `get_*_meta($key, true)` without a `metadata_exists` guard inside a source. |
| 40 | |
| 41 | ## Workflow |
| 42 | |
| 43 | ### 1. File location |
| 44 | |
| 45 | Public source adapter: `src/Source/<Name>Source.php`. The pure, WP-free engine that does the attribute-driven work already lives in `src/Internal/AttributeDrivenHydrator.php` — DON'T duplicate it. Your job is to feed the hydrator a fetcher closure. |
| 46 | |
| 47 | ### 2. Method shape — match the existing sources |
| 48 | |
| 49 | Every source has the same two static methods: |
| 50 | |
| 51 | ```php |
| 52 | public static function hydrate(int|\WP_Comment $record, string $dtoClass): DataObject; |
| 53 | |
| 54 | /** |
| 55 | * @template T of DataObject |
| 56 | * @param list<int> $ids |
| 57 | * @param class-string<T> $dtoClass |
| 58 | * @return list<T> |
| 59 | */ |
| 60 | public static function hydrateMany(array $ids, string $dtoClass): array; |
| 61 | ``` |
| 62 | |
| 63 | Why static: the source has no per-instance state. Why this exact shape: `HasWpSources` wires `Dto::fromComment($id)` to `CommentSource::hydrate($id, static::class)`; deviating breaks the trait contract. |
| 64 | |
| 65 | ### 3. The fetcher closure contract |
| 66 | |
| 67 | Every source ends up calling (or fakes a call to) `AttributeDrivenHydrator::hydrate($dtoClass, $primary, $metaFetcher)`. The third arg is a closure: |
| 68 | |
| 69 | ```php |
| 70 | $metaFetcher = static function (string $key) use ($commentId): mixed { |
| 71 | if (\function_exist |