Skip to content

Commit 3108e4b

Browse files
Ayush7614giswqs
andauthored
fix(catalog): merge Source Cooperative product records field-by-field so no enrichment is lost (#1738)
* fix(catalog): merge product records field-by-field so no enrichment is lost mergeProducts preferred whichever duplicate record was richer, but then replaced the whole record with a spread of both. When the two catalog sources enrich different fields (the feed adds a description, the /products/* call adds tags), the whole-object overwrite silently dropped whichever field the later record left empty. Merge tags and description per-field, keeping whichever record actually carries each one, so a merged entry always preserves both enrichments regardless of the order the sources are merged in. * fix(catalog): keep featured flag and first-seen fields when merging product records The field-by-field merge introduced in this PR regressed 'Featured' products: fetchCatalog merges [featured, recent], and a feed record (featured: false) landing second clobbered the API record's featured: true for any product in both lists, dropping the Featured badge and ranking boost. It also let a thinner feed record overwrite the first-seen title/url/updatedAt. Per-field merge now: tags/description prefer whichever record has them, featured is true if either source flags it, and everything else keeps the first-seen (API) record. Adds regression tests. * fix(catalog): prefer first-seen description and tags when both sides have values Fill empty description/tags from the later record, but keep the first-seen value when both are non-empty so feed text cannot replace the API record. Tighten the URL regression test and cover the both-non-empty case. * Address Claude review feedback - Rewrite the stale `mergeProducts` doc comment: it still described the old "prefer the richer record" whole-object overwrite, which no longer matches the per-field merge (empty description/tags filled from a later record, `featured` OR-ed, everything else first-seen wins). - Document the fill-only precedence as a deliberate scope limit, noting why it is safe for the sole caller (`fetchCatalog` merges `featured` before `recent`, and the feed contributes no tags) and flagging it for anyone who reuses the function with two sources that can each populate the same field. * Address CodeRabbit review feedback - Scope the `mergeProducts` doc comment's "later value is dropped" rule explicitly to `description` and `tags`, and note that `featured` is exempt (a later `true` always wins via the OR). The previous wording read as if the rule applied to every field, contradicting the featured semantics described one sentence earlier. - Fix singular/plural agreement now that the sentence covers two fields. --------- Co-authored-by: giswqs <giswqs@gmail.com>
1 parent 65e22f6 commit 3108e4b

2 files changed

Lines changed: 83 additions & 9 deletions

File tree

packages/plugins/src/plugins/source-coop-api.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -464,22 +464,39 @@ export function filterProducts(products: SourceCoopProduct[], query: string): So
464464
.map((entry) => entry.product);
465465
}
466466

467-
/** Merges catalog sources, preferring the richer record for a duplicate id. */
467+
/**
468+
* Merges catalog sources field-by-field for a duplicate id: empty `description`
469+
* and `tags` values are filled from a later record, `featured` is true if any
470+
* source flags it, and every other field keeps the first-seen value. The fill is
471+
* deliberately one-way for `description` and `tags` only — when both sources
472+
* carry a non-empty value for either, the later one is dropped rather than
473+
* unioned (`featured` is unaffected; a later `true` always wins). That is safe
474+
* for the only caller (`fetchCatalog` merges `featured` before `recent`, and the
475+
* feed never contributes tags) but is worth revisiting before reusing this with
476+
* two sources that can each populate the same field differently.
477+
*/
468478
export function mergeProducts(...groups: SourceCoopProduct[][]): SourceCoopProduct[] {
469479
const byId = new Map<string, SourceCoopProduct>();
470480
for (const group of groups) {
471481
for (const product of group) {
472482
const id = `${product.accountId}/${product.productId}`;
473483
const existing = byId.get(id);
474-
// The feed carries no tags, `/products/*` does; prefer whichever record
475-
// actually has them so a merged entry never loses searchable text.
476-
if (
477-
!existing ||
478-
(existing.tags.length === 0 && product.tags.length > 0) ||
479-
(!existing.description && product.description)
480-
) {
481-
byId.set(id, { ...existing, ...product });
484+
if (!existing) {
485+
byId.set(id, product);
486+
continue;
482487
}
488+
// The feed carries no tags, `/products/*` does; the two sources can
489+
// enrich different fields (one adds tags, the other a description). Fill
490+
// only empty fields from the later record so neither enrichment is lost,
491+
// and keep first-seen values when both sides already have one — matching
492+
// title/url/updatedAt. Featured is true if either source flags it, so a
493+
// feed record (`featured: false`) never un-flags an API featured product.
494+
byId.set(id, {
495+
...existing,
496+
description: existing.description || product.description,
497+
tags: existing.tags.length > 0 ? existing.tags : product.tags,
498+
featured: existing.featured || product.featured,
499+
});
483500
}
484501
}
485502
return [...byId.values()];

tests/source-coop-api.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,10 +499,67 @@ describe("mergeProducts", () => {
499499
assert.equal(merged[0].description, "Full");
500500
});
501501

502+
it("keeps both enrichments when the two records add different fields", () => {
503+
const apiEntry = product({ tags: ["pmtiles"], description: "" });
504+
const feedEntry = product({ tags: [], description: "A full description" });
505+
const merged = mergeProducts([apiEntry], [feedEntry]);
506+
assert.deepEqual(merged[0].tags, ["pmtiles"]);
507+
assert.equal(merged[0].description, "A full description");
508+
});
509+
510+
it("keeps both enrichments regardless of source order", () => {
511+
const apiEntry = product({ tags: ["pmtiles"], description: "" });
512+
const feedEntry = product({ tags: [], description: "A full description" });
513+
const merged = mergeProducts([feedEntry], [apiEntry]);
514+
assert.deepEqual(merged[0].tags, ["pmtiles"]);
515+
assert.equal(merged[0].description, "A full description");
516+
});
517+
502518
it("keeps distinct ids apart", () => {
503519
const merged = mergeProducts([product({ productId: "a" })], [product({ productId: "b" })]);
504520
assert.equal(merged.length, 2);
505521
});
522+
523+
it("keeps the featured flag when a feed record merges in second", () => {
524+
const apiEntry = product({ tags: ["pmtiles"], description: "Full", featured: true });
525+
const feedEntry = product({ tags: [], description: "" });
526+
const merged = mergeProducts([apiEntry], [feedEntry]);
527+
assert.equal(merged.length, 1);
528+
assert.equal(merged[0].featured, true);
529+
assert.deepEqual(merged[0].tags, ["pmtiles"]);
530+
assert.equal(merged[0].description, "Full");
531+
});
532+
533+
it("keeps the first-seen title and url when a thinner record merges in second", () => {
534+
const apiEntry = product({ title: "From API", tags: ["pmtiles"] });
535+
const feedEntry = product({
536+
title: "From feed",
537+
tags: [],
538+
url: "https://source.coop/acme/feed-buildings",
539+
});
540+
const merged = mergeProducts([apiEntry], [feedEntry]);
541+
assert.equal(merged[0].title, "From API");
542+
assert.equal(merged[0].url, "https://source.coop/acme/buildings");
543+
});
544+
545+
it("keeps the first-seen description and tags when both sides are non-empty", () => {
546+
const first = product({
547+
title: "First",
548+
description: "API description",
549+
tags: ["pmtiles", "vector"],
550+
});
551+
const second = product({
552+
title: "Second",
553+
description: "Feed description",
554+
tags: ["geojson"],
555+
url: "https://source.coop/acme/other",
556+
});
557+
const merged = mergeProducts([first], [second]);
558+
assert.equal(merged[0].description, "API description");
559+
assert.deepEqual(merged[0].tags, ["pmtiles", "vector"]);
560+
assert.equal(merged[0].title, "First");
561+
assert.equal(merged[0].url, "https://source.coop/acme/buildings");
562+
});
506563
});
507564

508565
describe("fetchProduct", () => {

0 commit comments

Comments
 (0)