From c3350953e52189716e02e473bfb0d573da20c3fc Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:41:14 -0400 Subject: [PATCH] Re-hearing a hash-only channel with a nil fingerprint updates its row (#284) --- .github/workflows/ci.yml | 2 +- db/channels.go | 15 +++ db/channels_hash_only_integration_test.go | 91 +++++++++++++++++++ db/channels_test.go | 25 ++++- ...e.upsert_channel_hash_only_named.auto.json | 4 + ...e.upsert_channel_hash_only_named.auto.json | 4 + db/planguard/corpus.yaml | 5 + db/queries/queries.sql | 14 ++- db/sqlc/mock/querier.go | 15 +++ db/sqlc/querier.go | 7 +- db/sqlc/queries.sql.go | 34 ++++++- 11 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 db/channels_hash_only_integration_test.go create mode 100644 db/planguard/baseline/fixture/sm.write.upsert_channel_hash_only_named.auto.json create mode 100644 db/planguard/baseline/local/sm.write.upsert_channel_hash_only_named.auto.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c07719e..fde2a01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: - name: Verify stats and endpoint queries against PostgreSQL 16 env: BEACON_TEST_POSTGRES_DSN: postgres://postgres:backup-ci-only@127.0.0.1:5432/postgres?sslmode=disable - run: go test ./db -run '^Test(Signal|Paths|PacketSummaries|PacketEndpointsResolveLive|ObserverMetrics|RouteEvidence|RouteEvidenceIndex|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches|MeshMapperCatalogue|ChannelMessageScopes|NodeLocationReset|RunMigrations[A-Za-z]*|KnownRoutesIndexSet|PacketsDomainChecks|Reconfirm[A-Za-z]*|BigintCursors|RouteSearchHopOrder|StatsNodeTypesSplit|ListKnownRoutes|ListKnownRoutesMatchesLegacy|KnownRoutesCursorTies|KnownRoutesUnanalyzedRegion|KnownRoutesHopStatementsStayCustom|GlobalHopBranchesMatchToday|KnownRoutePaginationTies|RouteRetentionCutoffsExact|DeleteExpiredRoutes|DeleteGraceRoutesSlice|GraceWatermarkHeldByLockedRow|OpenPoolsSettings|PlanRouterClassCUnnamed|PlanRouterParallelWorkers|APIStoreHopStatementsUnnamed|PgStatStatementsMigrationGuarded|DBStatements|DBStatementsNoExtension|PgStatStatementsMigrationNoCreatableSchema|IndexHealthBaselineOnReindex|UnusedIndexes|PgActivity|RollHourSetsWorkMem|RollupWritesKeyOrder|SmallTableIndexSet|NodeReadersAfterIndexDrops|ClockDriftWideValues|ObserverChannelReadersAfterIndexDrops|UpsertChannelWriteThrough|UpsertChannelHashOnlyWriteThrough|ReadOnlyPoolsRejectWrites|ListPacketsGlobalMatchesLegacy|PacketsGlobalCursorTies|ListPacketsWindowInvertedRows|PacketsLatestObserverTie|NodesLegacyCursorMatchesToday|NodesPageCursorTies|ObserversLegacyCursorMatchesToday|ObserversNextCursorFromLastSeen|ObserverAdvertsPartialIndex|ObserverComparisonMatchesToday|AdvertsWaitForPayloadTypeBackfill|StatsProbeMatchesToday|TraceTagsKeysetMatchesToday|TelemetrySinceMatchesCatchAll|TraceTagPagination|TraceTagPaginationTies|TraceSummarySurvivesMissedHearing|CmiMigrationsIndexSet|InsertChannelMessageSeed[A-Za-z]*|AddChannelMessageIATA|CmiSeedAndAddRace|CmiInitAndBackfill|CmiMessageRangeReconcile|CmiObservationRangeReconcile|CmiBackfillRetries23503|WaitForPreBootWriters|ChannelMessagesByIATAsMatchesLegacy|ChannelMessagesZeroHitRegion|CmiTrustLifecycle|CmiFirstVerification|PacketSitesMigrationsIndexSet|InsertObservationMaintainsPacketSites|InsertObservationResendWritesNothing|PacketSitesRaceMaxMerge|InsertObservationSiteFallback|PacketSitesInitAndBackfill|FillPacketSitesSliceIdempotent|FillPacketSitesSliceRetries23503|PacketSitesReconcile|PacketSitesAudit|PacketSitesAuditTakesNewestHearing|PacketsBySitesMatchesTruth|PacketsBySitesCursorTies|PacketsBySitesManyBogusSites|PacketsBySitesSaturatedPagesFull|PacketsBySitesMatchesLegacy|PacketSitesTrustLifecycle|PacketsBySitesBounded|PacketsBySitesFallbackKeepsPageCursor|SweepPacketSitesFrontsPlan|IndexHealthPacketSitesFreshBaseline|PacketSitesFirstVerification|PacketSitesBackfillSlicesSparseIDs|PacketSitesBackfillResumesAcrossGaps|PacketSitesProgressCountsRows|PacketSitesAuditSamplesRows|PacketSitesVerifySamplesRows|PacedBackfillSlices|FillSessionsPlanCustom|FillAnalyzeFailureIsIgnored|FillAnalyzeOnlyNeverAnalyzed|HygieneIndexSet|HygieneReadersAfterIndexDrops|KnownRoutesColumns|SequenceUse|SlowPoolIsolation|SlowPoolSize|SequenceUseMixedCaseSchema|ObservationsIndexSet|ReadersAfterIndexDrops)Postgres$' -count=1 -v + run: go test ./db -run '^Test(Signal|Paths|PacketSummaries|PacketEndpointsResolveLive|ObserverMetrics|RouteEvidence|RouteEvidenceIndex|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches|MeshMapperCatalogue|ChannelMessageScopes|NodeLocationReset|RunMigrations[A-Za-z]*|KnownRoutesIndexSet|PacketsDomainChecks|Reconfirm[A-Za-z]*|BigintCursors|RouteSearchHopOrder|StatsNodeTypesSplit|ListKnownRoutes|ListKnownRoutesMatchesLegacy|KnownRoutesCursorTies|KnownRoutesUnanalyzedRegion|KnownRoutesHopStatementsStayCustom|GlobalHopBranchesMatchToday|KnownRoutePaginationTies|RouteRetentionCutoffsExact|DeleteExpiredRoutes|DeleteGraceRoutesSlice|GraceWatermarkHeldByLockedRow|OpenPoolsSettings|PlanRouterClassCUnnamed|PlanRouterParallelWorkers|APIStoreHopStatementsUnnamed|PgStatStatementsMigrationGuarded|DBStatements|DBStatementsNoExtension|PgStatStatementsMigrationNoCreatableSchema|IndexHealthBaselineOnReindex|UnusedIndexes|PgActivity|RollHourSetsWorkMem|RollupWritesKeyOrder|SmallTableIndexSet|NodeReadersAfterIndexDrops|ClockDriftWideValues|ObserverChannelReadersAfterIndexDrops|UpsertChannelWriteThrough|UpsertChannelHashOnlyWriteThrough|ChannelHashOnlyRehear|ReadOnlyPoolsRejectWrites|ListPacketsGlobalMatchesLegacy|PacketsGlobalCursorTies|ListPacketsWindowInvertedRows|PacketsLatestObserverTie|NodesLegacyCursorMatchesToday|NodesPageCursorTies|ObserversLegacyCursorMatchesToday|ObserversNextCursorFromLastSeen|ObserverAdvertsPartialIndex|ObserverComparisonMatchesToday|AdvertsWaitForPayloadTypeBackfill|StatsProbeMatchesToday|TraceTagsKeysetMatchesToday|TelemetrySinceMatchesCatchAll|TraceTagPagination|TraceTagPaginationTies|TraceSummarySurvivesMissedHearing|CmiMigrationsIndexSet|InsertChannelMessageSeed[A-Za-z]*|AddChannelMessageIATA|CmiSeedAndAddRace|CmiInitAndBackfill|CmiMessageRangeReconcile|CmiObservationRangeReconcile|CmiBackfillRetries23503|WaitForPreBootWriters|ChannelMessagesByIATAsMatchesLegacy|ChannelMessagesZeroHitRegion|CmiTrustLifecycle|CmiFirstVerification|PacketSitesMigrationsIndexSet|InsertObservationMaintainsPacketSites|InsertObservationResendWritesNothing|PacketSitesRaceMaxMerge|InsertObservationSiteFallback|PacketSitesInitAndBackfill|FillPacketSitesSliceIdempotent|FillPacketSitesSliceRetries23503|PacketSitesReconcile|PacketSitesAudit|PacketSitesAuditTakesNewestHearing|PacketsBySitesMatchesTruth|PacketsBySitesCursorTies|PacketsBySitesManyBogusSites|PacketsBySitesSaturatedPagesFull|PacketsBySitesMatchesLegacy|PacketSitesTrustLifecycle|PacketsBySitesBounded|PacketsBySitesFallbackKeepsPageCursor|SweepPacketSitesFrontsPlan|IndexHealthPacketSitesFreshBaseline|PacketSitesFirstVerification|PacketSitesBackfillSlicesSparseIDs|PacketSitesBackfillResumesAcrossGaps|PacketSitesProgressCountsRows|PacketSitesAuditSamplesRows|PacketSitesVerifySamplesRows|PacedBackfillSlices|FillSessionsPlanCustom|FillAnalyzeFailureIsIgnored|FillAnalyzeOnlyNeverAnalyzed|HygieneIndexSet|HygieneReadersAfterIndexDrops|KnownRoutesColumns|SequenceUse|SlowPoolIsolation|SlowPoolSize|SequenceUseMixedCaseSchema|ObservationsIndexSet|ReadersAfterIndexDrops)Postgres$' -count=1 -v - name: Verify the channel message site rule against PostgreSQL 16 env: diff --git a/db/channels.go b/db/channels.go index 7924777..54d6e4d 100644 --- a/db/channels.go +++ b/db/channels.go @@ -30,6 +30,21 @@ func (s *Store) UpsertChannel(ctx context.Context, channelHash []byte, keyFinger if hashtag != "" { hashtagPtr = &hashtag } + if keyFingerprint == nil { + // The keyed statement's (hash, fingerprint) conflict target never matches a NULL fingerprint, so a second + // hearing would hit the hash-only unique index; use the hash-only statements. + if namePtr == nil && hashtagPtr == nil { + return s.UpsertChannelHashOnly(ctx, channelHash) + } + isHashtag := hashtag != "" + id, err := s.q.UpsertChannelHashOnlyNamed(ctx, sqlc.UpsertChannelHashOnlyNamedParams{ + ChannelHash: channelHash, Name: namePtr, Hashtag: hashtagPtr, IsHashtag: &isHashtag, + }) + if err != nil { + return 0, err + } + return int(id), nil + } cached := s.channelIDs != nil && keyFingerprint != nil key := channelKey{hash: string(channelHash), fp: string(keyFingerprint)} if cached { diff --git a/db/channels_hash_only_integration_test.go b/db/channels_hash_only_integration_test.go new file mode 100644 index 0000000..63aaa6f --- /dev/null +++ b/db/channels_hash_only_integration_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package db + +import ( + "testing" +) + +// A hash-only channel (key unknown) heard again, through either upsert and with or without the id cache, updates its +// row: no error and no second row. With the id cache (as ingest runs), no sequence value is burned once the row exists. +func TestChannelHashOnlyRehearPostgres(t *testing.T) { + ctx, pool := schemaPool(t) + if err := RunMigrations(ctx, pool); err != nil { + t.Fatal(err) + } + seq := func() int64 { + t.Helper() + var v int64 + if err := pool.QueryRow(ctx, `SELECT last_value FROM channels_id_seq`).Scan(&v); err != nil { + t.Fatal(err) + } + return v + } + rows := func(hash byte) (n int, ids []int64) { + t.Helper() + if err := pool.QueryRow(ctx, `SELECT count(*), coalesce(array_agg(id ORDER BY id), '{}') FROM channels + WHERE channel_hash = $1 AND key_fingerprint IS NULL`, []byte{hash}).Scan(&n, &ids); err != nil { + t.Fatal(err) + } + return n, ids + } + for _, c := range []struct { + name string + cache bool + }{{"no cache", false}, {"id cache", true}} { + t.Run(c.name, func(t *testing.T) { + s := New(pool, pool, 0, 0) + if c.cache { + s = New(pool, pool, 0, 0, WithChannelIDs(NewChannelIDCache())) + } + hash := byte(0x40) + if c.cache { + hash = 0x41 + } + first, err := s.UpsertChannelHashOnly(ctx, []byte{hash}) + if err != nil { + t.Fatal(err) + } + seq0 := seq() + for i := 0; i < 3; i++ { + if id, err := s.UpsertChannelHashOnly(ctx, []byte{hash}); err != nil || id != first { + t.Fatalf("UpsertChannelHashOnly again: id %d err %v, want %d", id, err, first) + } + if id, err := s.UpsertChannel(ctx, []byte{hash}, nil, "", ""); err != nil || id != first { + t.Fatalf("UpsertChannel with a nil fingerprint: id %d err %v, want %d", id, err, first) + } + } + if n, ids := rows(hash); n != 1 || ids[0] != int64(first) { + t.Errorf("hash-only rows %d %v, want one, id %d", n, ids, first) + } + if d := seq() - seq0; c.cache && d != 0 { + t.Errorf("re-hearing a known hash-only channel burned %d sequence values", d) + } + }) + } + + // Heard with a name and hashtag: the same row, the name kept as UpsertChannel keeps it. + s := New(pool, pool, 0, 0, WithChannelIDs(NewChannelIDCache())) + first, err := s.UpsertChannel(ctx, []byte{0x42}, nil, "named", "#tag") + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"", "renamed", ""} { + if id, err := s.UpsertChannel(ctx, []byte{0x42}, nil, name, "#tag"); err != nil || id != first { + t.Fatalf("named hash-only again (%q): id %d err %v, want %d", name, id, err, first) + } + } + var name, hashtag string + var isHashtag, keyKnown bool + if err := pool.QueryRow(ctx, `SELECT name, hashtag, is_hashtag, key_known FROM channels WHERE id = $1`, first). + Scan(&name, &hashtag, &isHashtag, &keyKnown); err != nil { + t.Fatal(err) + } + if name != "renamed" || hashtag != "#tag" || !isHashtag || keyKnown { + t.Errorf("named hash-only row: %q %q %v %v, want renamed #tag true false", name, hashtag, isHashtag, keyKnown) + } + if n, _ := rows(0x42); n != 1 { + t.Errorf("named hash-only rows %d, want 1", n) + } +} diff --git a/db/channels_test.go b/db/channels_test.go index 2687ed2..8e1e51c 100644 --- a/db/channels_test.go +++ b/db/channels_test.go @@ -456,8 +456,13 @@ func TestUpsertChannelNilFingerprintBypassesCache(t *testing.T) { cache := NewChannelIDCache() store := &Store{q: mock, channelIDs: cache} - mock.EXPECT().UpsertChannel(gomock.Any(), upsertParams([]byte{0x11}, nil, "pub", "")). - Return(sqlc.Channel{ID: 5}, nil).Times(2) + // A name or hashtag goes to the hash-only conflict target on every hearing, keeping the name; the keyed statement, + // whose (hash, fingerprint) target never matches a NULL fingerprint, is never used. + pub := "pub" + isHashtag := false + mock.EXPECT().UpsertChannelHashOnlyNamed(gomock.Any(), sqlc.UpsertChannelHashOnlyNamedParams{ + ChannelHash: []byte{0x11}, Name: &pub, IsHashtag: &isHashtag}).Return(int32(5), nil).Times(2) + mock.EXPECT().UpsertChannel(gomock.Any(), gomock.Any()).Times(0) mock.EXPECT().TouchChannel(gomock.Any(), gomock.Any()).Times(0) mock.EXPECT().TouchChannelHashOnly(gomock.Any(), gomock.Any()).Times(0) @@ -472,6 +477,22 @@ func TestUpsertChannelNilFingerprintBypassesCache(t *testing.T) { } } +// Without a name or hashtag, a nil fingerprint is a hash-only hearing: the hash-only upsert, then its write-through. +func TestUpsertChannelNilFingerprintIsHashOnly(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + store := &Store{q: mock, channelIDs: NewChannelIDCache()} + mock.EXPECT().UpsertChannelHashOnly(gomock.Any(), []byte{0x12}).Return(int32(8), nil).Times(1) + mock.EXPECT().TouchChannelHashOnly(gomock.Any(), sqlc.TouchChannelHashOnlyParams{ID: 8, ChannelHash: []byte{0x12}}). + Return(int32(8), nil).Times(1) + mock.EXPECT().UpsertChannel(gomock.Any(), gomock.Any()).Times(0) + for i := 0; i < 2; i++ { + if id, err := store.UpsertChannel(context.Background(), []byte{0x12}, nil, "", ""); err != nil || id != 8 { + t.Fatalf("call %d: %d, %v; want 8", i, id, err) + } + } +} + func TestUpsertChannelWithoutCacheUnchanged(t *testing.T) { ctrl := gomock.NewController(t) mock := mockdb.NewMockQuerier(ctrl) diff --git a/db/planguard/baseline/fixture/sm.write.upsert_channel_hash_only_named.auto.json b/db/planguard/baseline/fixture/sm.write.upsert_channel_hash_only_named.auto.json new file mode 100644 index 0000000..219dbf6 --- /dev/null +++ b/db/planguard/baseline/fixture/sm.write.upsert_channel_hash_only_named.auto.json @@ -0,0 +1,4 @@ +[ + "ModifyTable on channels", + " Result" +] diff --git a/db/planguard/baseline/local/sm.write.upsert_channel_hash_only_named.auto.json b/db/planguard/baseline/local/sm.write.upsert_channel_hash_only_named.auto.json new file mode 100644 index 0000000..219dbf6 --- /dev/null +++ b/db/planguard/baseline/local/sm.write.upsert_channel_hash_only_named.auto.json @@ -0,0 +1,4 @@ +[ + "ModifyTable on channels", + " Result" +] diff --git a/db/planguard/corpus.yaml b/db/planguard/corpus.yaml index 3b8b786..7a5e078 100644 --- a/db/planguard/corpus.yaml +++ b/db/planguard/corpus.yaml @@ -84,6 +84,7 @@ statements: DeleteExpiredRoutes: {sqlc: DeleteExpiredRoutes, kind: dml, plan: maint} DeleteGraceRoutesSlice: {sqlc: DeleteGraceRoutesSlice, kind: dml, plan: maint} UpsertChannel: {sqlc: UpsertChannel, kind: dml, plan: ingest} + UpsertChannelHashOnlyNamed: {sqlc: UpsertChannelHashOnlyNamed, kind: dml, plan: ingest} UpsertNodeIATA: {sqlc: UpsertNodeIATA, kind: dml, plan: ingest} TouchChannel: {sqlc: TouchChannel, kind: dml, plan: ingest} TouchChannelHashOnly: {sqlc: TouchChannelHashOnly, kind: dml, plan: ingest} @@ -3013,6 +3014,10 @@ entries: stmt: UpsertChannel shape: "UpsertChannel fallback for an unknown key (plain EXPLAIN; ingest)" params: ["\\x0102", null, x, null, false] + - id: sm.write.upsert_channel_hash_only_named + stmt: UpsertChannelHashOnlyNamed + shape: "UpsertChannel for a hash-only channel heard with a name (plain EXPLAIN; ingest)" + params: ["\\x0102", x, null, false] - id: sm.write.upsert_node_iata stmt: UpsertNodeIATA shape: "UpsertNodeIATA on the swapped node_iatas index set (plain EXPLAIN; ingest)" diff --git a/db/queries/queries.sql b/db/queries/queries.sql index f9ac85e..a760308 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -1530,8 +1530,8 @@ ON CONFLICT (node_id, iata) DO NOTHING; -- ============================================================ -- name: UpsertChannel :one --- Upsert a channel by (hash, key_fingerprint). Pass NULL fingerprint for --- hash-only records (key unknown). Returns the channel row. +-- Upsert a keyed channel by (hash, key_fingerprint). Returns the channel row. A NULL fingerprint never matches that key, +-- so the store sends hash-only records (key unknown) to UpsertChannelHashOnly or UpsertChannelHashOnlyNamed. INSERT INTO channels (channel_hash, key_fingerprint, name, hashtag, is_hashtag, key_known, last_seen) VALUES ($1, $2::bytea, $3, $4, $5, ($2 IS NOT NULL), NOW()) ON CONFLICT (channel_hash, key_fingerprint) DO UPDATE SET @@ -1546,6 +1546,16 @@ ON CONFLICT (channel_hash) WHERE key_fingerprint IS NULL DO UPDATE SET last_seen = NOW() RETURNING id; +-- name: UpsertChannelHashOnlyNamed :one +-- UpsertChannel for a hash-only channel heard with a name or hashtag: the hash-only conflict target, and +-- UpsertChannel's insert and conflict update. +INSERT INTO channels (channel_hash, name, hashtag, is_hashtag, key_known, last_seen) +VALUES (sqlc.arg(channel_hash), sqlc.narg(name), sqlc.narg(hashtag), sqlc.arg(is_hashtag), false, NOW()) +ON CONFLICT (channel_hash) WHERE key_fingerprint IS NULL DO UPDATE SET + last_seen = NOW(), + name = COALESCE(EXCLUDED.name, channels.name) +RETURNING id; + -- name: ListChannelIDs :many -- Every channel key and its id, preloaded at boot for the ingest write-through. SELECT id, channel_hash, key_fingerprint FROM channels; diff --git a/db/sqlc/mock/querier.go b/db/sqlc/mock/querier.go index 12c3f04..a1ec13a 100644 --- a/db/sqlc/mock/querier.go +++ b/db/sqlc/mock/querier.go @@ -3514,6 +3514,21 @@ func (mr *MockQuerierMockRecorder) UpsertChannelHashOnly(ctx, channelHash any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChannelHashOnly", reflect.TypeOf((*MockQuerier)(nil).UpsertChannelHashOnly), ctx, channelHash) } +// UpsertChannelHashOnlyNamed mocks base method. +func (m *MockQuerier) UpsertChannelHashOnlyNamed(ctx context.Context, arg db.UpsertChannelHashOnlyNamedParams) (int32, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChannelHashOnlyNamed", ctx, arg) + ret0, _ := ret[0].(int32) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertChannelHashOnlyNamed indicates an expected call of UpsertChannelHashOnlyNamed. +func (mr *MockQuerierMockRecorder) UpsertChannelHashOnlyNamed(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChannelHashOnlyNamed", reflect.TypeOf((*MockQuerier)(nil).UpsertChannelHashOnlyNamed), ctx, arg) +} + // UpsertChannelIATA mocks base method. func (m *MockQuerier) UpsertChannelIATA(ctx context.Context, arg db.UpsertChannelIATAParams) error { m.ctrl.T.Helper() diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index cc91357..e97c90b 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -581,10 +581,13 @@ type Querier interface { // ============================================================ // CHANNELS // ============================================================ - // Upsert a channel by (hash, key_fingerprint). Pass NULL fingerprint for - // hash-only records (key unknown). Returns the channel row. + // Upsert a keyed channel by (hash, key_fingerprint). Returns the channel row. A NULL fingerprint never matches that key, + // so the store sends hash-only records (key unknown) to UpsertChannelHashOnly or UpsertChannelHashOnlyNamed. UpsertChannel(ctx context.Context, arg UpsertChannelParams) (Channel, error) UpsertChannelHashOnly(ctx context.Context, channelHash []byte) (int32, error) + // UpsertChannel for a hash-only channel heard with a name or hashtag: the hash-only conflict target, and + // UpsertChannel's insert and conflict update. + UpsertChannelHashOnlyNamed(ctx context.Context, arg UpsertChannelHashOnlyNamedParams) (int32, error) // Refreshes at most hourly so repeat hears don't churn the row. UpsertChannelIATA(ctx context.Context, arg UpsertChannelIATAParams) error // Copyright 2026 Beacon Contributors diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index d0703c9..3be4c10 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -7610,8 +7610,8 @@ type UpsertChannelParams struct { // ============================================================ // CHANNELS // ============================================================ -// Upsert a channel by (hash, key_fingerprint). Pass NULL fingerprint for -// hash-only records (key unknown). Returns the channel row. +// Upsert a keyed channel by (hash, key_fingerprint). Returns the channel row. A NULL fingerprint never matches that key, +// so the store sends hash-only records (key unknown) to UpsertChannelHashOnly or UpsertChannelHashOnlyNamed. func (q *Queries) UpsertChannel(ctx context.Context, arg UpsertChannelParams) (Channel, error) { row := q.db.QueryRow(ctx, upsertChannel, arg.ChannelHash, @@ -7652,6 +7652,36 @@ func (q *Queries) UpsertChannelHashOnly(ctx context.Context, channelHash []byte) return id, err } +const upsertChannelHashOnlyNamed = `-- name: UpsertChannelHashOnlyNamed :one +INSERT INTO channels (channel_hash, name, hashtag, is_hashtag, key_known, last_seen) +VALUES ($1, $2, $3, $4, false, NOW()) +ON CONFLICT (channel_hash) WHERE key_fingerprint IS NULL DO UPDATE SET + last_seen = NOW(), + name = COALESCE(EXCLUDED.name, channels.name) +RETURNING id +` + +type UpsertChannelHashOnlyNamedParams struct { + ChannelHash []byte `json:"channel_hash"` + Name *string `json:"name"` + Hashtag *string `json:"hashtag"` + IsHashtag *bool `json:"is_hashtag"` +} + +// UpsertChannel for a hash-only channel heard with a name or hashtag: the hash-only conflict target, and +// UpsertChannel's insert and conflict update. +func (q *Queries) UpsertChannelHashOnlyNamed(ctx context.Context, arg UpsertChannelHashOnlyNamedParams) (int32, error) { + row := q.db.QueryRow(ctx, upsertChannelHashOnlyNamed, + arg.ChannelHash, + arg.Name, + arg.Hashtag, + arg.IsHashtag, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + const upsertChannelIATA = `-- name: UpsertChannelIATA :exec INSERT INTO channel_iatas (channel_hash, iata, last_heard) VALUES ($1, $2, $3)