feat(products): catalog guards, dedup and batching - #5690
Merged
Conversation
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 10, 2026 18:35
7eee857 to
a116c59
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 10, 2026 18:54
2939678 to
7b5a2f2
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 10, 2026 18:55
a116c59 to
7936d23
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 12, 2026 09:24
7b5a2f2 to
6cd4f01
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 12, 2026 09:24
7936d23 to
5c6fe4b
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 18, 2026 10:20
6cd4f01 to
087acff
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 18, 2026 10:21
5c6fe4b to
a68b644
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 18, 2026 11:46
087acff to
75947d9
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
2 times, most recently
from
June 18, 2026 12:00
7cfec6e to
13ba1e5
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 18, 2026 12:00
75947d9 to
022eeba
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 18, 2026 12:36
13ba1e5 to
c7fa0b7
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 18, 2026 12:36
022eeba to
44cfa06
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 18, 2026 12:39
c7fa0b7 to
df7fd8d
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 18, 2026 12:39
44cfa06 to
94fe18b
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 22, 2026 19:21
48b65b5 to
f989762
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 22, 2026 20:45
46f8c18 to
9fe3792
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 22, 2026 20:53
f989762 to
da5d44d
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 22, 2026 20:55
9fe3792 to
f9f703f
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
2 times, most recently
from
June 22, 2026 20:59
61904f9 to
f5a7f2e
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 22, 2026 20:59
f9f703f to
4993f07
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 24, 2026 12:22
f5a7f2e to
fdf982f
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
June 24, 2026 12:22
4993f07 to
99efd84
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
June 30, 2026 10:43
fdf982f to
662b8b6
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
July 16, 2026 14:59
91b41dc to
c42f058
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
2 times, most recently
from
July 16, 2026 15:41
64e5424 to
544f4aa
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
from
July 16, 2026 15:41
c42f058 to
7e3b5d1
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
July 17, 2026 05:59
544f4aa to
c2252c9
Compare
rsempe
force-pushed
the
products-and-plans-graphql
branch
2 times, most recently
from
July 17, 2026 06:44
3b40e8a to
83e7c8f
Compare
rsempe
force-pushed
the
products-and-plans-data-model
branch
from
July 17, 2026 07:37
c2252c9 to
8f2422a
Compare
This was referenced Jul 31, 2026
This was referenced Aug 7, 2026
Once a catalog entity is part of a plan or subscription, its structural fields must stop changing: the code, an item's product_category attachment, and a filter's values. Cosmetic fields (name, description, invoice display name) stay editable. In the update services, assign code (product_categories, items, filters), product_category attachment (items) and values (filters) only while the entity is not attached to a plan or subscription, silently skipping them otherwise. Expose those attributes on the update inputs and add an attached_to_plan_or_subscription field on the GraphQL types so the UI knows when to lock them.
## Context Creating a filter on a fixed product returned invalid_product_type. The product type sent is perfectly valid — it is the operation that is incompatible with a fixed product, which has no metric to filter. The code also sat outside the v2 error family, which reserves value_is_invalid for unknown values and not_allowed_for_<field> for valid-but-conflicting ones. ## Description Rename the code to not_allowed_for_product_type, matching the grammar of not_allowed_for_billing_timing and friends.
## Context values_changed? sorts [filter_id, value] pairs to compare the stored values with the submitted ones. A payload mixing a key-only entry (nil value) with a valued entry for the same key made the sort compare nil with a string and raise, turning an invalid request into a 500 on filters already billed through by a subscription — the one path where the comparison runs before the values validation. ## Description Compare values as strings on both sides. Key-only entries stay distinguishable: an empty-string value is invalid, so the empty string only ever represents nil.
## Context Two filters with the exact same resolved value set price the same event slice. The plan-side guard is per filter id, not per value set, so a plan could carry two cards pricing the same slice at different rates — an ambiguity the billing engine cannot resolve. Partial overlaps stay legal: they are real use cases and resolve by match precedence. ## Description Reject a filter whose resolved value set exactly matches another filter's on the same product with value_already_exist on values, at creation and when an update rewrites the values. The filter being updated is excluded from the scan so resending its own values stays a no-op. App-level check only — a product has a handful of filters; a values digest column with a unique index is the path if a DB backstop is ever needed.
## Context The catalog list queries resolved filters_count, products_count and attached_to_plan_or_subscription per row: one COUNT plus two EXISTS queries for every product, filter or category on a page, so a 50-item list issued ~150 queries when the FE selected those fields. ## Description Batch them through two dataloader sources. CountByForeignKey turns the per-row counts into a single grouped count. CatalogAttachment answers attachment for a whole page with one grouped query per applied-card side, keyed by product for products and their filters — a filter is attached when its product is, so both share the same batch — and by category through its products. Default scopes still apply, so discarded rows stay excluded exactly as before.
## Context The create services strip the code param but the update paths added with the attachment gating assigned it raw, so a padded code could be stored on rename and a padded-but-identical code tripped the attachment guard. ## Description Strip the code in the three update services, both when assigning and when comparing against the current code for the structural-edit guard.
endenis
approved these changes
Aug 10, 2026
The duplicate value-set and attachment checks are check-then-act: two concurrent filter writes on the same product could both pass the duplicate scan and commit identical value sets. Hold the product row lock around the checks and the write in the filter create and update services so concurrent writers serialize. A values edit racing a subscription attach is left to the attach path, which will take the same lock when it lands.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
Hardening on top of the catalog services (#6098) and GraphQL surface (#6099): the immutability rules, the review fixes and the list-performance work.
Description
attached_to_plan_or_subscription); filter values freeze once a subscription bills through the filter (attached_to_subscriptions).not_allowed_for_product_type.value_already_existonvalues) — two identical slices would be unresolvable for the billing engine; partial overlaps stay legal.filters_count,products_countandattached_to_plan_or_subscriptionthrough dataloader sources — one grouped query per page instead of per row.