@@ -139,42 +139,98 @@ export async function upsertAdvisoryPackage(
139139 return row . id as number
140140}
141141
142- // Delete every advisory_affected_ranges row for this advisory_package that
143- // lacks deps.dev raw columns — i.e. every OSV-owned row. The deps.dev BQ
144- // worker (future) is expected to populate range_raw / unaffected_raw on rows
145- // of its own and never set the structured introduced/fixed/last_affected
146- // columns; this predicate scopes the wipe to the OSV pipeline's rows so a
147- // deps.dev row is never clobbered on resync. OSV rows where all three
148- // structured columns are NULL (e.g. some MAL- "always vulnerable" ranges)
149- // are still deleted here and re-inserted from the new payload below — that's
150- // fine, the row is OSV-owned and idempotent.
151- export async function deleteOsvOnlyRanges (
142+ // Diff-based upsert + soft-delete for OSV-owned advisory_affected_ranges rows.
143+ // Replaces the old hard-delete + reinsert sweep (which minted a new PK for
144+ // every unchanged range every sync, producing zombie rows once Sequin
145+ // replicates DELETEs into a Tinybird ReplacingMergeTree that has no
146+ // sign/is_deleted column — see ADR-0001 §`advisory_affected_ranges`
147+ // delete/dedup strategy, CM-1258).
148+ //
149+ // An advisory_package whose range set hasn't changed since the last sync now
150+ // produces zero writes: matching tuples are left untouched (not even an
151+ // `updated_at` bump), so no Sequin/Tinybird event fires for them either.
152+ //
153+ // Tuple in both old and new -> untouched. Tuple only in new -> upserted
154+ // (INSERT ON CONFLICT, clearing deleted_at in case it was previously
155+ // soft-deleted and OSV brought it back). Tuple only in old -> soft-deleted.
156+ // Soft-delete (not hard-delete) is required because the unique key is the
157+ // value tuple itself: when OSV corrects a range, the corrected tuple has no
158+ // successor row to collapse into, so without deleted_at the stale tuple
159+ // would sit forever as a false-positive vulnerable-range match.
160+ export async function reconcileOsvRanges (
152161 qx : QueryExecutor ,
153162 advisoryPackageId : number ,
163+ ranges : AdvisoryRangeInsertInput [ ] ,
154164) : Promise < void > {
165+ const values = ranges . map ( ( r ) => ( {
166+ introduced_version : r . introducedVersion ,
167+ fixed_version : r . fixedVersion ,
168+ last_affected : r . lastAffected ,
169+ } ) )
170+
155171 await qx . result (
156172 `
157- DELETE FROM advisory_affected_ranges
158- WHERE advisory_package_id = $(advisoryPackageId)
159- AND range_raw IS NULL
160- AND unaffected_raw IS NULL
173+ INSERT INTO advisory_affected_ranges
174+ (advisory_package_id, introduced_version, fixed_version, last_affected, created_at, updated_at)
175+ SELECT $(advisoryPackageId), v.introduced_version, v.fixed_version, v.last_affected, NOW(), NOW()
176+ FROM jsonb_to_recordset($(values)::jsonb) AS v(
177+ introduced_version text,
178+ fixed_version text,
179+ last_affected text
180+ )
181+ ON CONFLICT (advisory_package_id, COALESCE(introduced_version, ''), COALESCE(fixed_version, ''), COALESCE(last_affected, ''))
182+ DO UPDATE SET
183+ updated_at = NOW(),
184+ deleted_at = NULL
185+ WHERE advisory_affected_ranges.deleted_at IS NOT NULL
161186 ` ,
162- { advisoryPackageId } ,
187+ { advisoryPackageId, values : JSON . stringify ( values ) } ,
188+ )
189+
190+ // Soft-delete OSV-owned live rows whose tuple isn't in the new set anymore.
191+ await qx . result (
192+ `
193+ UPDATE advisory_affected_ranges ar
194+ SET deleted_at = NOW()
195+ WHERE ar.advisory_package_id = $(advisoryPackageId)
196+ AND ar.deleted_at IS NULL
197+ AND ar.range_raw IS NULL
198+ AND ar.unaffected_raw IS NULL
199+ AND NOT EXISTS (
200+ SELECT 1
201+ FROM jsonb_to_recordset($(values)::jsonb) AS v(
202+ introduced_version text,
203+ fixed_version text,
204+ last_affected text
205+ )
206+ WHERE COALESCE(v.introduced_version, '') = COALESCE(ar.introduced_version, '')
207+ AND COALESCE(v.fixed_version, '') = COALESCE(ar.fixed_version, '')
208+ AND COALESCE(v.last_affected, '') = COALESCE(ar.last_affected, '')
209+ )
210+ ` ,
211+ { advisoryPackageId, values : JSON . stringify ( values ) } ,
163212 )
164213}
165214
166- export async function insertAdvisoryRange (
215+ // OSV is the source of truth for ranges over time (see ADR-0001 §Write
216+ // semantics `advisories` row): once OSV has written its structured ranges
217+ // for an advisory_package, any live deps.dev raw row for that same
218+ // advisory_package is superseded and should stop being treated as current.
219+ // Soft-deleted (not hard-deleted) for the same reason as reconcileOsvRanges —
220+ // no successor row exists to collapse a deps.dev tuple into.
221+ export async function supersedeDepsDevRanges (
167222 qx : QueryExecutor ,
168- range : AdvisoryRangeInsertInput ,
223+ advisoryPackageId : number ,
169224) : Promise < void > {
170225 await qx . result (
171226 `
172- INSERT INTO advisory_affected_ranges
173- (advisory_package_id, introduced_version, fixed_version, last_affected, created_at, updated_at)
174- VALUES
175- ($(advisoryPackageId), $(introducedVersion), $(fixedVersion), $(lastAffected), NOW(), NOW())
227+ UPDATE advisory_affected_ranges
228+ SET deleted_at = NOW()
229+ WHERE advisory_package_id = $(advisoryPackageId)
230+ AND deleted_at IS NULL
231+ AND (range_raw IS NOT NULL OR unaffected_raw IS NOT NULL)
176232 ` ,
177- range ,
233+ { advisoryPackageId } ,
178234 )
179235}
180236
0 commit comments