A content-only CMS save (a description edit, an image swap) bumps product_commerce.updated_at,
which invalidates the expectedUpdatedAt watermark carried by any open Pricing & inventory
form. The merchant sees a spurious "This product changed since you opened it" 409 on a save that
conflicts with nothing. Recovery is reloading the form, so it is an annoyance rather than a
correctness problem — but it is a real regression in merchant experience once the CMS sync's only
payload is the title.
Mechanism. deriveSaveIdempotencyKey includes a version component, so every save mints a
new key; the upsert therefore always applies, and always advances updated_at, even when no
owned column actually changed.
The obvious fix is wrong — do not re-propose it
Making the sync's idempotency key payload-derived (hash the title, so an unchanged title
replays and no-ops) corrupts the title cache. In
packages/store-postgres/src/kysely-product-commerce-store.ts, the replay guard
(idempotency_key != :key) and the watermark guard are two .where() clauses on the same
onConflict().doUpdateSet(). They are ANDed, so a replay-guard hit no-ops the whole
statement — including content_updated_at: eb.ref("excluded.content_updated_at") inside the SET
block. The stored watermark would then sit permanently behind real time:
- stored
title="A", key=K(A), content_updated_at=T1;
- merchant renames to
"B" at T2, then back to "A" at T3;
- delivery reorders — T3 arrives first.
K(A) == K(A) ⇒ replay guard fails ⇒ total no-op,
watermark stays at T1;
- the delayed T2 lands.
K(B) != K(A) passes, and T2 >= T1 passes the watermark guard ⇒ it
applies;
- final cached title
"B"; CMS truth "A". Nothing self-heals — the cache is only rewritten on
the next title change and there is no reconcile cron.
That value is the snapshot source for order_items.title, so it reaches receipts, admin order
views, emails and the Stripe PaymentIntent description. Today's per-save-distinct key is what
keeps the watermark advancing; the churn is the price of that correctness.
The recommended fix
Make updated_at in the DO UPDATE SET conditional on an owned column genuinely differing,
while content_updated_at continues to advance unconditionally. That is the correct seam — it
fixes the console-edit path too.
Scope: [Adapters] + the store contract, cases on both dialects, and it needs a SQLite
portability answer (no IS DISTINCT FROM).
Two facts for whoever picks this up
idempotency_key is text NOT NULL with no length bound on either dialect
(packages/store-postgres/src/migrations/0002_product_commerce.ts), so key width never
constrained the design.
- The row has a single shared
idempotency_key column, so an intervening activate or
console edit overwrites the stored key. Any future keying scheme has to account for that.
Source: plans/one-home-per-field.md §4.5. Deliberately out of scope for PRs 1a/1b/1c.
A content-only CMS save (a description edit, an image swap) bumps
product_commerce.updated_at,which invalidates the
expectedUpdatedAtwatermark carried by any open Pricing & inventoryform. The merchant sees a spurious "This product changed since you opened it" 409 on a save that
conflicts with nothing. Recovery is reloading the form, so it is an annoyance rather than a
correctness problem — but it is a real regression in merchant experience once the CMS sync's only
payload is the title.
Mechanism.
deriveSaveIdempotencyKeyincludes aversioncomponent, so every save mints anew key; the upsert therefore always applies, and always advances
updated_at, even when noowned column actually changed.
The obvious fix is wrong — do not re-propose it
Making the sync's idempotency key payload-derived (hash the title, so an unchanged title
replays and no-ops) corrupts the title cache. In
packages/store-postgres/src/kysely-product-commerce-store.ts, the replay guard(
idempotency_key != :key) and the watermark guard are two.where()clauses on the sameonConflict().doUpdateSet(). They are ANDed, so a replay-guard hit no-ops the wholestatement — including
content_updated_at: eb.ref("excluded.content_updated_at")inside the SETblock. The stored watermark would then sit permanently behind real time:
title="A",key=K(A),content_updated_at=T1;"B"at T2, then back to"A"at T3;K(A) == K(A)⇒ replay guard fails ⇒ total no-op,watermark stays at T1;
K(B) != K(A)passes, andT2 >= T1passes the watermark guard ⇒ itapplies;
"B"; CMS truth"A". Nothing self-heals — the cache is only rewritten onthe next title change and there is no reconcile cron.
That value is the snapshot source for
order_items.title, so it reaches receipts, admin orderviews, emails and the Stripe PaymentIntent description. Today's per-save-distinct key is what
keeps the watermark advancing; the churn is the price of that correctness.
The recommended fix
Make
updated_atin theDO UPDATE SETconditional on an owned column genuinely differing,while
content_updated_atcontinues to advance unconditionally. That is the correct seam — itfixes the console-edit path too.
Scope:
[Adapters]+ the store contract, cases on both dialects, and it needs a SQLiteportability answer (no
IS DISTINCT FROM).Two facts for whoever picks this up
idempotency_keyistext NOT NULLwith no length bound on either dialect(
packages/store-postgres/src/migrations/0002_product_commerce.ts), so key width neverconstrained the design.
idempotency_keycolumn, so an interveningactivateorconsole edit overwrites the stored key. Any future keying scheme has to account for that.
Source:
plans/one-home-per-field.md§4.5. Deliberately out of scope for PRs 1a/1b/1c.