Skip to content

Port Linked Orders to the v2 framework and re-base the monorepo on the template - #20

Merged
ahegyes merged 54 commits into
trunkfrom
feat/lo-port
Oct 30, 2026
Merged

ahegyes merged 54 commits into
trunkfrom
feat/lo-port

Conversation

@ahegyes

@ahegyes ahegyes commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Ports Linked Orders for WooCommerce 1.2.2 to the v2 framework as 2.0.0, on both order storages and distributed through wordpress.org, and brings the monorepo root, its CI and its shared test environment up to the plugin template's conventions, removing the three pre-re-architecture plugin shells.

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Build / CI / tooling
  • Documentation
  • Dependency update

Checklist

  • Changelog fragment added (composer changelog:add in the changed plugin's directory, or at the root for the monorepo itself)
  • Tests added/updated
  • composer quality-check passes locally in every changed plugin
  • Plugin header and readme.txt updated if runtime requirements changed

What changes

  • Root: CI re-based on wordpress-configs' reusable workflows, with plugins.yml as the matrix source (plugins, projects, readmes, mirrors and WordPress branches from each plugin's floor). split-plugins.yml pushes trunk to every mirror and a <slug>/vX.Y.Z tag to its mirror as vX.Y.Z, without --force. Dependabot covers / and /plugins/*. The shared tests environment, on port 8820, maps Linked Orders, and CI runs its Integration and end-to-end suites through the root's test:integration:ci and setup. The license is GPL-3.0-or-later.
  • Linked Orders: built from the plugin template on the framework's core, settings and woocommerce packages, prefixed under DeepWebSolutions\LinkedOrders\Scoped. v1's option and meta keys, settings section and groups, settings API, capabilities, linking API, DWS_Order_Node, add-child action, meta box, orders list additions, autocompletion and Sequential Order Numbers Pro integration work on the posts storage and the order tables, with and without sync, and the plugin declares compatibility with the order tables. Every v1 hook fires deprecated before its dws_lowc/ name; v1's root getters, hook-tag builders, constants and boot hooks stay, deprecated.

Breaking changes

  • Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.2.
  • Hooks move to dws_lowc/ names; their v1 names still fire, deprecated, but for eight settings hooks and the v1 framework's notice-markup hooks, which are removed, as are the v1 framework's accessors such as dws_lowc_instance() and dws_lowc_component().
  • dws_lowc_link_orders() returns false unless the child is another order with neither a parent nor children. v1 accepted the order itself and its ancestors, which made cycles, and roots with children, whose descendants kept depths past the maximum.
  • Links take a lock so that two links to one parent no longer drop one child from its list. dws_lowc_link_orders() waits up to five seconds for another request's link and returns false only after that; dws_lowc_create_linked_child() then deletes the child it created and returns a WP_Error, which the add-child button reports.
  • Freemius is removed; the uninstall's consent reads v1's option.
  • A child whose parent was deleted is its own root, where v1 failed.

Not verified locally

  • Plugin Check, the readme linter and the release rehearsal run only in CI.
  • The wc-linked-orders mirror needs a wp-org-release environment with SVN_USERNAME and SVN_PASSWORD before the first wordpress.org release.

The root manifests sat on the May scaffold's majors: @wordpress/env 11,
@wordpress/scripts 32, @wordpress/e2e-test-utils-playwright 1,
jetpack-changelogger 6, and wordpress-configs floating on its default
branch.

The ranges move to the current majors (@wordpress/env 12.0.0,
@wordpress/scripts 37.0.0, which configs requires as a peer,
@wordpress/e2e-test-utils-playwright 3.1.0, jetpack-changelogger 7),
and configs is pinned to 8d53efd, its current trunk, as the template
pins it. Composer also brings PHPStan 2.3.4, WPCS 3.4.1, wp-compat 3
and the WordPress 7.1 stubs through configs. The npm lock was rebuilt
from a clean install; the only versions that move down are older
copies that hoisting now places at the top of node_modules.

Assisted-by: Claude Code:claude-opus-5-5
The three plugin directories were the May scaffold's shells, built on
framework types that no longer exist (Core\Kernel\PluginKernel among
them), so every plugin lane failed against today's framework. The
first port, Linked Orders, is rebuilt from the current plugin
template, and Internal Comments and Locked Payment Methods return with
their own ports.

The shells go, Linked Orders' included.

Assisted-by: Claude Code:claude-opus-5-5
The root's workflows were the May scaffold's: actions on floating
tags, configs on its default branch, a hand-written plugin list in
every matrix, and a split that pushed each mirror's trunk but no tag,
so no plugin could release from its mirror.

plugins.yml lists the plugin directories, their mirrors (each
composer.json's name), those with a readme.txt and each plugin's
WordPress branches from its Requires at least up, and the other
workflows take their matrices from it, so a plugin joins every lane
without an edit. Quality, Tests, Tests (nightly), Supply-Chain Audit,
CodeQL and Workflow Checks are the template's at configs 8d53efd, run
once per plugin with project-path, plus the readme lint for the
plugins with a readme.txt and the changelog check at the root and in
every plugin. The split forwards a tag <slug>/vX.Y.Z to that plugin's
mirror as vX.Y.Z without --force, as the framework's split forwards
v*. Dependabot covers the root and every plugin directory, since the
mirrors are read-only; zizmor takes configs' exception for ./ calls.

Assisted-by: Claude Code:claude-opus-5-5
The root still described three plugin shells, licensed the monorepo
under GPL-2.0-or-later, and kept a shared test environment on WordPress
nightly and an unpinned WooCommerce zip, with empty test folders and
fan-outs that named the deleted shells.

The root is GPL-3.0-or-later, as the framework and template are. The
shared environment stays on port 8820, now on the template's pinned
WordPress and on WooCommerce from vendor/, with HPOS on and every
plugin active, and its smoke spec checks that each plugin it maps is
active beside the others. The root runs each plugin's Integration
suite in it through test:integration, and the plugins:* fan-outs and
the npm aggregators follow the workspace's script names. AGENTS.md,
README.md, CONTRIBUTING.md, SECURITY.md and the issue and pull-request
templates describe the monorepo as it now works: plugins built from
the template's fill-in, releases forwarded to the mirrors, mirrors
that take no contributions.

Assisted-by: Claude Code:claude-opus-5-5
Linked Orders is the first plugin ported to v2. It starts from
wordpress-plugin-template's trunk (d17a381) through the template's own
fill-in, run in a clone of the template: name Linked Orders for
WooCommerce, slug linked-orders-for-woocommerce, namespace
DeepWebSolutions\LinkedOrders, prefix dws_lowc, ports 8822 and 8823,
repository ahegyes/wc-linked-orders.

As the v1 successor it stays at 2.0.0, which the fill-in sets to 0.1.0
for a new plugin, and its Composer name is its mirror,
ahegyes/wc-linked-orders. The directory keeps the template's files but
for CONTRIBUTING.md, the pull-request and issue templates, Dependabot's
configuration and the fill-in's workflow, script and Quality job,
which belong to the monorepo. The template's demo stays until the
port replaces it, and every suite passes as filled in.

Assisted-by: Claude Code:claude-opus-5-5
Linked Orders carried the template's demo, which a plugin replaces:
the greeting feature, the example block and the editor script, with
their options, settings fields, tests and build entries.

They go as the template's README describes, the greeting's keys,
text and validator with them. The build now produces v1's orders
list table script, which opens the views of a customer's or a tree's
orders in a new tab, and its stylesheet for the order action icons,
and the entry's build gate checks the script's asset file. The data
consent and the template's generic readers stay until the port
replaces them.

Assisted-by: Claude Code:claude-opus-5-5
v1 stored three settings under its own keys, read them through
dws_lowc_get_*_setting() helpers that other plugins call, filtered
them through hooks this port keeps as deprecated, granted four
capabilities, and on uninstall removed its order links and options.
Nothing in the port carries any of it yet.

Rows pin v1's keys and defaults, the section on WooCommerce's Advanced
tab under v1's id with v1's two groups, the checkbox and depth
round trips, the field and value filters (v1 literal first, then the
v2 name), the settings helpers, the migration's defaults, the
capabilities on administrators and shop managers, and an uninstall
after v1's consent that removes the links on both order storages,
v1's leftover rows and the capabilities. The boot row expects the
capability grants and the declaration of order-table compatibility.
OrderStorage runs a row on either storage, with sync on or off.

Assisted-by: Claude Code:claude-opus-5-5
The settings API rows assumed a read without a group found the field
in any group, and that both v1 value filters fired on every read. v1
resolved a read without a group only from a field id that names the
group, such as general/max-depth, and fired the section's filter only
on that path, after the group's.

The rows now read the group from the id, expect null for a bare id
without a group, and check each path's filters. The settings provider
no longer overrides PHPUnit's final groups(), and the partial-deploy
row throws from an include that loads after metadata.php, since no
include reads the composition root any more.

Assisted-by: Claude Code:claude-opus-5-5
The port carried none of what v1 stored or exposed: its settings and
their helpers, its capabilities, its order links on uninstall, or its
compatibility with WooCommerce's order tables.

The settings keep v1's keys and defaults in a section of the Advanced
tab under v1's id, with v1's two groups; the yes-or-no selects become
checkboxes, which store the same values. Each group's fields pass its
v1 filter, deprecated, then its v2 filter. dws_lowc_get_raw_setting(),
dws_lowc_get_validated_setting() and dws_lowc_validate_setting() keep
v1's signatures and read paths, and the validated value passes v1's
filters, deprecated, then dws_lowc/settings/validated_value. The
migration stores every default, keeping a v1 site's values.
CapabilityGrants gives administrators and shop managers v1's four
capabilities. The footprint covers v1's settings, version rows,
notices, order-link meta on both storages and the capabilities. The
plugin row links to the section and the support forum, and the plugin
declares itself compatible with the order tables.

Assisted-by: Claude Code:claude-opus-5-5
v1's linking API, its DWS_Order_Node model and its child creation are
the plugin's domain surface, which Quote Requests v1 calls, and the
port carries none of them yet.

Rows link, walk and name orders on both order storages, with sync on
and off: the link's stored meta, the tree walk, the root of a child
whose parent was deleted, the node's proxy and formatted name, the
child-creation check against capability, maximum depth and status,
and a created child's customer, addresses, creator and link. Linking
refuses a child that has a parent, the order itself, and an ancestor
of the parent, because a cycle sends every tree walk into endless
recursion. A failed creation returns null or the creator's error, as
in v1. Each renamed hook fires its v1 literal, deprecated, then its v2
name.

Assisted-by: Claude Code:claude-opus-5-5
The linking rows expected dws_lowc_is_supported_order() to return
false for a refund. v1 returns null for anything that is not a
WC_Order, and a refund is a WC_Order_Refund, so the row now expects
null.

Assisted-by: Claude Code:claude-opus-5-5
The port had no linking API, DWS_Order_Node or child creation, which
Quote Requests v1 calls and type-hints.

includes/post-type.php and includes/linking.php keep v1's twelve
helpers with their signatures and return values, and models/ holds
DWS_Order_Node on the classmap under its global name, which reads and
saves v1's meta keys through WC CRUD, so links work on both order
storages. Each v1 hook fires its literal, deprecated, then its v2
name. Linking also refuses the order itself and an ancestor of the
parent, which v1 allowed and which sent every tree walk into endless
recursion, and a child whose parent was deleted is its own root rather
than a fatal error. The node reads the post type from the order's
type, so it holds on the order tables too. PHPCS knows WooCommerce's
edit_shop_orders capability, and PHPStan covers models/.

Assisted-by: Claude Code:claude-opus-5-5
v1 created a linked child from an admin-post action that Quote
Requests v1 links to, completed descendants when their parent
completed, and numbered a new child through WooCommerce Sequential
Order Numbers Pro. The port has none of them.

Rows post the action on both order storages and expect the child's
editor, a death without a valid nonce, and a one-shot notice with a
redirect for a missing parent, a user who may not create a child and
a failed creation. Autocompletion rows complete a root on both
storages, complete only a child's own subtree, do nothing with the
setting off, and honor both filters, whose v1 literals fire first.
The SOS Pro integration loads only beside that plugin, which a
stand-in function replaces in a separate process.

Assisted-by: Claude Code:claude-opus-5-5
The autocompletion rows expected the note to read only "Child Order
autocompleted.", but WooCommerce appends its own sentence on the
status change to a transition's note. The boot row listed only the
components the port had then.

The note row expects the whole note, and the boot row expects child
creation and autocompletion loaded and the Sequential Order Numbers
Pro integration skipped, since the tests site runs without that
plugin.

Assisted-by: Claude Code:claude-opus-5-5
The port had no admin-post action to create a linked child, no
autocompletion of descendants and no Sequential Order Numbers Pro
integration, all of which v1 shipped.

ChildCreation answers v1's admin-post action and nonce, opens the new
child in its editor on either order storage, and otherwise returns to
the referring screen, or the parent, with a one-shot user notice
through AdminNotices. Autocompletion checks the setting when an order
completes, as v1's validated read did, and walks the order's
descendants with v1's filters, deprecated, before the v2 names. The
integration loads only where wc_seq_order_number_pro() exists and
numbers a child from the v2 creation hook; a stand-in in tests/Fixtures
gives PHPStan the plugin's API. dws_lowc_get_singular_name() holds the
order type's label lookup, which the node now shares.

Assisted-by: Claude Code:claude-opus-5-5
v1 showed an order's links in a meta box and added a depth column,
depth, guest and tree filters and three row actions to the orders
list, all on the posts table only. The port shows none of them.

Meta box rows register the box beside an order's editor on both
storages, for users with its capabilities only, and render the
parent, the children, the add-child button with its nonce, and the
message for an order at the maximum depth. List rows check the column
and its labels, the depth filter up to the deepest stored or allowed
level, the queries by depth, guest email and tree, the row actions
and their URLs on either list, and the script and icons only on order
lists. The three hooks they fire keep their v1 literals first.

Assisted-by: Claude Code:claude-opus-5-5
The meta box rows called add_meta_box(), which WordPress loads only in
admin requests along with the screen API it needs, and the boot row
did not list the new components. The depth-filter row created its
deepest order last, so a lookup that ignored depth and took the
newest order passed too.

The rows load template.php and the screen API as an admin request
does, the boot row expects the meta box and the list table, and the
depth-filter row adds a newer, shallower order, which fails both
posts-storage rows when the lookup sends that store a meta query.

Assisted-by: Claude Code:claude-opus-5-5
v1's meta box and list additions hooked the posts table's screens,
queries and links only, so on a store using WooCommerce's order tables
none of them appeared.

MetaBox registers on add_meta_boxes for whichever editor screen fires
it and links through get_edit_order_url(). ListTable hooks the depth
column from current_screen into either list, the filters into both
restrict hooks, and the depth, guest and tree filters into the posts
request and the order-table query arguments, with the guest email as
a meta clause or billing_email as each store takes it. The row
actions link to either list. The deepest stored level comes from one
order query per store, since the posts store refuses a meta query.
Capabilities are checked in each callback. Each v1 hook fires its
literal, deprecated, then its v2 name. dws_lowc_get_max_depth() now
reads the validated setting, so v1's filters apply to every depth
check, and dws_lowc_get_order_node() documents that it takes any
value.

Assisted-by: Claude Code:claude-opus-5-5
v1 exposed five getters, two hook-tag builders and five constants at
the root, and fired dws_lowc_initialized and
dws_lowc_initialization_failure after its boot. The port defines none
of them, so code that still calls them fatals.

Rows expect the getters to answer from the plugin header, each with a
deprecation naming its replacement where it has one, the constants to
hold the same values, the hook-tag builders to join and sanitize names
as v1 did, with null for a component v1 did not have, and the boot
hooks to fire, deprecated, after the framework's, with the failure.

Assisted-by: Claude Code:claude-opus-5-5
Code written against v1 called dws_lowc_name() and its siblings, read
the DWS_LOWC_* constants, built hook names with the hook-tag helpers,
and listened for dws_lowc_initialized; under the port each of these
fatals or never fires.

includes/deprecated.php defines the five constants from the plugin
header, read once per request, so they follow each release; v1's
whitelabel prefix on the name goes. The five getters answer from the
same values, and the two builders join and sanitize names as v1 did,
mapping its twelve component ids to their roots, each raising a
deprecation. functions.php hooks the two v1 boot hooks, deprecated,
to the framework's succeeded and failed hooks, passing the failure's
Throwable where v1 passed its own exception.

Assisted-by: Claude Code:claude-opus-5-5
Linked Orders ships through wordpress.org, whose readme.txt states the
stable tag and floors a second time, and the changelog sync fills its
changelog block between two markers. The port has no readme.txt.

Unit rows expect the readme's Stable tag, Requires at least and
Requires PHP to equal the entry file's Version and floors, and its
changelog block to hold the markers.

Assisted-by: Claude Code:claude-opus-5-5
Linked Orders ships from wordpress.org, as v1 did, but the port still
carried the template's GitHub distribution: an Update URI, its own
text-domain loading, the template's description, no readme.txt and a
release that publishes to GitHub only.

The entry file drops both and takes v1's description, which turns the
bundled updater inert and leaves translations to language packs.
readme.txt keeps v1's sections updated for v2, with an Upgrade Notice
naming the floors and the removed hooks. CHANGELOG.md holds v1's
history between the markers that changelog:write copies into the
readme, through the Pronamic sync it now runs, and changelog/ stages
the 2.0.0 entries, one per change the rulings list. The plugin's
quality workflow lints the readme, and its release passes wp-org.

Assisted-by: Claude Code:claude-opus-5-5
The Integration rows fire the hooks the meta box and the orders list
answer, but never load those screens, so a hook WooCommerce fires at
another time, or not at all, would pass them.

An end-to-end spec creates an order, adds a child from its meta box,
expects the child's editor to name the parent, and expects the orders
list to show both orders' depth; a second follows the plugin row's
Settings link to the section. The tests environment stores orders in
WooCommerce's order tables, so these cover that storage, and the
Integration rows cover the posts table.

Assisted-by: Claude Code:claude-opus-5-5
Ruling 46 asks for each Integration row that touches orders on the
posts table and on WooCommerce's order tables, each with the sync
between them on and off, but nineteen rows ran only on the tests
site's own storage, the order tables without sync.

Those rows take the storages provider. The list-table rows also clear
the column hooks a screen adds, which stayed for the rest of the
process and showed the depth column to a user without its
capability on the next posts-table row. The rows that check hook
order, and the stand-in plugin's row, stay on one storage, since the
storage changes neither.

Assisted-by: Claude Code:claude-opus-5-5
The plugin's README, AGENTS.md, architecture and tests docs, SECURITY
scope and manifest descriptions still described the template: its
demo, its fill-in, its blocks and its GitHub distribution. docs/
recipes.md, the template's guide, is removed in 846f4f4, which
staged it by accident.

Each now describes Linked Orders: what it does and how other plugins
extend it, its layout of components, v1 API and model, its order
storage and v1-hook conventions, its data under v1's keys and its
wordpress.org distribution. They point at the template's recipes,
which it maintains. The empty templates/ folder goes, and so do
PHPCS's exclusion of a blocks build and two workflow comments that
named the fill-in.

Assisted-by: Claude Code:claude-opus-5-5
The Claude review found four gaps the rows missed. The uninstall rows
ran with the plugin loaded, while WordPress loads only uninstall.php
for an inactive plugin. Linking accepted a root that had children,
whose descendants then kept their old depths. The child-creation
action read its parent id with absint(), so '-42' named order 42.
The tree filter replaced the orders a search had found instead of
narrowing them.

Rows uninstall through a WP-CLI request that skips the plugin, refuse
a child that has children, treat a negative or partly numeric parent
id as no order, and expect the tree filter to keep only the found
orders in the tree, on both storages.

Assisted-by: Claude Code:claude-opus-5-5
The row expecting a malformed parent id to name no order left the
children it created behind while the action still read the id with
absint(). The shared tests site then held orders on the order tables
without posts copies, and the next start of the site failed
WooCommerce's sync check before enabling the order tables.

The row now hands every child of the parent to the cleanup before
asserting there are none.

Assisted-by: Claude Code:claude-opus-5-5
An uninstall loaded footprint.php without the capability constants it
names, so every real uninstall, where WordPress loads uninstall.php
alone, failed and kept the plugin's data despite consent. Linking a
root that had children left its descendants at their old depths, past
the maximum. The child-creation action read its parent id with
absint(), so '-42' named order 42. The tree filter replaced the
orders a search had found.

uninstall.php loads capabilities.php. A child must be a lone root,
neither with a parent nor with children, which also rules out every
ancestor, so the root lookup goes. The parent id is read as a whole
number of at least 1. The tree filter intersects what a search found
on either storage. A node loads its parent's and children's orders
only when asked, so the depth column no longer loads them for every
row. dws_lowc_get_create_child_url() builds the add-child URL for the
meta box and the row action. The typed option readers, which nothing
called, go with their rows.

Assisted-by: Claude Code:claude-opus-5-5
Linking now refuses a root that has children, which v1 accepted, and
code that linked whole trees through dws_lowc_link_orders() gets
false where v1 gave true. The 2.0.0 changelog did not say so.

A major changed entry names the refusal and why.

Assisted-by: Claude Code:claude-opus-5-5
Codex found three gaps. A v1 filter on the validated depth could stop
new children with 0, which the port clamped back to 1. The depth and
guest filters joined another extension's OR meta query as further
alternatives. The include loader's underscore check asserted on a
file the port never had.

Rows expect a filtered depth of 0 to stand, the depth filter to
narrow an OR meta query on both storages, and a fresh request to skip
an underscore-prefixed include written for the row. The uninstall row
without the plugin restores the site through a request with the
plugin, as the site's next request would: its in-process restore left
the migration runner unable to run later in the same process, which
failed the migration rows in the shared environment's order.

Assisted-by: Claude Code:claude-opus-5-5
The depth reader clamped v1's filtered value back to at least 1, so a
filter returning 0 no longer stopped new children as it did in v1.
The list's depth and guest clauses joined an OR meta query already in
place as further alternatives, broadening the list. CI ran each
plugin's suites in its own directory and never the root's shared
environment, so a broken root mapping or activation would pass. The
docs claimed four storages for every order-touching row, and the
Upgrade Notice left out the removed v1 accessors.

dws_lowc_get_max_depth() returns the filtered value; the stored value
keeps its minimum of 1 through validation. The list nests the query in
place and its own clauses under one AND. A root composer script,
test:integration:ci, installs and builds the plugins before the shared
Integration suite, which tests.yml now runs beside the shared
end-to-end suite. The docs name the rows that use one storage, and the
Upgrade Notice names the removed accessors.

Assisted-by: Claude Code:claude-opus-5-5
Codex found two gaps. v1 read any field of a known group from its
option, such as one an extension adds through the group's field
filter, which the port keeps, but the settings API reads only the
three built-in fields. The Sequential Order Numbers Pro row named the
number set before the child's save without checking it.

A row expects an extension field to read back through the raw and
validated getters, by group and through the section, and an unknown
group to read as null. The stand-in numbers the order on its object,
and the row expects the reloaded child to hold that number.

Assisted-by: Claude Code:claude-opus-5-5
The settings API resolved only the three built-in fields, returning
null for any other, while v1 read every field of a known group from
its option and the port keeps the group filter through which an
extension adds one. Such a field saved through WooCommerce but read
back as null.

dws_lowc_find_setting() derives the option, dws-lowc_{group}_{field},
for any field of a known group, and validation stays specific to the
built-in settings. DWS_LOWC_SETTINGS_GROUPS lists v1's groups only,
since the option follows from the group and the field.

Assisted-by: Claude Code:claude-opus-5-5
Codex found two gaps. The depth filter's deepest level skipped trashed
orders, which v1's query counted and the list's Trash view shows. The
meta box rows passed a WC_Order on every storage, while the posts
editor passes its WP_Post, so a callback narrowed to orders would
pass them and break that editor.

A row expects a trashed order's depth at the deepest level, on all
four storages, and the meta box rows pass the post on the posts
storage and the order on the order tables.

Assisted-by: Claude Code:claude-opus-5-5
The depth filter's deepest level came from an order query without a
status, which WooCommerce limits to its live statuses, so an order in
the Trash, which the list's Trash view shows, never set it, though
v1's query counted every order of the type.

The query names every order status and trash.

Assisted-by: Claude Code:claude-opus-5-5
Codex found that autocompletion skipped the completed order itself,
while v1 walked the order's whole tree, the order first, through both
filters, so a v1 completed_status filter could set the order itself
to another status.

A row on all four storages expects the completed order to pass the
should_autocomplete filter before its child. The deprecated-hook rows
complete a lone order, whose tree is the order alone, so each filter
still runs once.

Assisted-by: Claude Code:claude-opus-5-5
Autocompletion skipped the order that completed and walked only its
descendants, while v1 walked the order's whole tree, the order first,
through both filters, so a v1 completed_status filter could no longer
set the order itself to another status.

The walk starts at the order again. Setting an already completed
order to completed changes nothing, so the order changes only through
a filter.

Assisted-by: Claude Code:claude-opus-5-5
Codex found two gaps. The deepest-level query named WooCommerce's
order statuses and trash, so an order in any other registered status,
such as a draft or another plugin's status, never set the level,
though v1 counted every order of the type. With no filter chosen, the
list's query callbacks still added a meta_query key, and WooCommerce
counts the order tables' list from its cache only when the query
holds none of its own arguments, so every unfiltered list view ran a
count query.

A row on all four storages expects a trashed and then a draft order
to set the deepest level, and another expects an unfiltered list's
query to come back as it went in.

Assisted-by: Claude Code:claude-opus-5-5
WooCommerce sets a draft order back to pending on save, so the
deepest-level row's draft case never held the status it named. v1
Quote Requests registers its quote statuses only as post statuses,
outside WooCommerce's order statuses, which is the case the row
means.

The row registers such a status, accepts it on the order while it
saves, and expects the order holding it to set the deepest level.

Assisted-by: Claude Code:claude-opus-5-5
The deepest-level query named WooCommerce's order statuses and trash,
so a supported order type whose statuses are registered only as post
statuses, as v1 Quote Requests registers its quotes', never set the
level, though v1 counted every order of the type. The list's query
callbacks added a meta query key even with no filter chosen, which
kept WooCommerce from counting the order tables' list from its cache
on every unfiltered view.

The query names every registered post status, and the callbacks add
a meta query only when a depth or guest filter is chosen.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Feat/lo port Port Linked Orders to the v2 framework and re-base the monorepo on the template Oct 30, 2026
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

The wordpress.org readme linter fails CI on wordpress.org's default
readme text, and the manual installation step, kept from v1, matched
its "Upload ... plugin ... directory" pattern.

The step says to copy the unzipped folder into the site's
wp-content/plugins folder, which no default-text pattern matches.

Assisted-by: Claude Code:claude-opus-5-5
A parent's children are one meta value that every link reads and
rewrites. A request that loaded the parent before another request
linked a child to it wrote back its own list, dropping that child,
as two clicks on the add-child button can do. v1 shares the race.

A row on all four storages links one child in a second WP-CLI request
after this request loaded the parent, links another here, and expects
both listed. Others expect a link to be refused while another request
holds the linking lock, and a linked child whose link is refused to be
deleted and an error returned in place of its id.

Assisted-by: Claude Code:claude-opus-5-5
Every link read the parent's children list, added one and wrote the
list back, so two requests linking to one parent at the same time,
as two clicks on the add-child button can, each wrote their own list
and one child dropped out of it while keeping its own parent. A
request that loaded the parent before the other request's link
dropped the child even without overlapping, since it wrote back the
meta it had cached.

dws_lowc_link_orders() holds the framework's Storage\Lock, as the
dws_lowc_linking option, while it links, re-reads both orders' meta
from the database once it holds it, and returns false while another
request holds it. dws_lowc_create_linked_child() no longer ignores a
refused link: it deletes the child it created and returns a WP_Error,
which the add-child action reports.

Assisted-by: Claude Code:claude-opus-5-5
Codex found that WooCommerce's order-tables data caching, off by
default but recommended for stores with an object cache, keeps each
order's meta for the request, and read_meta_data( true ) reads
through that cache, so a link still wrote back a parent loaded before
another request's link and dropped its child.

The lost-update row gains a variant on the order tables with data
caching on.

Assisted-by: Claude Code:claude-opus-5-5
With WooCommerce's order-tables data caching on, read_meta_data( true )
read each order's meta from the cache this request filled when it
first loaded the order, so a link under the lock still wrote back a
parent loaded before another request's link and dropped its child.

The link clears both orders' cached data through the order data store
before it re-reads their meta; the posts store has no such cache, and
the call reaches nothing there.

Assisted-by: Claude Code:claude-opus-5-5
The shared environment's Integration job failed the row that brings a
new site to revision one. The migration runner's 20-second budget
lasts for the request, and in a fresh site the activating request does
not boot the plugin, so PHPUnit's bootstrap is the first request that
migrates and starts the budget. The row's in-process run, minutes into
the suite, found it spent and only scheduled a continuation. A local
site that had already migrated never started the budget, so the row
passed there.

The row runs in a separate process, as a site's next request would.

Assisted-by: Claude Code:claude-opus-5-5
v1 Quote Requests resets a quote row's actions at priority 999,
keeping Linked Orders' only where its settings allow quote linking.
v1 Linked Orders added its actions at the same priority but booted
first, so the reset saw them. v2 boots on plugins_loaded at 15, after
Quote Requests v1 has registered, so its actions land after the reset
and show on quote rows the settings hide them from.

A row registers a reset at 999 before the plugin's own callback and
expects the reset to have the last word.

Assisted-by: Claude Code:claude-opus-5-5
v1 Quote Requests resets a quote row's actions at priority 999 and
keeps Linked Orders' only where its settings allow quote linking. v2
added its actions at the same priority, after Quote Requests v1 had
registered, so they survived the reset and showed on quote rows.

The actions go in at 998, so the reset has the last word whichever
plugin boots first.

Assisted-by: Claude Code:claude-opus-5-5
No row failed when a link skipped re-reading the child's meta, though
a child another request linked after this one loaded it was then
linked again, ending with two parents. The rows also left the order
tables' data caching to whatever the tests site stored.

A row on all five variants expects that link to be refused and the
child to keep its first parent; it fails on each when the child's
forced read is removed. OrderStorage pins the data caching option for
every row, and the lost-update row checks the parent as this request
sees it after the saves as well as after a cache flush.

Assisted-by: Claude Code:claude-opus-5-5
A link cleared both orders' cached data through the order data store's
clear_cached_data(), which WooCommerce tags @internal, reached through
WC_Data_Store's magic forwarding and a PHPStan ignore. The bypass is
needed only with the order tables' data caching on, since that cache
answers read_meta_data( true ) in place of the database.

The link turns the caching off through the option's pre_option filter
for the two forced reads only, and removes the filter before the
saves, which then clear WooCommerce's cache as they do without it.

Assisted-by: Claude Code:claude-opus-5-5
A link refused at once while another request held the linking lock,
though v1 callers such as Quote Requests never check whether a link
worked, so an order created from an accepted quote could stay
unlinked when two links overlapped by a few milliseconds.

A row has a second request hold the lock for about a second and
expects the link to wait for it and succeed; the refusal row expects
the link to give up only after five seconds.

Assisted-by: Claude Code:claude-opus-5-5
PHPCS warned that the lock-row reader's assignment in the wait row was
out of line with the counter below it.

Assisted-by: Claude Code:claude-opus-5-5
A link refused at once while another request held the linking lock,
though a link holds it for milliseconds and v1 callers such as Quote
Requests never check whether a link worked, so an order created from
an accepted quote could stay unlinked.

dws_lowc_link_orders() retries the lock every 50 milliseconds for up
to five seconds, and refuses only after that, as when a request died
holding it until its 30-second hold expires. An overlapping second
click on the add-child button now links its child too, as in v1.

Assisted-by: Claude Code:claude-opus-5-5
Every Deep Web Solutions plugin moves to v2 together, so Linked Orders
need not order its row actions around v1 Quote Requests' reset, and
comments that justified code by v1 Quote Requests or by v1 callers
would go stale once those are gone.

The row actions go back to priority 999 and the row that registered a
reset before them goes. The link's docblock gives the general reason
it waits for the lock, and the deepest-level row names its custom
status without referring to quotes.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes
ahegyes merged commit 6682423 into trunk Oct 30, 2026
33 checks passed
@ahegyes
ahegyes deleted the feat/lo-port branch October 30, 2026 18:11
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.

2 participants