Preserve V2 FlowSpec state during V3 admin sync

This PR makes V3 FlowSpec publishes update only the V3-owned product-selection envelope while preserving V2-owned runtime edits, legacy JSON, and concurrent mutations.

Author: @mehulshinde PR: dealops#6398 Base: main Head: mehul/dealops-qnp9 State: open Files: 17 Diff: +1312 / -259 Builds on: #6396

What it fixes

V3 admin sync no longer overwrites the live V2 PricingFlowSpec document with stale converted calculator, filters, or terms state.

Ownership model

V3 owns product selection. V2 remains authoritative for Terms Library, Pricing Calculator, filters, unknown top-level fields, and legacy step variants.

Concurrency guard

Server-side read-modify-write paths are serialized per organization with a transaction-scoped Postgres advisory lock.

Client rebase

Read-before-write hooks bypass React Query’s stale window so they splice edits into the latest server document.

V3 FlowSpec
V2 PricingFlowSpec
Product-selection envelope
V2-owned admin editors
Mutation lock

1. Why this exists

Before

A V3 publish converted the V3 FlowSpec into a full V2 pricingFlowSpecData payload.

  • Converted V3 state could contain stale calculator or term data.
  • Unknown V2 JSON fields could be stripped by schema parsing.
  • Racing V2 and V3 writes could clobber each other.
After

V3 sync is treated as a fragment update against the latest live V2 document.

  • Only the v3ProductSelectionStep is replaced or inserted.
  • Existing V2-owned fields and steps are cloned forward verbatim.
  • Writes are locked and rebased on the latest row.
V3-owned Product Selection

Synced from V3 into the V2 runtime row.

V2-owned Terms, calculator, filters

Preserved from the latest V2 row during V3 sync.

Shared write surface Locked per organization

Read and write happen inside one advisory-locked transaction.

2. What changes

Server: preservation-aware FlowSpec mutations

pricingFlowSpecMutations.ts
new Adds mergeV3ProductSelectionIntoV2FlowSpec and withPricingFlowSpecMutationLock.
v3Admin/_helpers.ts
publish V3 publish now appends a V2 row by copying the latest V2 document and replacing only product selection.
pricingFlowSpec/update.ts
rebase Dealops V3 orgs update the latest row inside the same lock, preserving current product selection while applying V2 editor changes.
TermService.ts
raw JSON Term mutations read the raw latest row, validate only the term/page-layout fragments they touch, and save raw JSON back.
pricingFlowSpecRepository.ts
repository Adds transaction-aware reads plus raw getMostRecentRaw and updatePricingFlowSpecDataRaw escape hatches.

Client: fresh read before write

New helper

fetchFreshQuery wraps queryClient.fetchQuery with staleTime: 0.

That forces a server read even when the shared React Query cache entry is still fresh.

Updated hooks
  • useFilterMutations
  • usePricingCalculatorConfig
  • useProductSelectionConfig
Cache invalidation

useUpdateFlowSpec now invalidates V2-backed pricing flow and terms queries after V3 publish.

Behavior comparison

Path Old behavior New behavior
V3 FlowSpec publish Append a V2 row from full V3 conversion. Append a V2 row from latest V2 + converted product-selection envelope.
Rollback / in-place sync Overwrite the most recent V2 row with full conversion. Mutate only product selection in the most recent V2 row.
Terms Library save Parse and rewrite the whole V2 FlowSpec shape. Lock, read raw latest JSON, mutate terms in category layouts, save raw JSON.
Pricing Calculator save Apply edits to possibly cached V2 data. Fetch fresh V2 data, apply calculator fragment, then update.

3. How it works

Merge rule

mergeV3ProductSelectionIntoV2FlowSpec(currentV2, convertedV2) clones currentV2, then replaces or inserts the converted product-selection step.

If the converted document has no product-selection envelope, it throws instead of wiping V2 state.

Lock rule

withPricingFlowSpecMutationLock runs inside prisma.$transaction and executes:

SELECT pg_advisory_xact_lock(hashtextextended(key, 0))
Raw JSON rule

Preservation-aware paths avoid full PricingFlowSpecData parsing because that schema can strip unknown keys or reject legacy steps.

TermService now mutates fragments, not the whole schema

mutatePricingFlowSpec lock organization getMostRecentRaw
validate touched fragments category step page layout term payload
updatePricingFlowSpecDataRaw same transaction unknown fields preserved

4. Test coverage added

Client cache regression

fetchFreshQuery.test.ts proves the helper runs the query function even when the shared cache entry is still fresh.

Repository raw paths

Repository tests cover raw latest-row reads and raw JSON updates without schema parsing.

Deterministic concurrency

Term tests verify both orderings: Terms-save first and V3-publish first. Both changes survive.

V3 publish regression

V3 admin tests assert publish and rollback change only product selection while preserving calculator, terms, legacy fields, and unknown config.

Validation reported in the PR: deterministic server tests: 29 passing; repository and client adapter suites passing; server/client typechecks passing; targeted ESLint, Prettier, and git diff --check passing.

5. What it doesn’t change

6. Risks / rollback / open questions

Risk: advisory lock contention. FlowSpec mutations for the same organization now serialize with a 20s transaction wait/timeout. That is the right safety tradeoff for avoiding lost updates, but reviewers should watch for long-running work inside the lock.
Risk: raw JSON escape hatches. The PR intentionally bypasses full FlowSpec schema parsing on preservation paths. Fragment validation and fail-closed structure checks are the guardrails.
Rollback behavior: rollback/in-place sync still mutates the latest V2 row, but now only swaps the V3 product-selection envelope. That preserves V2-owned live edits instead of restoring a full converted document.