fix(catalog-backend): use shared NULL_SENTINEL constant in dedup keys

Move NULL_SENTINEL to util.ts and import it in buildEntitySearch and
syncSearchRows instead of hardcoding '\x01'. Keeps the dedup keys
consistent with filterSentinelValues and the SQL COALESCE(…, chr(1))
logic, avoiding drift if the sentinel ever changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-05-12 09:19:23 +02:00
parent 50b5cbd226
commit 0305dc2c1e
3 changed files with 12 additions and 11 deletions
@@ -17,6 +17,7 @@
import { DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model';
import { InputError } from '@backstage/errors';
import { DbSearchRow } from '../../tables';
import { NULL_SENTINEL } from './util';
// These are excluded in the generic loop, either because they do not make sense
// to index, or because they are special-case always inserted whether they are
@@ -235,7 +236,7 @@ export function buildEntitySearch(
// violate the unique constraint on (entity_id, key, value).
const seen = new Set<string>();
return rows.filter(row => {
const k = `${row.key}\0${row.value === null ? '\x01' : row.value}`;
const k = `${row.key}\0${row.value === null ? NULL_SENTINEL : row.value}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
@@ -16,15 +16,7 @@
import { Knex } from 'knex';
import { DbSearchRow } from '../../tables';
import { BATCH_SIZE } from './util';
// The Postgres sync uses COALESCE(x, NULL_SENTINEL) to allow Postgres to
// include nullable columns in the Hash Cond of anti-joins (IS NOT DISTINCT
// FROM prevents this). As a consequence, values that are exactly this
// sentinel character are not searchable — they would be treated as NULL.
// This is the SOH (Start of Heading) control character which does not
// appear in real entity metadata.
const NULL_SENTINEL = '\x01';
import { BATCH_SIZE, NULL_SENTINEL } from './util';
function filterSentinelValues(entries: DbSearchRow[]): DbSearchRow[] {
return entries.filter(
@@ -55,7 +47,9 @@ export async function syncSearchRows(
// consistently pick the same original_value for a given input order.
const dedupMap = new Map<string, DbSearchRow>();
for (const entry of searchEntries) {
const k = `${entry.key}\0${entry.value === null ? '\x01' : entry.value}`;
const k = `${entry.key}\0${
entry.value === null ? NULL_SENTINEL : entry.value
}`;
if (!dedupMap.has(k)) {
dedupMap.set(k, entry);
}
@@ -24,6 +24,12 @@ import stableStringify from 'fast-json-stable-stringify';
// enough to get the speed benefits.
export const BATCH_SIZE = 50;
// The SOH (Start of Heading) control character, used as a stand-in for NULL
// in contexts where NULL cannot participate in equality comparisons (SQL
// COALESCE, JS dedup keys). It cannot appear in real entity metadata values
// since they are human-readable strings.
export const NULL_SENTINEL = '\x01';
export function generateStableHash(entity: Entity) {
return createHash('sha1')
.update(stableStringify({ ...entity }))