AgentStack
SKILL verified MIT Self-run

Bd Source Adapter

skill-lonsdale201-wp-agent-skills-bd-source-adapter · by Lonsdale201

Add a new source adapter to better-data — code that reads

No reviews yet
0 installs
6 views
0.0% view→install

Install

$ agentstack add skill-lonsdale201-wp-agent-skills-bd-source-adapter

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Bd Source Adapter? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

better-data: Adding a source adapter

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.

Misconception this skill corrects

> "I'll call get_post_meta($id, $key, true) directly — empty string means 'not set'."

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.

The contract: the closure you pass to the hydrator returns:

  • null → key does not exist (hydrator falls back to parameter default / parameter nullable)
  • the stored value (including '') → key exists, hydrator coerces

Verified pattern from [src/Source/PostSource.php:91-93](PostSource.php):

if (\function_exists('metadata_exists')
    && !\metadata_exists('post', $postId, $key)) {
    return null;  // key does not exist
}
return \get_post_meta($postId, $key, true);  // exists; could be ''

Other AI-prone misconceptions:

  • "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.
  • "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.
  • "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.

When to use this skill

Trigger when ANY of the following is true:

  • Creating a new file under src/Source/.
  • The diff adds a hydrate(...) / hydrateMany(...) method that reads from a WP table.
  • Adding a ::fromComment($id) / ::fromAttachment($id) etc. shortcut to HasWpSources.
  • Reviewing a PR that calls get_*_meta($key, true) without a metadata_exists guard inside a source.

Workflow

1. File location

Public source adapter: src/Source/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.

2. Method shape — match the existing sources

Every source has the same two static methods:

public static function hydrate(int|\WP_Comment $record, string $dtoClass): DataObject;

/**
 * @template T of DataObject
 * @param  list      $ids
 * @param  class-string $dtoClass
 * @return list
 */
public static function hydrateMany(array $ids, string $dtoClass): array;

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.

3. The fetcher closure contract

Every source ends up calling (or fakes a call to) AttributeDrivenHydrator::hydrate($dtoClass, $primary, $metaFetcher). The third arg is a closure:

$metaFetcher = static function (string $key) use ($commentId): mixed {
    if (\function_exists('metadata_exists')
        && !\metadata_exists('comment', $commentId, $key)) {
        return null;  // CRUCIAL — distinguishes missing from empty
    }
    return \get_comment_meta($commentId, $key, true);
};

Without the metadata_exists guard, the closure returns '' for both missing and stored-empty, and the hydrator can't apply default-fallback logic correctly.

4. Bulk path: prewarm caches first

public static function hydrateMany(array $commentIds, string $dtoClass): array
{
    if ($commentIds === []) {
        return [];
    }

    // Prewarm WP's per-request comment cache.
    if (\function_exists('_prime_comment_caches')) {
        \_prime_comment_caches($commentIds);
    }

    // Prewarm meta cache (single SELECT for all comments × all keys).
    if (\function_exists('update_meta_cache')) {
        \update_meta_cache('comment', $commentIds);
    }

    return \array_map(
        static fn (int $id): DataObject => self::hydrate($id, $dtoClass),
        $commentIds,
    );
}

Result: N comments cost 1 prime + 1 meta + N hydrate calls (cache hits) — instead of N comments × (1 SELECT comment + N SELECT meta) = O(N²).

5. Add a HasWpSources shortcut

If the source maps to a natural shortcut, add a method on the trait at [src/Source/HasWpSources.php:28](HasWpSources.php):

// Inside trait HasWpSources:
public static function fromComment(int|\WP_Comment $comment): static
{
    /** @var static */
    return CommentSource::hydrate($comment, static::class);
}

DTO authors then write:

final readonly class CommentDto extends DataObject
{
    use HasWpSources;
    // ...
}

$dto = CommentDto::fromComment($commentId);

Don't add a shortcut for niche sources — RowSource doesn't have one because raw row hydration is intentionally call-site-explicit.

6. Exception strategy

Throw a typed exception when the primary record doesn't exist. Mirror [src/Exception/PostNotFoundException.php](PostNotFoundException.php):

namespace BetterData\Exception;

final class CommentNotFoundException extends \RuntimeException
{
    public static function forId(string $dtoClass, int $id): self
    {
        return new self(\sprintf('No comment found for %s with ID %d', $dtoClass, $id));
    }
}

7. Testing

Two test layers:

tests/Unit/CommentSourceTest.php       ← pure engine via fake fetcher closure
tests/Fixtures/CommentDtoFixture.php   ← realistic DTO shape
companion plugin: src/Stress/CommentScenario.php  ← live-WP behavior

Unit tests don't require WP — pass a hand-crafted closure to AttributeDrivenHydrator::hydrate directly. Stress tests against a live WP install verify cache primings and metadata_exists behavior in the real environment.

vendor/bin/phpunit --filter CommentSource
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/php-cs-fixer fix
wp better-data stress  # in companion plugin context

