mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): split channel history fetch by kind so limit buys visible depth
The GUI history filter requested the broad CHANNEL_EVENT_KINDS set (reactions, edits, deletions alongside messages), so a 200-event "limit" budget bought only visible-message depth diluted by aux events — measured 136 messages / 200 events on a reaction-heavy channel, deterministic across runs. Scrollback inherited the same dilution, and same-second events could shuffle position when merged from two REQs (history vs live-sub). Fix, per the invariant converged on in #buzz-bugs: 1. Base history filter = visible content kinds only (CHANNEL_TIMELINE_CONTENT_KINDS, mirroring isTimelineContentEvent), so the limit budget maps to visible rows. 2. Backfill reactions/edits/deletions by `#e` over the loaded message ids — by reference, not by time window, so a late edit/delete for a visible old message still applies (avoids the silent-stale-state trap). 3. Keep the broad-kind live subscription for future aux events; cold/scrollback correctness comes from the #e backfill, not the tail. 4. Sort GUI merges by (created_at, id) so same-second merges are deterministic — both the cache sort (sortMessages) and the history-resolve sort (sortEvents) carry the id tiebreaker, keeping the two on one invariant. Extracted the channel filter-builders into relayChannelFilters.ts to keep relayClientSession.ts under its size cap. Aux backfill lives in its own module (auxBackfill.ts) to avoid coupling useFetchOlderMessages to hooks.ts. Regression tests prove the reducer applies aux by `#e` reference regardless of timestamp (far-future edit rewrites an old body; far-future deletion hides an old message), plus sort order-independence for both sort functions. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
c488d8452c
commit
022edf3fc4
@@ -31,6 +31,7 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
|
||||
// Same .mjs the renderer uses, so the cache-update projection can't drift
|
||||
// from the on-render overlay.
|
||||
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
|
||||
import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill";
|
||||
import {
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
@@ -186,6 +187,10 @@ export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
history,
|
||||
);
|
||||
|
||||
// Paint messages immediately; backfill their reactions/edits/deletions
|
||||
// by `#e` in the background (it self-merges into the same cache key).
|
||||
void backfillAuxForMessages(queryClient, channel.id, history);
|
||||
|
||||
return mergedHistory;
|
||||
},
|
||||
staleTime: 5 * 60 * 1_000,
|
||||
@@ -211,6 +216,8 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
channelMessagesKey(channelId),
|
||||
(current = []) => mergeTimelineHistoryMessages(current, history),
|
||||
);
|
||||
|
||||
void backfillAuxForMessages(queryClient, channelId, history);
|
||||
});
|
||||
|
||||
const appendMessage = useEffectEvent((event: RelayEvent) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { collectMessageIdsForAuxBackfill } from "./auxBackfill.ts";
|
||||
|
||||
const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";
|
||||
|
||||
function event(id, kind) {
|
||||
return {
|
||||
id,
|
||||
pubkey: "a".repeat(64),
|
||||
kind,
|
||||
created_at: 1_700_000_000,
|
||||
content: "",
|
||||
tags: [["h", CHANNEL_ID]],
|
||||
sig: "sig",
|
||||
};
|
||||
}
|
||||
|
||||
function hex(char) {
|
||||
return char.repeat(64);
|
||||
}
|
||||
|
||||
test("collects content-kind message ids (stream, v2, diff, system, jobs)", () => {
|
||||
const events = [
|
||||
event(hex("1"), 9), // stream message
|
||||
event(hex("2"), 40002), // v2 stream message
|
||||
event(hex("3"), 40008), // diff (own row)
|
||||
event(hex("4"), 40099), // system message
|
||||
event(hex("5"), 43001), // job request
|
||||
];
|
||||
assert.deepEqual(collectMessageIdsForAuxBackfill(events), [
|
||||
hex("1"),
|
||||
hex("2"),
|
||||
hex("3"),
|
||||
hex("4"),
|
||||
hex("5"),
|
||||
]);
|
||||
});
|
||||
|
||||
test("excludes auxiliary kinds (reactions, edits, deletions)", () => {
|
||||
const events = [
|
||||
event(hex("1"), 9), // message — kept
|
||||
event(hex("2"), 7), // reaction — excluded
|
||||
event(hex("3"), 40003), // edit — excluded
|
||||
event(hex("4"), 5), // NIP-09 deletion — excluded
|
||||
event(hex("5"), 9005), // Buzz-native deletion — excluded
|
||||
];
|
||||
assert.deepEqual(collectMessageIdsForAuxBackfill(events), [hex("1")]);
|
||||
});
|
||||
|
||||
test("returns empty for a window of only auxiliary events", () => {
|
||||
const events = [event(hex("2"), 7), event(hex("3"), 40003)];
|
||||
assert.deepEqual(collectMessageIdsForAuxBackfill(events), []);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
channelMessagesKey,
|
||||
mergeTimelineHistoryMessages,
|
||||
} from "@/features/messages/lib/messageQueryKeys";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import { CHANNEL_TIMELINE_CONTENT_KINDS } from "@/shared/constants/kinds";
|
||||
|
||||
const TIMELINE_CONTENT_KINDS: ReadonlySet<number> = new Set(
|
||||
CHANNEL_TIMELINE_CONTENT_KINDS,
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract the ids of the visible content messages from a freshly-fetched
|
||||
* history window. Auxiliary events (reactions, edits, deletions) are then
|
||||
* backfilled by `#e` reference over exactly these ids. Pure so it can be
|
||||
* unit-tested without a relay or query client.
|
||||
*/
|
||||
export function collectMessageIdsForAuxBackfill(
|
||||
historyEvents: RelayEvent[],
|
||||
): string[] {
|
||||
return historyEvents
|
||||
.filter((event) => TIMELINE_CONTENT_KINDS.has(event.kind))
|
||||
.map((event) => event.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a content-kinds-only history fetch, pull the auxiliary events
|
||||
* (reactions, edits, deletions) that reference the loaded messages — keyed by
|
||||
* `#e` over their ids, not by a time window — and merge them into the same
|
||||
* channel cache.
|
||||
*
|
||||
* History fetches request content kinds only so the `limit` budget buys
|
||||
* visible message depth (a reaction-heavy 200-event window was only ~136
|
||||
* messages). The cost is that an edit/deletion for a visible message can fall
|
||||
* outside any fetched time window — so aux must be pulled by reference, or a
|
||||
* visible message renders stale (un-edited / not-deleted).
|
||||
*
|
||||
* Best-effort: failures are logged but never reject, so a flaky overlay fetch
|
||||
* can't blank the freshly-loaded messages.
|
||||
*/
|
||||
export async function backfillAuxForMessages(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
historyEvents: RelayEvent[],
|
||||
): Promise<void> {
|
||||
const messageIds = collectMessageIdsForAuxBackfill(historyEvents);
|
||||
if (messageIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const auxEvents = await relayClient.fetchAuxEventsForMessages(
|
||||
channelId,
|
||||
messageIds,
|
||||
);
|
||||
if (auxEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(channelId),
|
||||
(current = []) => mergeTimelineHistoryMessages(current, auxEvents),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to backfill auxiliary events for channel",
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,64 @@ function deletionEvent(kind, targetId, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function streamEdit(targetId, content, overrides = {}) {
|
||||
return {
|
||||
id: HEX64_B,
|
||||
pubkey: PUBKEY_A,
|
||||
kind: 40003,
|
||||
created_at: 1_700_000_001,
|
||||
content,
|
||||
tags: [
|
||||
["h", CHANNEL_ID],
|
||||
["e", targetId],
|
||||
],
|
||||
sig: "sig",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keystone regression: aux events (edits/deletions) apply by `#e` reference,
|
||||
// NOT by time-window overlap. This is the invariant the split-query +
|
||||
// `#e`-backfill fix depends on: an edit/deletion can be loaded long after the
|
||||
// message it targets — even with a far-future `created_at` — and must still
|
||||
// apply. If the reducer ever gated aux application on timestamp proximity, a
|
||||
// late edit/delete for a visible old message would silently render stale.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("a far-future edit still rewrites the body of an old message", () => {
|
||||
const old = streamMessage({ created_at: 1_700_000_000 });
|
||||
const lateEdit = streamEdit(HEX64_A, "edited body", {
|
||||
created_at: 1_900_000_000,
|
||||
});
|
||||
const out = formatTimelineMessages([old, lateEdit], null, undefined, null);
|
||||
assert.equal(out.length, 1, "the message should still render");
|
||||
assert.equal(
|
||||
out[0].body,
|
||||
"edited body",
|
||||
"the far-future edit must overlay the old message's body regardless of the time gap",
|
||||
);
|
||||
assert.equal(out[0].edited, true, "the message must be marked edited");
|
||||
});
|
||||
|
||||
test("a far-future deletion still hides an old message", () => {
|
||||
const old = streamMessage({ created_at: 1_700_000_000 });
|
||||
const lateDeletion = deletionEvent(9005, HEX64_A, {
|
||||
created_at: 1_900_000_000,
|
||||
});
|
||||
const out = formatTimelineMessages(
|
||||
[old, lateDeletion],
|
||||
null,
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
out.length,
|
||||
0,
|
||||
"the far-future deletion must filter out the old message regardless of the time gap",
|
||||
);
|
||||
});
|
||||
|
||||
test("kind:5 (NIP-09) deletion hides the target message", () => {
|
||||
const events = [streamMessage(), deletionEvent(5, HEX64_A)];
|
||||
const out = formatTimelineMessages(events, null, undefined, null);
|
||||
|
||||
@@ -202,3 +202,19 @@ test("timeline history and live cache merges retain the same visible content reg
|
||||
assert.equal(historyThenLiveContent[0], id("old", 201));
|
||||
assert.equal(historyThenLiveContent.at(-1), liveMessage.id);
|
||||
});
|
||||
|
||||
test("sortMessages tiebreaks same-second events on id, order-independent", () => {
|
||||
// Three events sharing one created_at, fed in two different input orders.
|
||||
// The (created_at, id) sort must produce the same sequence both ways, so a
|
||||
// history-then-live merge and a live-then-history merge can't shuffle a
|
||||
// same-second message to a different visible position.
|
||||
const a = event({ id: id("aaa", 1), createdAt: 5_000 });
|
||||
const b = event({ id: id("bbb", 1), createdAt: 5_000 });
|
||||
const c = event({ id: id("ccc", 1), createdAt: 5_000 });
|
||||
|
||||
const forward = normalizeTimelineMessages([a, b, c]).map((m) => m.id);
|
||||
const reverse = normalizeTimelineMessages([c, b, a]).map((m) => m.id);
|
||||
|
||||
assert.deepEqual(forward, reverse);
|
||||
assert.deepEqual(forward, [a.id, b.id, c.id]);
|
||||
});
|
||||
|
||||
@@ -37,9 +37,16 @@ export function dedupeMessagesById(messages: RelayEvent[]) {
|
||||
}
|
||||
|
||||
export function sortMessages(messages: RelayEvent[]) {
|
||||
return dedupeMessagesById(messages).sort(
|
||||
(left, right) => left.created_at - right.created_at,
|
||||
);
|
||||
return dedupeMessagesById(messages).sort((left, right) => {
|
||||
if (left.created_at !== right.created_at) {
|
||||
return left.created_at - right.created_at;
|
||||
}
|
||||
// Tiebreak same-second events on id so the merge order is deterministic.
|
||||
// Without this, two events sharing a created_at can land in a different
|
||||
// position depending on which REQ (history vs live-sub) delivered them
|
||||
// first — reading as a "missing"/shuffled message at a fixed scroll offset.
|
||||
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function isTimelineWindowContentEvent(event: RelayEvent) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill";
|
||||
import {
|
||||
channelMessagesKey,
|
||||
mergeTimelineHistoryMessages,
|
||||
@@ -90,6 +91,10 @@ export function useFetchOlderMessages(channel: Channel | null) {
|
||||
mergeTimelineHistoryMessages(current, olderMessages),
|
||||
);
|
||||
|
||||
// Backfill the older messages' reactions/edits/deletions by `#e`
|
||||
// (history is content-kinds only). Background — paint immediately.
|
||||
void backfillAuxForMessages(queryClient, channelId, olderMessages);
|
||||
|
||||
const updatedMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
CHANNEL_AUX_EVENT_KINDS,
|
||||
CHANNEL_EVENT_KINDS,
|
||||
CHANNEL_TIMELINE_CONTENT_KINDS,
|
||||
HOME_MENTION_EVENT_KINDS,
|
||||
} from "@/shared/constants/kinds";
|
||||
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
|
||||
|
||||
// Auxiliary-event backfill: `#e` filters reference loaded message ids to pull
|
||||
// their reactions/edits/deletions. Chunk the ids so each REQ stays within
|
||||
// relay filter limits, and let each chunk return up to the relay's WS cap —
|
||||
// a single reaction-heavy message can have many aux events.
|
||||
export const AUX_BACKFILL_CHUNK_SIZE = 100;
|
||||
export const MAX_HISTORICAL_LIMIT = 10_000;
|
||||
|
||||
/**
|
||||
* Live-subscription filter for an open channel: the broad
|
||||
* {@link CHANNEL_EVENT_KINDS} set so the tail delivers reactions/edits/
|
||||
* deletions for future messages as well as new message rows.
|
||||
*/
|
||||
export function buildChannelFilter(
|
||||
channelId: string,
|
||||
limit: number,
|
||||
until?: number,
|
||||
): RelaySubscriptionFilter {
|
||||
const filter: RelaySubscriptionFilter = {
|
||||
kinds: [...CHANNEL_EVENT_KINDS],
|
||||
"#h": [channelId],
|
||||
limit,
|
||||
};
|
||||
|
||||
if (until !== undefined) {
|
||||
filter.until = until;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* History filter for cold-load and scrollback: message kinds *only*, so the
|
||||
* `limit` budget buys visible message depth. Auxiliary events (reactions,
|
||||
* edits, deletions) are backfilled separately by `#e` reference via
|
||||
* {@link buildChannelAuxFilter}, and arrive for future messages through the
|
||||
* live subscription ({@link buildChannelFilter}, which keeps the broad
|
||||
* {@link CHANNEL_EVENT_KINDS} set).
|
||||
*/
|
||||
export function buildChannelHistoryFilter(
|
||||
channelId: string,
|
||||
limit: number,
|
||||
until?: number,
|
||||
): RelaySubscriptionFilter {
|
||||
const filter: RelaySubscriptionFilter = {
|
||||
kinds: [...CHANNEL_TIMELINE_CONTENT_KINDS],
|
||||
"#h": [channelId],
|
||||
limit,
|
||||
};
|
||||
|
||||
if (until !== undefined) {
|
||||
filter.until = until;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aux-backfill filter for one chunk of loaded message ids: pulls reactions/
|
||||
* edits/deletions ({@link CHANNEL_AUX_EVENT_KINDS}) that reference those ids
|
||||
* by `#e`. Keyed by reference, not time, so a late edit/deletion for an old
|
||||
* visible message still applies — see {@link buildChannelHistoryFilter}.
|
||||
*/
|
||||
export function buildChannelAuxFilter(
|
||||
channelId: string,
|
||||
messageIds: string[],
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [...CHANNEL_AUX_EVENT_KINDS],
|
||||
"#h": [channelId],
|
||||
"#e": messageIds,
|
||||
limit: MAX_HISTORICAL_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGlobalStreamFilter(
|
||||
limit: number,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [...CHANNEL_EVENT_KINDS],
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChannelMentionFilter(
|
||||
channelId: string,
|
||||
pubkey: string,
|
||||
limit: number,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [...HOME_MENTION_EVENT_KINDS],
|
||||
"#h": [channelId],
|
||||
"#p": [pubkey],
|
||||
limit,
|
||||
since: Math.floor(Date.now() / 1_000),
|
||||
};
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
} from "@/shared/api/tauri";
|
||||
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
CHANNEL_EVENT_KINDS,
|
||||
HOME_MENTION_EVENT_KINDS,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_TYPING_INDICATOR,
|
||||
KIND_USER_STATUS,
|
||||
@@ -21,6 +19,14 @@ import {
|
||||
type RelaySubscription,
|
||||
type RelaySubscriptionFilter,
|
||||
} from "@/shared/api/relayClientShared";
|
||||
import {
|
||||
AUX_BACKFILL_CHUNK_SIZE,
|
||||
buildChannelAuxFilter,
|
||||
buildChannelFilter,
|
||||
buildChannelHistoryFilter,
|
||||
buildChannelMentionFilter,
|
||||
buildGlobalStreamFilter,
|
||||
} from "@/shared/api/relayChannelFilters";
|
||||
import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay";
|
||||
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
|
||||
import {
|
||||
@@ -148,7 +154,7 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
async fetchChannelHistory(channelId: string, limit = 50) {
|
||||
return this.fetchHistory(this.buildChannelFilter(channelId, limit));
|
||||
return this.fetchHistory(buildChannelHistoryFilter(channelId, limit));
|
||||
}
|
||||
|
||||
async fetchChannelHistoryBefore(
|
||||
@@ -156,7 +162,48 @@ export class RelayClient {
|
||||
before: number,
|
||||
limit = 50,
|
||||
) {
|
||||
return this.fetchHistory(this.buildChannelFilter(channelId, limit, before));
|
||||
return this.fetchHistory(
|
||||
buildChannelHistoryFilter(channelId, limit, before),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the auxiliary events (reactions, edits, diffs, deletions) that
|
||||
* reference a set of already-loaded message ids, keyed by `#e` rather than a
|
||||
* time window.
|
||||
*
|
||||
* History fetches deliberately request message kinds only, so the `limit`
|
||||
* budget buys visible message depth instead of being diluted by aux events
|
||||
* (on a reaction-heavy channel a 200-event window was only ~136 messages).
|
||||
* The trade-off is that an edit/deletion for a visible message can fall
|
||||
* outside any message time window — so we must pull aux by reference, not by
|
||||
* time, or a visible message would render stale (un-edited / not-deleted).
|
||||
*
|
||||
* Batched: `#e` filters can grow large, so message ids are chunked to keep
|
||||
* each REQ within relay filter limits. Results across chunks are merged.
|
||||
*/
|
||||
async fetchAuxEventsForMessages(
|
||||
channelId: string,
|
||||
messageIds: string[],
|
||||
): Promise<RelayEvent[]> {
|
||||
if (messageIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await this.ensureConnected();
|
||||
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < messageIds.length; i += AUX_BACKFILL_CHUNK_SIZE) {
|
||||
chunks.push(messageIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
const batches = await Promise.all(
|
||||
chunks.map((ids) =>
|
||||
this.requestHistory(buildChannelAuxFilter(channelId, ids)),
|
||||
),
|
||||
);
|
||||
|
||||
return batches.flat();
|
||||
}
|
||||
|
||||
async fetchEvents(filter: RelaySubscriptionFilter): Promise<RelayEvent[]> {
|
||||
@@ -269,7 +316,7 @@ export class RelayClient {
|
||||
channelId: string,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
return this.subscribe(this.buildChannelFilter(channelId, 50), onEvent);
|
||||
return this.subscribe(buildChannelFilter(channelId, 50), onEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,7 +404,7 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) {
|
||||
return this.subscribe(this.buildGlobalStreamFilter(50), onEvent);
|
||||
return this.subscribe(buildGlobalStreamFilter(50), onEvent);
|
||||
}
|
||||
|
||||
async subscribeLive(
|
||||
@@ -373,7 +420,7 @@ export class RelayClient {
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
) {
|
||||
return this.subscribe(
|
||||
this.buildChannelMentionFilter(channelId, pubkey, 50),
|
||||
buildChannelMentionFilter(channelId, pubkey, 50),
|
||||
onEvent,
|
||||
);
|
||||
}
|
||||
@@ -487,45 +534,6 @@ export class RelayClient {
|
||||
this.emitReconnectIfNeeded();
|
||||
}
|
||||
|
||||
private buildChannelFilter(
|
||||
channelId: string,
|
||||
limit: number,
|
||||
until?: number,
|
||||
): RelaySubscriptionFilter {
|
||||
const filter: RelaySubscriptionFilter = {
|
||||
kinds: [...CHANNEL_EVENT_KINDS],
|
||||
"#h": [channelId],
|
||||
limit,
|
||||
};
|
||||
|
||||
if (until !== undefined) {
|
||||
filter.until = until;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
private buildGlobalStreamFilter(limit: number): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [...CHANNEL_EVENT_KINDS],
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
private buildChannelMentionFilter(
|
||||
channelId: string,
|
||||
pubkey: string,
|
||||
limit: number,
|
||||
): RelaySubscriptionFilter {
|
||||
return {
|
||||
kinds: [...HOME_MENTION_EVENT_KINDS],
|
||||
"#h": [channelId],
|
||||
"#p": [pubkey],
|
||||
limit,
|
||||
since: Math.floor(Date.now() / 1_000),
|
||||
};
|
||||
}
|
||||
|
||||
private async subscribe(
|
||||
filter: RelaySubscriptionFilter,
|
||||
onEvent: (event: RelayEvent) => void,
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isRelayConnectionDegraded } from "./relayClientShared.ts";
|
||||
import { isRelayConnectionDegraded, sortEvents } from "./relayClientShared.ts";
|
||||
|
||||
function event(id, createdAt) {
|
||||
return {
|
||||
id,
|
||||
pubkey: "pubkey",
|
||||
created_at: createdAt,
|
||||
kind: 9,
|
||||
tags: [],
|
||||
content: "",
|
||||
sig: "sig",
|
||||
};
|
||||
}
|
||||
|
||||
test("sortEvents — same-second events sort by id, order-independent", () => {
|
||||
const a = event("aaa", 100);
|
||||
const b = event("bbb", 100);
|
||||
const c = event("ccc", 101);
|
||||
|
||||
const forward = sortEvents([a, b, c]).map((e) => e.id);
|
||||
const shuffled = sortEvents([c, b, a]).map((e) => e.id);
|
||||
|
||||
// Stable (created_at, id) order regardless of input order, matching the
|
||||
// cache sort (sortMessages) and the relay's id-ASC same-second tiebreak.
|
||||
assert.deepEqual(forward, ["aaa", "bbb", "ccc"]);
|
||||
assert.deepEqual(shuffled, ["aaa", "bbb", "ccc"]);
|
||||
});
|
||||
|
||||
test("isRelayConnectionDegraded — healthy states are not degraded", () => {
|
||||
assert.equal(isRelayConnectionDegraded("idle"), false);
|
||||
|
||||
@@ -63,7 +63,16 @@ export type PendingEvent = {
|
||||
export type RelaySubscription = HistorySubscription | LiveSubscription;
|
||||
|
||||
export function sortEvents(events: RelayEvent[]) {
|
||||
return [...events].sort((left, right) => left.created_at - right.created_at);
|
||||
return [...events].sort((left, right) => {
|
||||
if (left.created_at !== right.created_at) {
|
||||
return left.created_at - right.created_at;
|
||||
}
|
||||
// Same (created_at, id) tiebreak as the cache sort (sortMessages) so a
|
||||
// history REQ resolves same-second events in a stable, relay-matching
|
||||
// order. Currently every consumer re-sorts downstream, but keeping the
|
||||
// two sorts on one invariant avoids a latent ordering drift.
|
||||
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function getTextPayload(message: unknown) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
buildReconnectReplayFilter,
|
||||
replayLiveSubscriptions,
|
||||
} from "./relayReconnectReplay.ts";
|
||||
import { RelayClient } from "./relayClientSession.ts";
|
||||
import { buildChannelFilter } from "./relayChannelFilters.ts";
|
||||
|
||||
function replayFilter(filter, since, until) {
|
||||
return buildReconnectReplayFilter(filter, since, until);
|
||||
@@ -111,8 +111,7 @@ test("channel reconnect replay pages the missed window until a short page", asyn
|
||||
eventRange("middle", 1002, 500),
|
||||
eventRange("oldest", 995, 8),
|
||||
];
|
||||
const client = new RelayClient();
|
||||
const filter = client.buildChannelFilter("channel-1", 50);
|
||||
const filter = buildChannelFilter("channel-1", 50);
|
||||
const subscriptions = new Map([
|
||||
[
|
||||
"live-1",
|
||||
|
||||
@@ -65,6 +65,41 @@ export const CHANNEL_EVENT_KINDS = [
|
||||
KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.)
|
||||
] as const;
|
||||
|
||||
// Auxiliary (non-row) timeline kinds: events that overlay onto or hide an
|
||||
// existing message rather than rendering their own row — reactions, edits, and
|
||||
// deletions. History fetches request the visible content kinds only, so the
|
||||
// `limit` budget buys visible message depth instead of being diluted by these
|
||||
// (on a reaction-heavy channel a 200-event window was only ~136 messages).
|
||||
// They are backfilled separately by `#e` reference over the loaded message ids
|
||||
// — by reference, not by time window, so a late edit/delete for a visible old
|
||||
// message still applies. NOTE: kind:40008 (diff) renders its OWN row, so it is
|
||||
// a content kind, not aux.
|
||||
export const CHANNEL_AUX_EVENT_KINDS = [
|
||||
KIND_DELETION, // 5 — NIP-09 event deletions
|
||||
KIND_REACTION, // 7 — NIP-25 reactions
|
||||
KIND_NIP29_DELETE_EVENT, // 9005 — NIP-29 / Buzz-native deletions
|
||||
KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits
|
||||
] as const;
|
||||
|
||||
// Visible content kinds the main timeline renders as their own rows. Mirrors
|
||||
// `isTimelineContentEvent` in formatTimelineMessages.ts — keep the two in sync.
|
||||
// This is the kind set the history fetch requests so the `limit` budget maps
|
||||
// to visible rows; auxiliary overlays (CHANNEL_AUX_EVENT_KINDS) are fetched
|
||||
// separately by `#e` reference. Forum kinds (45001/45003) are excluded: forum
|
||||
// channels use a different query path, not this timeline.
|
||||
export const CHANNEL_TIMELINE_CONTENT_KINDS = [
|
||||
KIND_STREAM_MESSAGE, // 9
|
||||
KIND_STREAM_MESSAGE_V2, // 40002
|
||||
KIND_STREAM_MESSAGE_DIFF, // 40008 — diff messages (own row)
|
||||
KIND_SYSTEM_MESSAGE, // 40099 — system rows (join/leave/channel-created)
|
||||
KIND_JOB_REQUEST, // 43001
|
||||
KIND_JOB_ACCEPTED, // 43002
|
||||
KIND_JOB_PROGRESS, // 43003
|
||||
KIND_JOB_RESULT, // 43004
|
||||
KIND_JOB_CANCEL, // 43005
|
||||
KIND_JOB_ERROR, // 43006
|
||||
] as const;
|
||||
|
||||
// Timeline kinds that are NOT conversational: relay-signed system rows
|
||||
// (channel-created, member-joined) and job-lifecycle events. These render in
|
||||
// the timeline but must not count toward the channel's unread pill — a freshly
|
||||
|
||||
Reference in New Issue
Block a user