Skip to content

Release 4.2.0#1893

Merged
PatelUtkarsh merged 113 commits into
masterfrom
release/v4.2.0
May 28, 2026
Merged

Release 4.2.0#1893
PatelUtkarsh merged 113 commits into
masterfrom
release/v4.2.0

Conversation

@PatelUtkarsh
Copy link
Copy Markdown
Member

@PatelUtkarsh PatelUtkarsh commented May 28, 2026

Release 4.2.0. Merge release/v4.2.0 into master so the version tag and WP.org deploy land on master.

Release Changelog

See changelog.md β†’ 4.2.0 for the full list. Highlights:

Release Checklist

  • This pull request is to the master branch.
  • Release version follows semantic versioning. No breaking changes.
  • Update changelog in readme.txt.
  • Bump version in stream.php (4.2.0).
  • Bump Stable tag in readme.txt (4.2.0).
  • Bump version in classes/class-plugin.php (4.2.0).
  • Draft a release on GitHub after this PR merges.

Pre-release verification

  • RC pre-release v4.2.0-rc.1 published 2026-05-27, WP.org SVN dry-run succeeded (run).
  • RC zip available on the v4.2.0-rc.1 release for smoke testing.

After this PR merges to master, draft the v4.2.0 GitHub release targeting master to trigger the WP.org deploy.