Critical rules

  • Lives in src/Source/, not src/Internal/. Internal stays WP-free.
  • hydrate(int|, string $dtoClass): DataObject + hydrateMany(list, string $dtoClass): list — exact two-method shape every source uses.
  • Meta fetcher closure: return null for missing, stored value otherwise. Use metadata_exists (or equivalent) before fetching. Empty string is NOT missing.
  • Bulk path prewarms caches. _prime_*_caches for the primary objects, update_meta_cache(...) for meta. Without it, you've shipped O(N²).
  • Use get_user_by('id', $id), not new WP_User($id) in user-related sources — object cache participation.
  • Wrap WP function calls in function_exists if the source is unit-tested without WP. Allows tests to import Source without bootstrapping WP.
  • Throw a typed exception when the primary record is missing (PostNotFoundException-style). Don't return null from hydrate — the caller can't tell hydration-of-missing from hydration-of-empty.
  • Add the HasWpSources shortcut if the source has a natural ergonomic call site (most do; RowSource is the exception).

Common mistakes

// WRONG — get_post_meta with empty-string ambiguity
$metaFetcher = static fn (string $key): mixed => \get_post_meta($postId, $key, true);
// Returns '' for missing AND stored-empty — defaults break.

// RIGHT
$metaFetcher = static function (string $key) use ($postId): mixed {
    if (\function_exists('metadata_exists')
        && !\metadata_exists('post', $postId, $key)) {
        return null;
    }
    return \get_post_meta($postId, $key, true);
};

// WRONG — N+1 in hydrateMany
public static function hydrateMany(array $ids, string $dtoClass): array
{
    return \array_map(
        static fn (int $id) => self::hydrate($id, $dtoClass),
        $ids,
    );
}
// Each hydrate call hits cold caches.

// RIGHT — prewarm first
\_prime_comment_caches($ids);
\update_meta_cache('comment', $ids);
return \array_map(...);

// WRONG — new WP_User in a loop
foreach ($userIds as $id) {
    $user = new \WP_User($id);  // bypasses object cache
    // ...
}

// RIGHT
\update_meta_cache('user', $userIds);
foreach ($userIds as $id) {
    $user = \get_user_by('id', $id);
    // ...
}

// WRONG — silent on missing record
public static function hydrate(int $id, string $dtoClass): DataObject
{
    $comment = \get_comment($id);
    if ($comment === null) {
        return $dtoClass::fromArray([]);  // 🔴 hydrates an empty DTO; caller can't tell
    }
    // ...
}

// RIGHT — typed exception
if ($comment === null) {
    throw CommentNotFoundException::forId($dtoClass, $id);
}

// WRONG — engine call inside src/Internal/
// File: src/Internal/CommentEngine.php
\update_meta_cache('comment', $ids);  // 🔴 src/Internal/ must be WP-free for unit testability

// RIGHT — keep WP calls in src/Source/
// (the WP-free attribute-driven hydrator already lives in src/Internal/)

Cross-references

  • Run bd-sink when also writing back to the same store — sources and sinks usually ship as a pair.
  • Run bd-attribute if the new source needs a NEW attribute (e.g. #[CommentField]) — the attribute and source must be wired together.
  • Run bd-data-object when adding the DTO that the source hydrates — DTO + source design typically co-evolve.

What this skill does NOT cover

  • Custom hydration logic beyond the attribute-driven path. If your source needs deep custom branching (e.g. building a tree from term hierarchy), that goes inside the source as helper logic, but the entry shape stays hydrate / hydrateMany.
  • Replacing AttributeDrivenHydrator. The hydrator is intentionally one engine; new behaviors are attributes, not parallel hydrators.
  • Performance optimization beyond cache prewarming (sharding queries, lazy hydration). Most sources don't need it.
  • Caching the hydrated DTO itself — that's a consumer concern. Sources hydrate fresh each call.
  • Sourcing from non-WP systems (REST API, GraphQL, external DB). Possible architecturally but outside this skill's WP-store scope.

References

  • Reference source (full pattern): [libraries/better-data/src/Source/PostSource.php](PostSource.php) — hydrate at line 64, hydrateMany with _prime_post_caches + update_meta_cache at line 117-124, metadata_exists guard at line 91-93.
  • Trait shortcut: [libraries/better-data/src/Source/HasWpSources.php:28](HasWpSources.php) — ::fromPost, ::fromUser, ::fromTerm, ::fromOption, ::fromRow.
  • Hydrator engine: [libraries/better-data/src/Internal/AttributeDrivenHydrator.php](AttributeDrivenHydrator.php) — pure, WP-free; takes a fetcher closure and produces a hydrated DTO.
  • Default-fallback predicate: [libraries/better-data/src/Internal/AttributeDrivenHydrator.php:90-95](AttributeDrivenHydrator.php) — the null-vs-value distinction in the fetcher matters here.
  • Other sources: [libraries/better-data/src/Source/UserSource.php](UserSource.php), [libraries/better-data/src/Source/TermSource.php](TermSource.php), [libraries/better-data/src/Source/OptionSource.php](OptionSource.php), [libraries/better-data/src/Source/RowSource.php](RowSource.php), [libraries/better-data/src/Source/RequestSource.php](RequestSource.php).

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.