fix(push): preserve baseline policies at lease capacity

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-18 17:22:30 -07:00
parent 63dfcc6821
commit 6540fbdeb4
3 changed files with 88 additions and 20 deletions

View File

@@ -54,6 +54,12 @@ push capability. Supporting mobile clients add a single self-scoped
for the first activation. They do not enumerate or synchronize thread follows.
Old relays retain the existing mention/DM policy.
The existing lease capacity limits still apply. If the authored rule cannot
represent all channel mutes (more than 350) or no subscription slot remains,
the client omits that rule while continuing to synchronize mention/DM policies.
It never truncates mute exclusions or abandons the whole update. The authored
rule returns when capacity is available again.
The authenticated `/query` bridge accepts the same predicate and derives the
recipient from the authenticated identity. Historical query filtering and batch
wake matching share the database root-author relation, including community,

View File

@@ -528,26 +528,6 @@ List<BuzzPushSubscription> buildDesiredBuzzPushSubscriptions({
),
];
if (authoredThreadReplies) {
final muted = normalizedMuted.toList()..sort();
subscriptions.add(
BuzzPushSubscription(
filter: BuzzPushFilter(
kinds: buzzPushEligibleKinds,
authoredThreadReplies: true,
since: authoredThreadRepliesSince,
),
notificationClass: 'default',
ignore: [
selfAuthored,
for (final chunk in _chunks(muted, buzzPushChannelChunkSize))
BuzzPushFilter(kinds: buzzPushEligibleKinds, hTags: chunk),
],
suppress: suppression,
),
);
}
final channels = normalizedChannelIds.difference(normalizedMuted).toList()
..sort();
for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) {
@@ -563,6 +543,32 @@ List<BuzzPushSubscription> buildDesiredBuzzPushSubscriptions({
if (subscriptions.length > buzzPushMaxSubscriptions) {
throw const FormatException('Too many channels for a push lease.');
}
// Existing relay limits may leave no room for a fully muted authored rule.
// Omit that rule rather than dropping exclusions or aborting mention/DM sync.
final muted = normalizedMuted.toList()..sort();
final authoredMuteChunks = _chunks(muted, buzzPushChannelChunkSize);
if (authoredThreadReplies &&
authoredMuteChunks.length + 1 <= buzzPushMaxIgnoreFilters &&
subscriptions.length < buzzPushMaxSubscriptions) {
subscriptions.insert(
1,
BuzzPushSubscription(
filter: BuzzPushFilter(
kinds: buzzPushEligibleKinds,
authoredThreadReplies: true,
since: authoredThreadRepliesSince,
),
notificationClass: 'default',
ignore: [
selfAuthored,
for (final chunk in authoredMuteChunks)
BuzzPushFilter(kinds: buzzPushEligibleKinds, hTags: chunk),
],
suppress: suppression,
),
);
}
return List.unmodifiable(subscriptions);
}

View File

@@ -8,6 +8,62 @@ void main() {
const channelA = '123e4567-e89b-42d3-a456-426614174000';
const channelB = '123e4567-e89b-42d3-a456-426614174001';
List<String> channels(int count) => [
for (var i = 0; i < count; i++)
'00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}',
];
test(
'authored mute capacity preserves every exclusion or omits the rule',
() {
final mutes = channels(351);
final atLimit = buildDesiredBuzzPushSubscriptions(
myPubkey: me,
authoredThreadReplies: true,
mutedChannelIds: mutes.take(350),
).singleWhere((s) => s.filter.authoredThreadReplies);
expect(atLimit.ignore, hasLength(buzzPushMaxIgnoreFilters));
expect(atLimit.ignore.skip(1).expand((f) => f.hTags!), mutes.take(350));
final overLimit = buildDesiredBuzzPushSubscriptions(
myPubkey: me,
channelIds: [channelA],
authoredThreadReplies: true,
mutedChannelIds: mutes,
);
final baseline = buildDesiredBuzzPushSubscriptions(
myPubkey: me,
channelIds: [channelA],
mutedChannelIds: mutes,
);
expect(
buzzPushSubscriptionsFingerprint(overLimit),
buzzPushSubscriptionsFingerprint(baseline),
);
expect(overLimit.last.filter.hTags, [channelA]);
},
);
test('authored rule never consumes a required DM subscription slot', () {
final dms = channels(750);
final withRoom = buildDesiredBuzzPushSubscriptions(
myPubkey: me,
channelIds: dms.take(700),
authoredThreadReplies: true,
);
expect(withRoom, hasLength(buzzPushMaxSubscriptions));
expect(withRoom.any((s) => s.filter.authoredThreadReplies), isTrue);
final full = buildDesiredBuzzPushSubscriptions(
myPubkey: me,
channelIds: dms,
authoredThreadReplies: true,
);
expect(full, hasLength(buzzPushMaxSubscriptions));
expect(full.any((s) => s.filter.authoredThreadReplies), isFalse);
expect(full.skip(1).expand((s) => s.filter.hTags!), dms);
});
test(
'authored replies opt in without enumerating roots and honor all mutes',
() {