dd32 and others added 30 commits February 13, 2025 13:10
In some cases `require_name_email` and `comment_registration` will both be enabled, the details from the email lookup should be respected before falling back to the comment author name.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Add abstract Ability base class, Abilities loader with WP 6.9 + setting
gating, and a new "Enable Abilities API" toggle under the existing
Advanced settings section. The loader hooks wp_abilities_api_init and
will register concrete abilities once they are added in subsequent
commits. Falls back silently on WordPress < 6.9.
Implements the six read-only abilities under stream/* namespace:
get-records, get-record, get-settings, get-alerts, get-connectors,
and get-exclusion-rules. Each ability has hand-written JSON Schemas,
delegates to existing Stream APIs, and ships with a PHPUnit test
covering name, schema, permission gating, and execution.

Tests skip themselves on WordPress < 6.9 via the shared
Abilities_TestCase base class.
Add three abilities that mutate Stream state through the existing internal
APIs: stream/create-alert (creates a wp_stream_alerts CPT post with
alert_type and alert_meta), stream/update-settings (partial-merge update
to the wp_stream option), and stream/create-exclusion-rule (appends to
the parallel-array exclude_rules option columns Stream already uses).

Each ability is gated behind the manage_options capability. Hand-written
JSON schemas describe inputs and outputs for AI consumers; create-alert
requires the four trigger fields, create-exclusion-rule requires at
least one filter property, and update-settings requires a non-empty
settings map. Each ability ships with PHPUnit coverage that verifies
permissions, schema shape, and end-to-end execution against the option
or post store.
Add the two destructive abilities required by the ticket:
stream/purge-records (filtered DELETE against the Stream records table
with a cascading meta delete that mirrors Admin::erase()) and
stream/delete-alert (force-delete a wp_stream_alerts post by ID).

purge-records refuses to run unless confirm: true is supplied AND at
least one filter (older_than_days, connector, context, action) is set,
preventing an accidental full table wipe. The row count is computed
before the DELETE so the response is meaningful even though the
multi-table DELETE returns the combined affected rows. delete-alert
returns a 404 WP_Error when the ID is unknown or refers to a non-alert
post type, which makes the ability safely idempotent.

Both abilities ship with PHPUnit coverage that exercises permissions,
schema validation, the happy path, the refusal paths, and (for
purge-records) the meta cascade.
Cover the two infrastructure pieces left untested by the per-ability
suites: tests/phpunit/test-class-ability.php exercises the Ability
abstract base via an in-file Fake_Ability_For_Test subclass (verifies
get_meta() emits category and show_in_rest, conditionally adds an
annotations key, and that the default permission_callback denies
subscribers and grants admins; also asserts register() makes the
ability retrievable via wp_get_ability() when the API is available).

tests/phpunit/test-class-abilities.php covers the loader: is_available()
tracks the WP_Ability class presence, is_enabled() reflects the
advanced_enable_abilities_api option, the constructor only hooks
wp_abilities_api_init when both gates pass, get_ability_slugs() lists
all eleven slugs, load_abilities() instantiates each, and
register_abilities() does not double-load on a second invocation.

Resolves: XWPENG-13
- Register 'stream' category on wp_abilities_api_categories_init so
  abilities with category=stream pass core's category-existence check
- Move 'category' from meta to top-level args in Ability::register(),
  matching the wp_register_ability() contract in WP 6.9
- Replace get-record's broken DB::get_records(['record' => $id]) call
  (Query class never implemented the singular 'record' arg) with a
  direct $wpdb single-row lookup
- Snapshot/restore $plugin->settings->options in Abilities_TestCase so
  in-memory mutations from write-ability tests don't leak across tests
- Update tests to satisfy the doing_action() guards on
  wp_register_ability() and wp_register_ability_category()
Add a per-ability 'instructions' annotation: a 1-2 sentence note for
AI agents about when and how to call each ability, distinct from the
description (which describes what it does).

Add tests/phpunit/abilities/test-rest-integration.php covering all
three ability types end-to-end: dispatches actual WP_REST_Requests
through WP_REST_Server and asserts 200/403/404/405 paths plus the
list-abilities endpoint exposes all 11 stream/* abilities. Catches
breakage in the real REST stack that direct execute() tests miss.

Add idempotent: true to purge-records annotations. WP core's REST
router only routes to DELETE when destructive AND idempotent are
both true; without idempotent the controller expects POST.

Refactor test action-firing to use the documented core test pattern
of pushing onto $wp_current_filter rather than registering callbacks
through add_action(). Cleaner, no global hook pollution, matches the
convention used in WordPress core's own abilities-api tests.

Make Abilities::register_abilities() defensive: skip per-ability
register() calls when the ability is already registered, preventing
spurious _doing_it_wrong notices when load_abilities() runs more
than once in the same process.
WP core's WP_Ability::invoke_callback() spreads zero arguments into the
execute callback when the ability declares no input_schema (see
wp-includes/abilities-api/class-wp-ability.php:506-512). Our previous
'execute($input)' signature required one argument, so any GET request
to a no-input-schema ability raised a fatal ArgumentCountError and
returned HTTP 500 to the caller.

Add '$input = null' as the default on the abstract Ability::execute()
plus all 11 concrete subclasses and the test fake. Null matches WP
core's own conventions (their invoke_callback and check_permissions
both default $input to null). Abilities that DO declare an
input_schema continue to receive the parsed value verbatim from core,
so the default sits unused for those.

Caught by live e2e testing against WP 6.9.1 (Phase 4 of
XWPENG-13-e2e.md): get-settings, get-connectors, and
get-exclusion-rules previously fataled.
- Authorization: read abilities use 'view_stream'; base default uses
  WP_STREAM_SETTINGS_CAPABILITY. Abilities registers a user_has_cap
  filter for REST contexts where Admin (and its filter) isn't loaded,
  so allowed roles can call read abilities consistently with the UI.
- update-settings: allowlist {section}_{field} keys from registered
  fields, run incoming values through Settings::sanitize_settings(),
  reject payloads with no recognized keys.
- create-exclusion-rule: schema gains format:ip and maxLength bounds;
  execute() sanitizes via sanitize_text_field(), validates IPs with
  FILTER_VALIDATE_IP, validates connector against registered slugs,
  rejects all-empty payloads.
- purge-records: use rows_affected from the DELETE itself (no stale
  pre-count); run orphan-meta sweep after; fix MySQL alias syntax.
- get-record: kept direct query (Query::query has a real array_shift
  bug with record__in) but adds explicit blog_id scoping on multisite
  so cross-site record leakage cannot occur.
The 5 read-only abilities (get-records, get-record, get-alerts,
get-connectors, get-exclusion-rules) all carried an identical
permission_callback() returning current_user_can( 'view_stream' )
with the same rationale docblock. Move it into a shared trait so the
authorization rule lives in one place.

Each ability file require_once's the trait directly so per-test loaders
(which require ability files individually) keep working without any
autoloader changes.

Net -35 LOC. Single-site and multisite Ability suites unchanged: 316
tests pass with the same skipped/incomplete counts as before.
- orderby: add enum bound to Query::query()'s actual sortable columns,
  and change the default from 'date' (not a real Stream column;
  silently fell back to ID) to 'created'. This makes the silent
  fallback impossible at the schema layer for REST callers and
  surfaces the contract for direct PHP callers.
- user_id__in / connector__in: add maxItems: 100 so a caller cannot
  force an unbounded IN(...) clause from a single request.

Tests cover schema shape, REST schema validation (orderby=date now
rejected, 101 items rejected), and a behavioral regression that seeds
two records with out-of-order created/ID and asserts orderby=created
ASC actually orders by created -- not by ID, which is what the old
silent fallback was doing.
…or-context

Mirror the admin form's create-alert flow (classes/class-alerts.php:766-
806) so API-created alerts behave identically to UI-created ones:

- Validate alert_type against $plugin->alerts->alert_types (the
  registered notifier slugs). Schema can't enum these because
  wp_stream_alert_types is a filter -- a hardcoded enum would lock out
  3rd-party notifiers. Reject unknown slugs with
  stream_unknown_alert_type / status 400 BEFORE inserting the post.
- Split 'connector-context' input into trigger_connector +
  trigger_context meta keys, exactly like the admin form does. Without
  the split, Alert_Trigger_Context::check_record() silently let any
  connector through because trigger_connector was never populated --
  alerts created via the API were effectively connector-agnostic.
- Build an Alert model from the split meta and use $alert->get_title()
  for post_title, so the admin list shows a meaningful title instead of
  'Auto Draft'.

Tests cover the title regression, the connector-dash-context split, and
the alert_type rejection path (including no-side-effects: no post is
inserted when validation fails).
…ed multisite

On a network-activated multisite install, the Abilities API toggle is saved
to the wp_stream_network site option via Network::update_site_option().
However, Settings::get_options() only reads from get_site_option() when
is_network_admin() is true; in REST and frontend contexts $plugin->settings->options
reflects the (typically empty) per-site option. As a result, is_enabled()
returned false in REST even when the network admin had enabled the API,
making the entire Abilities API silently unreachable on network-activated
sites.

Read the network option directly via get_site_option($settings->network_options_key)
when is_multisite() && $plugin->is_network_activated(), and fall back to
the existing in-memory per-site options otherwise (preserves single-site
and per-site-activated behavior).

Adds two regression tests:
- test_is_enabled_reads_network_option_when_network_activated (@group ms-required)
  flips the wp_stream_is_network_activated filter and proves is_enabled()
  follows the network option even when in-memory options say disabled.
- test_is_enabled_reads_per_site_options_when_not_network_activated proves
  the network option is ignored when the plugin isn't network-activated.
Add //end try comments to satisfy Squiz.Commenting.LongConditionClosingComment
on the new is_enabled() multisite tests, and lift the inline 'not a
registered notifier' comment above the array literal so it doesn't trip
Squiz.Commenting.PostStatementComment in the unknown-alert_type test.
The comment claimed format:ip is a hint not enforced by
rest_validate_value_from_schema(), which is wrong. WP core's
rest_is_ip_address() in wp-includes/rest-api.php DOES validate the
format and rejects bogus IPs at the schema layer with
ability_invalid_input before our execute() runs.

Reframe the in-method check as defense-in-depth for direct PHP callers
who invoke $ability->execute() outside the REST stack.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
PatelUtkarsh and others added 27 commits May 19, 2026 17:01
The inline @php -r string in composer.json had grown into an unreadable
nested-quote mess. Move the same logic into local/scripts/install-mcp-adapter-deps.php
with proper comments explaining the three branches:

- Adapter directory absent (production --no-dev releases): silent no-op.
- Adapter directory present, vendor populated: silent no-op.
- Adapter directory present, vendor missing (typical dev install):
  run `composer install --no-dev` inside the adapter directory so its
  Autoloader can find vendor/autoload.php on plugin activation.

No behavior change for end users. Release safety is unchanged: --no-dev
installs skip wordpress/mcp-adapter entirely, so the adapter directory
never exists in shipped artifacts and the script's first guard returns
silently. /local/ is also excluded by .distignore as a second backstop.
Move the dev environment from HTTP to HTTPS so it matches what WordPress
Application Passwords and most MCP clients expect by default.

- wp-cli.yml: set url to https://stream.wpenv.net so wp core multisite-install
  writes https values to wp_options at install time. No wp-config overrides
  needed -- the DB values are authoritative.
- Playwright: swap all http://stream.wpenv.net references to https://. Add
  ignoreHTTPSErrors: true so Playwright's Chromium accepts the mkcert-issued
  certificate without requiring `mkcert -install` on the runner / host.
- CI workflow: wait for TCP :443 instead of :80 in the E2E job since HTTPS
  is now the canonical entry point.
- contributing.md: dev-environment URL is now https; documented one-line
  wp search-replace for existing checkouts to upgrade their DB values
  without a full reinstall.

No Apache HTTP->HTTPS redirect added; with siteurl and home both https in
the DB, WordPress already generates https URLs everywhere and redirects
http wp-admin / wp-login hits via the canonical-URL machinery, so the
extra redirect would only catch direct `curl http://` cases that are
useful to keep working for quick TCP probes.
The published ghcr.io/xwp/stream-wordpress image is refreshed only by
docker-images.yml on master pushes. When this branch updates
local/docker/wordpress/Dockerfile (e.g. `a2enmod ssl` + the
stream-ssl vhost), `docker compose pull wordpress` happily fetches
the stale published image and skips the local build context.

Add an explicit `docker compose build wordpress` step after the pull
so the branch's Dockerfile actually lands in the running container.
Without this, CI's HTTPS smoke fails with ERR_CONNECTION_CLOSED on
:443 because Apache has no SSL vhost configured.

Docker's layer cache keeps the rebuild cheap when the Dockerfile is
unchanged from the published base.
The previous description packed three sentences and an internal URL into
a single line, making the Stream settings UI visually dense and forcing
users to scan past technical details to find the primary action. Trim to
one sentence that surfaces what enabling does, treats MCP as a
parenthetical secondary fact, and keeps the WP version requirement.

Title is unchanged (still "Enable Abilities API and MCP").
1. Settings::updated_option_ttl_remove_records() now triggers an
   immediate purge directly. Previously it relied on the legacy
   wp_stream_auto_purge action being hooked to purge_scheduled_action,
   which this PR severed. Without this fix, shortening the TTL did not
   take effect until the next 12h recurring tick.

2. TTL fallback uses isset() instead of empty(), so an explicit '0'
   set via CLI/SQL is no longer silently overridden to 30. Added an
   explicit short-circuit: a non-positive TTL bails out of the cycle
   entirely. The UI enforces min=1; the only paths to 0 are operator
   error, and bailing out (records stop being purged) is a less
   destructive failure mode than honoring it (records get wiped
   repeatedly every 12h).

3. Overlap guard now reuses Admin::is_running_auto_purge(), so a
   pending reaper also blocks a new chain. Previously only a pending
   batch action blocked the guard, leaving a small window between
   chain completion and reaper completion where a new chain could
   stack.

Three new PHPUnit tests cover each behaviour.

Refs XWPENG-28
- Move wp_stream_auto_purge BC action to after all bail-out checks so it
  fires only when a purge actually runs (was firing on every recurring
  tick regardless of whether work happened).
- Extend is_running_auto_purge() to also check IN-PROGRESS actions via
  as_get_scheduled_actions() so the overlap guard cannot let a second
  chain stack against rows the running batch worker is still touching.
- Settings TTL-shortened path now enqueues AUTO_PURGE_ACTION via
  as_enqueue_async_action() so the immediate purge serializes through
  Action Scheduler instead of bypassing the overlap guard with an
  inline call. Inline fallback retained for when AS is unavailable.
- Render an admin notice for wp_stream_message=orphan_meta_cleanup_scheduled
  on both admin_notices and network_admin_notices so the post-redirect
  UX is actually visible (was a half-built feature: redirect happened,
  no notice rendered).
- E2E wpEval(): switch from 'docker compose run --rm' to 'docker
  compose exec -T' to attach to the long-lived container (~3-5s saved
  per call) and surface failures via console.warn instead of silently
  swallowing errors.
- PHPUnit: add coverage for both the BC-action-suppressed-on-bailout
  path and the in-progress-action overlap guard. Update the
  TTL-shortened test to assert the AS enqueue path.
The previous quick-win switch from 'docker compose run --rm --user
$(id -u)' to 'docker compose exec -T' dropped the user mapping. On
local dev that happened to work because the wordpress container's
default exec user is what the runtime image inherits; on CI it defaults
to root and wp-cli refuses to run with:

  YIKES! It looks like you're running this as root.

$(id -u) only worked on the host because UID 1000 mapped to www-data
inside the container β€” that's not portable to the GitHub Actions runner
(UID 1001). Pin the exec user explicitly to www-data, which owns the
WordPress files inside the container.

Failing run: actions/runs/26144103763
Register Stream operations as WordPress Abilities
…#1879)

Bumps [johnbillion/query-monitor](https://github.com/johnbillion/query-monitor) from 3.16.3 to 3.20.4.
- [Release notes](https://github.com/johnbillion/query-monitor/releases)
- [Commits](johnbillion/query-monitor@3.16.3...3.20.4)

---
updated-dependencies:
- dependency-name: johnbillion/query-monitor
  dependency-version: 3.20.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…-5.4.51

chore(deps-dev): bump symfony/process from 5.4.46 to 5.4.51
…auto-purge-action-scheduler

# Conflicts:
#	classes/class-settings.php
Bumps [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit) from 9.6.20 to 9.6.33.
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/9.6.33/ChangeLog-9.6.md)
- [Commits](sebastianbergmann/phpunit@9.6.20...9.6.33)

---
updated-dependencies:
- dependency-name: phpunit/phpunit
  dependency-version: 9.6.33
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Comments Connector: Avoid a PHP Warning on pingbacks.
Bumps [composer/composer](https://github.com/composer/composer) from 2.7.7 to 2.9.8.
- [Release notes](https://github.com/composer/composer/releases)
- [Changelog](https://github.com/composer/composer/blob/main/CHANGELOG.md)
- [Commits](composer/composer@2.7.7...2.9.8)

---
updated-dependencies:
- dependency-name: composer/composer
  dependency-version: 2.9.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Set baseURL in playwright.config.js and replace absolute
https://stream.wpenv.net references in every spec with relative
paths. Also drops the docker-exec state seeding from
admin-orphan-cleanup.spec.js so it matches the browser-only
convention of the rest of the suite; the running-state UX it
seeded is covered by PHPUnit.
Co-authored-by: Utkarsh Patel <itismeutkarsh@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1. wp_ajax_clean_orphan_meta() now uses is_running_auto_purge() for its
   idempotency check instead of as_has_scheduled_action(), which only
   probes PENDING. The new probe checks PENDING + RUNNING across the
   batch worker and reaper, matching the UI hide condition and closing
   the small CSRF/stale-URL window where a duplicate reaper could be
   enqueued while a chain is mid-flight. function_exists() guard
   updated to as_get_scheduled_actions accordingly.

2. Add explicit case 'none' in Settings::render_field() so the intent
   of the running-state UI swap (hide the value column, let the
   description carry the message) is captured in code instead of
   relying on the switch's implicit fall-through. Used by both the
   Reset Stream Database row and the new Clean Orphaned Meta row.

3. Expand auto_purge_batch() docblock to acknowledge the
   last_entry-based forward-progress trade-off: any eligible row that
   lands inside the already-touched ID range after that batch ran is
   skipped by the current chain and picked up on the next tick. Sources
   are bounded (dev seeders, importers, clock skew); steady-state
   logging is monotonic so this is a no-op for production traffic.

4. Changelog clarifies that the wp_stream_auto_purge BC action's
   semantics have changed: it used to fire on every WP-Cron tick
   regardless of whether work happened, now fires only after all
   bail-out checks pass. Integrations that relied on the legacy
   tick-rate semantics should switch to AUTO_PURGE_ACTION.

5. New PHPUnit test test_settings_ttl_shortened_via_option_update_enqueues_purge
   exercises the full hook wiring (update_option / update_site_option
   β†’ Settings::updated_option_ttl_remove_records β†’ AS enqueue) end-to-
   end. The existing unit test calls the method directly and would
   still pass if Network::__construct() lost its
   update_site_option_wp_stream_network hook registration.

6. New PHPUnit test test_ajax_clean_orphan_meta_denies_users_without_settings_cap
   covers the security boundary on the manual cleanup handler. Uses
   _handleAjax() so WP_Ajax_UnitTestCase's buffer machinery runs.

7. New PHPUnit test test_clean_orphan_meta_field_reflects_running_state
   asserts the integration between is_running_auto_purge() and the
   field-rendering decision in Settings::build_clean_orphan_meta_field().
   Replaces the e2e specs removed in b4c8f28 for activation-race
   fragility with deterministic PHPUnit coverage.

8. New PHPUnit test test_purge_scheduled_action_scopes_to_current_blog_when_not_network_activated
   forces the multisite-not-network-activated branch via a Plugin stub
   and asserts the enqueued batch carries get_current_blog_id() rather
   than 0. The acceptance criterion ("per-site activations only purge
   the current blog") was only covered at the batch-worker layer; the
   routing decision in purge_scheduled_action() was untested.

Refs XWPENG-28
Production loads the trait via Abilities::load_abilities() before any
class-ability-*.php file is included, but PHPUnit's coverage
post-processor with processUncoveredFiles="true" walks the
<coverage><include> directories directly via include_once and bypasses
the plugin's loader. On PHP versions where the order surfaces the
issue, each read ability fatals on `use Trait_View_Stream_Permission`
during coverage generation, breaking the clover.xml emit step and
failing the test target with a non-zero exit even when all tests pass.

Require the trait at bootstrap so it is available before any include_once
walks the abilities directory. Mirrors the production chokepoint without
reintroducing per-file require_once at the top of each ability class.
* feat(purge): add Action Scheduler constants for auto-purge migration

Refs XWPENG-28

* feat(purge): wire Admin to AS-based auto-purge callbacks

Adds stub method bodies so the action registration resolves; the real
implementations land in subsequent commits.

Refs XWPENG-28

* feat(purge): schedule auto-purge via Action Scheduler

Replaces the legacy twicedaily WP-Cron event with a recurring AS action
scheduled at 12h intervals. Clears any pre-existing legacy event on
upgrade so the two cannot double-fire. Idempotent: re-running the
setup while a recurring action is pending is a no-op.

Refs XWPENG-28

* feat(purge): replace inline DELETE with AS chain enqueue

The recurring action now snapshots the TTL cutoff in UTC, fires
wp_stream_auto_purge for back-compat, applies an overlap guard, and
enqueues the first batch into the auto-purge chain. Multisite scoping
(per-site activation) is encoded as blog_id; 0 means 'all blogs'.
Defaults are merged into the options array via wp_parse_args so a
partially-saved option still gets the missing keys.

Refs XWPENG-28

* feat(purge): add batched auto_purge_batch worker

Window-based deletion (ID range \u2264 wp_stream_batch_size) joined against
stream_meta in a single statement, mirroring erase_large_records().
Snapshotted UTC cutoff is threaded through each batch in the chain.
blog_id == 0 means 'all blogs' for network-activated installs; non-zero
scopes to that blog. Schedules the orphan reaper as the terminal step
when no more rows are eligible.

Refs XWPENG-28

* feat(purge): add terminal orphan reaper to auto-purge chain

Runs delete_orphaned_meta() once at the end of every chain so installs
that already had orphan meta from historical timed-out purges heal
over time without operator intervention. Lifts delete_orphaned_meta()
visibility from private to protected so the reaper can call it.

Refs XWPENG-28

* feat(purge): add manual 'Clean Orphaned Meta' link on Settings \u2192 Advanced

Settings UI link is nonced for users with WP_STREAM_SETTINGS_CAPABILITY;
the ajax handler schedules a one-shot reaper via Action Scheduler.
Idempotent: re-clicking while a reaper is pending is a no-op. Bails out
early under WP_STREAM_TESTS so PHPUnit doesn't exit the worker.

Refs XWPENG-28

* test(e2e): cover manual orphan-meta cleanup link

Asserts the Clean Orphaned Meta link renders on Settings \u2192 Advanced,
points at admin-ajax.php with the expected action + nonce, and that
following the link redirects to the settings page with a confirmation
marker in the URL. Also fixes the redirect target in the handler to
use network_settings_page_slug on network-activated installs.

Refs XWPENG-28

* fix(purge): ensure forward progress on tables with concurrent writes

The previous auto_purge_batch SELECT used 'WHERE created < cutoff ORDER
BY ID DESC LIMIT 1' to find the next window's top, but on hosts that
are actively logging during a chain the ID space is sparse (eligible
rows interleaved with fresh rows whose created > cutoff). That caused
each subsequent batch to find a top only ~30 IDs below the previous,
stalling progress.

Match Admin::erase_large_records()'s pattern instead: pass last_entry
(the lower bound of the previous window) through the chain and use
'WHERE ID < last_entry' to guarantee the next batch starts strictly
below the previous window. Stride is now exactly wp_stream_batch_size
IDs per batch.

Verified end-to-end on a multisite install seeded to ~320k aged
records: chain drains in ~35 batches at batch_size=10000 and
terminates with zero orphans.

Refs XWPENG-28

* style: address PHPCS findings in auto-purge implementation

- Replace interpolated-SQL pattern in auto_purge_batch with explicit
  prepared statements per (blog_id, last_entry) combination.
- Correct @return doctype on wp_ajax_clean_orphan_meta to bool|void.
- Settings UI array alignment.
- Add @param to set_records_ttl test helper.

Refs XWPENG-28

* docs(changelog): note XWPENG-28 auto-purge migration

Refs XWPENG-28

* fix(purge): harden missing-option fallback against filtered defaults

Settings::get_defaults() runs every field through wp_stream_settings_option_fields,
which Network::get_network_admin_fields() uses to strip 'records_ttl' from the
per-site option's defaults set. Outside any admin context (Action Scheduler,
WP-CLI, system cron) the per-site option_key is in effect, so the filtered
defaults never contained general_records_ttl. Without this fallback the
auto-purge silently no-ops on every install where the option is missing,
defeating the whole point of fixing this on bloated sites.

Hardcoded fallback to 30 days (the documented default on the settings field
itself) when general_records_ttl is absent after the merge.

Caught by section 9 of the e2e plan.

Refs XWPENG-28

* feat(purge): consult wp_stream_is_large_records_table for small-table fast path

The acceptance criteria require the auto-purge to honor the same
wp_stream_is_large_records_table filter the manual reset uses, so
ops only have one knob to tune table-size semantics.

The recurring callback now counts eligible rows once, passes the count
through Plugin::is_large_records_table(), and:

- Small table (filter returns false): runs a single inline multi-table
  DELETE for the eligible rows, then enqueues the orphan reaper as a
  terminal AS action so the heal step stays observable in
  Tools \u2192 Scheduled Actions.
- Large table (filter returns true, default for record_count > 1M):
  enqueues the batched chain as before.

Tests:
- New test_purge_scheduled_action_small_table_fast_path covering the
  inline-DELETE branch.
- New test_purge_scheduled_action_large_table_uses_batched_chain
  exercising the batched branch via the filter.
- Existing batched-path tests now opt into the chain explicitly via
  add_filter('wp_stream_is_large_records_table','__return_true').

Refs XWPENG-28

* feat(purge): expose Admin::is_running_auto_purge() state probe

Mirrors is_running_async_deletion() but checks the auto-purge group
(batch + reaper). The recurring scheduler is intentionally excluded
from the probe so it doesn't always report 'running' under normal
operation. Settings UI uses this in the next commit to render an
'Auto-purge currently running' notice on Settings \u2192 Advanced.

Refs XWPENG-28

* feat(purge): hide manual Clean Orphaned Meta link while chain is running

When the auto-purge chain is active (batch worker or reaper pending),
the manual Settings \u2192 Advanced cleanup link is hidden and the field
description is swapped to explain that the reaper will run as part of
the active cycle. Mirrors how Reset Stream Database hides itself
during async deletion.

Refs XWPENG-28

* test(e2e): cover purge-active and purge-idle UI states

Two new specs exercise the Settings \u2192 Advanced field behaviour driven
by Admin::is_running_auto_purge():

- Active state: seed a pending reaper action via wp-cli, assert the
  Clean Orphaned Meta link is removed from the DOM and the swapped
  description ('Auto-purge is currently running') is visible.
- Idle state: drain the seeded action and assert the link is restored.

Both specs use a small wp-cli helper (execSync into the wordpress
container) to seed/clear AS state, keeping the test free of any
browser-side timing on the AS worker.

Refs XWPENG-28

* test(e2e): harden orphan-cleanup spec against activation races

- wpEval helper now swallows non-zero exits from the wordpress
  container instead of throwing into the test runner. State seeding
  is best-effort; the test's own assertions are the source of truth.
- beforeAll waits for the post-activation navigation and explicitly
  confirms 'Network Deactivate Stream' is visible before any test
  runs, failing fast instead of letting every test silently hit the
  'Sorry, you are not allowed to access this page' redirect when a
  prior suite leaves Stream deactivated.

In-isolation: spec is stable across 3 consecutive runs. Pre-existing
cross-spec activation races (editor-new-post, admin-ui-smoke) remain
out of scope here.

Refs XWPENG-28

* docs(changelog): note small-table fast path + running-state UX

Refs XWPENG-28

* fix(e2e): drop unused catch binding for ESLint

CI runs ESLint with no-unused-vars; the wpEval helper's catch block
declared 'err' but never referenced it. Use the optional binding form
'catch {}' which is supported on the runner's Node version (22+).

Refs XWPENG-28

* test: drop two low-value auto-purge unit tests

- test_auto_purge_action_constants_exist was a tautology: it asserted
  that four constants equal the string values they are declared with.
  The test catches nothing that static analysis or a typo in the
  consumer wouldn't catch.

- test_auto_purge_batch_respects_wp_stream_batch_size_filter only
  verified the filter was *called* (invocation counter), not that the
  returned value affected behaviour. test_auto_purge_batch_deletes_
  window_and_chains_next_batch already drives the chain with a custom
  batch_size and asserts on the resulting chain, which is the
  functional contract that matters.

No coverage lost.

Refs XWPENG-28

* review: address code review feedback

1. auto_purge_batch() now throws InvalidArgumentException on empty
   cutoff instead of silently returning. A bare 'return' caused AS to
   mark the action complete; throwing makes AS log it as failed and
   surface it in Tools > Scheduled Actions. New PHPUnit test covers
   the throw path. In practice this branch is unreachable because
   purge_scheduled_action() always populates the cutoff, but the
   guard exists for third-party code that may enqueue with bad input.

2. wp_ajax_clean_orphan_meta() now uses $this->settings_cap to match
   the rest of the file (wp_ajax_reset, ajax_filters). The bare
   WP_STREAM_SETTINGS_CAPABILITY constant could break installs that
   override the capability via the property after construction.

3. The Settings 'Clean Orphaned Meta' field now calls
   Admin::is_running_auto_purge() once per render instead of twice.
   Extracted the field-building logic into a small helper so the
   state probe runs once and both the 'type' and 'desc' branches
   reuse the result.

Refs XWPENG-28

* review: address second round of code review feedback

1. Settings::updated_option_ttl_remove_records() now triggers an
   immediate purge directly. Previously it relied on the legacy
   wp_stream_auto_purge action being hooked to purge_scheduled_action,
   which this PR severed. Without this fix, shortening the TTL did not
   take effect until the next 12h recurring tick.

2. TTL fallback uses isset() instead of empty(), so an explicit '0'
   set via CLI/SQL is no longer silently overridden to 30. Added an
   explicit short-circuit: a non-positive TTL bails out of the cycle
   entirely. The UI enforces min=1; the only paths to 0 are operator
   error, and bailing out (records stop being purged) is a less
   destructive failure mode than honoring it (records get wiped
   repeatedly every 12h).

3. Overlap guard now reuses Admin::is_running_auto_purge(), so a
   pending reaper also blocks a new chain. Previously only a pending
   batch action blocked the guard, leaving a small window between
   chain completion and reaper completion where a new chain could
   stack.

Three new PHPUnit tests cover each behaviour.

Refs XWPENG-28

* review: address third round of code review feedback

- Move wp_stream_auto_purge BC action to after all bail-out checks so it
  fires only when a purge actually runs (was firing on every recurring
  tick regardless of whether work happened).
- Extend is_running_auto_purge() to also check IN-PROGRESS actions via
  as_get_scheduled_actions() so the overlap guard cannot let a second
  chain stack against rows the running batch worker is still touching.
- Settings TTL-shortened path now enqueues AUTO_PURGE_ACTION via
  as_enqueue_async_action() so the immediate purge serializes through
  Action Scheduler instead of bypassing the overlap guard with an
  inline call. Inline fallback retained for when AS is unavailable.
- Render an admin notice for wp_stream_message=orphan_meta_cleanup_scheduled
  on both admin_notices and network_admin_notices so the post-redirect
  UX is actually visible (was a half-built feature: redirect happened,
  no notice rendered).
- E2E wpEval(): switch from 'docker compose run --rm' to 'docker
  compose exec -T' to attach to the long-lived container (~3-5s saved
  per call) and surface failures via console.warn instead of silently
  swallowing errors.
- PHPUnit: add coverage for both the BC-action-suppressed-on-bailout
  path and the in-progress-action overlap guard. Update the
  TTL-shortened test to assert the AS enqueue path.

* fix(e2e): pass --user www-data to docker compose exec

The previous quick-win switch from 'docker compose run --rm --user
$(id -u)' to 'docker compose exec -T' dropped the user mapping. On
local dev that happened to work because the wordpress container's
default exec user is what the runtime image inherits; on CI it defaults
to root and wp-cli refuses to run with:

  YIKES! It looks like you're running this as root.

$(id -u) only worked on the host because UID 1000 mapped to www-data
inside the container β€” that's not portable to the GitHub Actions runner
(UID 1001). Pin the exec user explicitly to www-data, which owns the
WordPress files inside the container.

Failing run: actions/runs/26144103763

* refactor(e2e): use Playwright baseURL for admin URLs

Set baseURL in playwright.config.js and replace absolute
https://stream.wpenv.net references in every spec with relative
paths. Also drops the docker-exec state seeding from
admin-orphan-cleanup.spec.js so it matches the browser-only
convention of the rest of the suite; the running-state UX it
seeded is covered by PHPUnit.

* review: address fourth round of code review feedback

1. wp_ajax_clean_orphan_meta() now uses is_running_auto_purge() for its
   idempotency check instead of as_has_scheduled_action(), which only
   probes PENDING. The new probe checks PENDING + RUNNING across the
   batch worker and reaper, matching the UI hide condition and closing
   the small CSRF/stale-URL window where a duplicate reaper could be
   enqueued while a chain is mid-flight. function_exists() guard
   updated to as_get_scheduled_actions accordingly.

2. Add explicit case 'none' in Settings::render_field() so the intent
   of the running-state UI swap (hide the value column, let the
   description carry the message) is captured in code instead of
   relying on the switch's implicit fall-through. Used by both the
   Reset Stream Database row and the new Clean Orphaned Meta row.

3. Expand auto_purge_batch() docblock to acknowledge the
   last_entry-based forward-progress trade-off: any eligible row that
   lands inside the already-touched ID range after that batch ran is
   skipped by the current chain and picked up on the next tick. Sources
   are bounded (dev seeders, importers, clock skew); steady-state
   logging is monotonic so this is a no-op for production traffic.

4. Changelog clarifies that the wp_stream_auto_purge BC action's
   semantics have changed: it used to fire on every WP-Cron tick
   regardless of whether work happened, now fires only after all
   bail-out checks pass. Integrations that relied on the legacy
   tick-rate semantics should switch to AUTO_PURGE_ACTION.

5. New PHPUnit test test_settings_ttl_shortened_via_option_update_enqueues_purge
   exercises the full hook wiring (update_option / update_site_option
   β†’ Settings::updated_option_ttl_remove_records β†’ AS enqueue) end-to-
   end. The existing unit test calls the method directly and would
   still pass if Network::__construct() lost its
   update_site_option_wp_stream_network hook registration.

6. New PHPUnit test test_ajax_clean_orphan_meta_denies_users_without_settings_cap
   covers the security boundary on the manual cleanup handler. Uses
   _handleAjax() so WP_Ajax_UnitTestCase's buffer machinery runs.

7. New PHPUnit test test_clean_orphan_meta_field_reflects_running_state
   asserts the integration between is_running_auto_purge() and the
   field-rendering decision in Settings::build_clean_orphan_meta_field().
   Replaces the e2e specs removed in b4c8f28 for activation-race
   fragility with deterministic PHPUnit coverage.

8. New PHPUnit test test_purge_scheduled_action_scopes_to_current_blog_when_not_network_activated
   forces the multisite-not-network-activated branch via a Plugin stub
   and asserts the enqueued batch carries get_current_blog_id() rather
   than 0. The acceptance criterion ("per-site activations only purge
   the current blog") was only covered at the batch-worker layer; the
   routing decision in purge_scheduled_action() was untested.

Refs XWPENG-28
* Skip async deletion AS query outside admin and memoise it

`Admin::is_running_async_deletion()` is called from `Settings::get_fields()`
and `Settings::get_deletion_warning()` to decide whether to render a
"deletion in progress" warning on the Stream settings screen. Because
`Settings::__construct` eagerly calls `get_options()` on `init`, and
`get_options()` walks the field definition via `get_defaults()`, the AS
query runs twice on every pageload β€” front-end included β€” even though the
result is only ever consumed by admin UI.

Short-circuit when `is_admin()` is false (the only callers are admin UI
render paths) and memoise per request to collapse the duplicate within an
admin pageload.

Fixes #1884

* Lift AS gate into Settings; also skip auto-purge query on front-end

The previous commit short-circuited Admin::is_running_async_deletion()
outside admin and memoised it. That works for the immediate cost, but
has two issues:

1. It changes the semantic contract of a public static method β€”
   `is_running_async_deletion()` no longer means "is async deletion
   running" but "is async deletion running AND we're in admin". Any
   non-Settings caller (custom CLI, REST endpoint, AS worker) gets a
   silently wrong answer.

2. The sibling probe Admin::is_running_auto_purge() has the same
   per-pageload cost (called from Settings::build_clean_orphan_meta_field
   on every front-end pageload via the init-time get_options walk), but
   cannot use the same fix: it has a non-admin caller β€” the recurring
   auto-purge callback at class-admin.php uses it as an overlap guard,
   running under Action Scheduler where is_admin() is false. Adding the
   short-circuit there would let parallel auto-purge chains stack. A
   process-static memo is also unsafe under AS workers that process
   multiple actions per PHP process.

Move the gate to the UI layer instead. Both Settings fields that depend
on AS state now skip the probe when not in admin, symmetrically:

- Extract delete_all_records into build_delete_all_records_field(),
  mirroring the existing build_clean_orphan_meta_field() shape.
- Gate both helpers on is_admin() before calling the AS probes.
- Reuse the pre-computed deletion state inside one render by passing it
  to get_deletion_warning() (new optional parameter, backwards
  compatible).
- Revert Admin::is_running_async_deletion() to a one-liner. The probe
  now reports the truth in all contexts; the "UI doesn't need this
  outside admin" knowledge lives where it belongs β€” in Settings.

Query budget per pageload:

  Front-end:         2 AS queries  ->  0
  Admin / admin-ajax: 3 AS queries ->  2 (one async-deletion + one auto-purge)
  AS workers (overlap guard):  unchanged β€” still reports truth.

Refs #1884

* test(phpunit): cover async-deletion field state and warning helper

Adds three PHPUnit tests for the lifted-into-Settings AS gate:

- test_is_running_async_deletion_reflects_scheduled_state β€” closes a
  pre-existing gap; the probe had no direct unit coverage on develop.
  Mirrors test_is_running_auto_purge_reflects_chain_state.

- test_delete_all_records_field_reflects_running_state β€” integration
  test for the running-state UI swap on the "Reset Stream Database"
  field. Asserts type=link <-> type=none and the warning text swap.
  Mirrors test_clean_orphan_meta_field_reflects_running_state.

- test_get_deletion_warning_respects_precomputed_state β€” verifies the
  new optional parameter on get_deletion_warning(): when the caller
  passes the pre-computed state, the warning reflects the argument
  regardless of the real AS state. Guards against future regressions
  that bypass the cached value and re-query AS.

All three pass on single-site and multisite. The is_admin()=false
branch is not directly asserted because WP_ADMIN gets defined globally
by earlier tests and cannot be undefined within the same process. The
behavioural contract for that branch is covered by passing false
explicitly to get_deletion_warning() in the third test.

---------

Co-authored-by: Dion Hulse <dion@a8c.com>
Co-authored-by: Utkarsh Patel <itismeutkarsh@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* chore(release): prepare 4.2.0

Bump plugin version to 4.2.0 across stream.php, Plugin::VERSION,
and readme.txt Stable tag. Promote changelog Unreleased section
to 4.2.0 and add notable items (Abilities API + MCP, HTTPS dev
env, Playwright E2E in CI, Node v24, jQuery v4).

* chore(release): set 4.2.0 changelog date to May 28, 2026

* docs(changelog): note skipped AS queries on front-end pageloads

Adds an entry for #1885 / #1884 to the 4.2.0 changelog under Bug Fixes:
front-end pageloads no longer issue Action Scheduler queries from the
delete_all_records and clean_orphan_meta settings fields.

* chore(release): bump Tested up to 7.0

WordPress 7.0 is the current target release; the plugin has been smoke
tested against it as part of the 4.2.0 release validation. Bump the
readme.txt 'Tested up to' header so WP.org search and the plugin
directory listing reflect the supported range.
@PatelUtkarsh PatelUtkarsh merged commit e47dc08 into master May 28, 2026
7 checks passed
@PatelUtkarsh PatelUtkarsh deleted the release/v4.2.0 branch May 28, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants