feat(rank-tracking): record which field a stored position came from - #177
Open
Nordalux wants to merge 1 commit into
Open
feat(rank-tracking): record which field a stored position came from#177Nordalux wants to merge 1 commit into
Nordalux wants to merge 1 commit into
Conversation
#429 switched new rank checks from `rank_absolute` to `rank_group`. That is the right number to show, but `rank_snapshots.position` holds both meanings now with nothing to tell them apart, and the two are not on the same scale: for the same result `rank_absolute` is worse by the number of SERP features above it. Add a nullable `position_basis` column and set it on write. Null means the pre-#429 rank_absolute, resolved in one place by `resolvePositionBasis` so the legacy meaning does not get re-derived at each call site. No backfill, deliberately: the raw SERP items are not stored, so `rank_group` cannot be recomputed for existing rows. Guessing would be worse than recording that we do not know. This changes no behaviour — nothing reads the column yet. It is separate so the metadata starts being captured now; every check written without it produces another row whose meaning can only be inferred from its timestamp later. Both migrations are a plain additive nullable column. schema-parity covers the two dialects.
HasimEmre
pushed a commit
to HasimEmre/open-seo
that referenced
this pull request
Jul 30, 2026
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.
Problem
#429 changed new rank checks from
rank_absolutetorank_group, which is the right number to show — I'm not proposing to revert that. Butrank_snapshots.positionis a single integer column with nothing recording which of the two a row holds, and the two are not on the same scale: for the same result,rank_absoluteis worse by the number of SERP features above it.That matters because positions are compared across rows.
rankTrackingResults.tsbuildspreviousPositionsfrom the snapshot before the view's cutoff date, and falls back to the earliest snapshot when there is none:So it isn't a one-off blip on the first check after upgrading. Any view whose comparison window reaches back before the switch shows a delta computed across two different definitions, and for keywords whose history starts before the switch the earliest-snapshot fallback keeps a
rank_absolutebaseline indefinitely. The user sees an improvement of roughly the number of SERP features above their result — a local pack plus PAA is easily two to four positions of movement that never happened.Change
Add a nullable
position_basiscolumn torank_snapshotsand set it when writing.nullmeans the pre-#429rank_absolute, resolved in exactly one place:Both writers go through
mapResultsToSnapshotRowsinrankCheckPaths.ts, so one line covers the live and queued paths.No backfill, deliberately. The raw SERP items aren't stored — only the feature type list — so
rank_groupcannot be recomputed for existing rows. Recording "unknown" is honest; guessing would put wrong numbers into a series users read as history.No behaviour change. Nothing reads the column yet. That's the point of splitting it out: every check written without the column produces another row whose meaning can only be inferred from its timestamp later, so capturing it is worth doing before the part that needs a decision.
Follow-up, not in this PR
The fix that uses this is to reject a comparison snapshot whose basis differs from the current one, so
previousPositioncomes backnulland no delta is shown. That path already exists —createEmptyDeviceResult(null)andtoDeviceResult(snapshot, null)handle "no history" today — so it needs no new UI.I left it out because it's a behaviour change and you may want it presented differently, for instance an explicit hint rather than silently no delta. Happy to open it once you've said which you'd prefer.
One design question I'd rather ask than assume:
rank_check_runsis arguably the tidier home, since a run is homogeneous — one code version produced it. I put the column on the snapshot because the three read paths (getLatestSnapshotsForKeywords,getSnapshotsBeforeDate,getEarliestSnapshotsForKeywords) all return snapshot rows and a run-level field would need three joins. Say the word if you'd rather have it on the run.Verification
ALTER TABLE rank_snapshots ADD position_basis text;for D1 and the Postgres equivalent. No rewrite, no default backfill.schema-parity.test.ts(120 cases) passes, covering the two dialects.src/shared/rank-tracking.test.tspinning that new writes arerank_group, that a missing or unrecognised basis reads back asrank_absolute, and that a stored basis passes through.pnpm ci:checkpasses. Fullpnpm test:ci: 772 passed.Verified and tested by hand. The description was drafted with AI assistance — I'd rather let the diff do the talking than a long write-up.