feat: 직접 writer를 내구성 변화 저널에 연결

86개 API mutation을 분류하고 메시지 mailbox, 베팅, 국가 설정, 예약 명령의 revision/outbox 표식을 소유 transaction에 연결한다. 공개 SSE는 식별자 없는 invalidation만 노출한다.
This commit is contained in:
2026-08-16 18:38:08 +00:00
parent e4ac2a60b1
commit dd4645e4d0
22 changed files with 485 additions and 55 deletions
+13 -8
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import {
readModelOutboxPayloadToChanges,
readModelOutboxPayloadToMessageMailboxes,
type ReadModelDomain,
} from '@sammo-ts/common';
import {
@@ -11,13 +12,14 @@ import {
type RedisConnector,
} from '@sammo-ts/infra';
import { publishRealtimeReadModelChanges } from './publisher.js';
import { publishRealtimeMessageChanges, publishRealtimeReadModelChanges } from './publisher.js';
// access.general is an authoritative DB-only source revision. Tournament and
// betting still have separate Redis-owned source revisions. None of the three
// should wake the legacy dashboard channel solely because its outbox row ran.
// Source-only/access keys and separately owned tournament/betting state must
// not wake the legacy dashboard channel solely because an outbox row ran.
const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
'access.general',
'dashboard.global',
'messages.mailbox',
'tournament',
'betting',
]);
@@ -83,11 +85,14 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
const result = await dispatchReadModelOutboxBatch(
this.db,
async (payload) => {
if (payload.changes.every(([domain]) => NON_DASHBOARD_DOMAINS.has(domain))) {
return;
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
if (mailboxes.length > 0) {
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
}
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
const changes = readModelOutboxPayloadToChanges(payload);
await publishRealtimeReadModelChanges(this.redis, this.profileName, changes);
}
const changes = readModelOutboxPayloadToChanges(payload);
await publishRealtimeReadModelChanges(this.redis, this.profileName, changes);
},
{
owner: this.owner,
+3 -2
View File
@@ -62,8 +62,9 @@ export const toPublicRealtimeEvent = (
const viewers = uniqueIdentities(
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
);
if (event.type === 'messageCreated') {
return viewers.some((identity) => isMailboxRelevant(event.mailbox, identity))
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
? { type: 'messagesInvalidated' }
: null;
}
+12
View File
@@ -30,3 +30,15 @@ export const publishRealtimeReadModelChanges = async (
});
return revision;
};
export const publishRealtimeMessageChanges = async (
redis: RedisConnector['client'],
profileName: string,
mailboxes: readonly number[]
): Promise<void> => {
if (mailboxes.length === 0) return;
await publishRealtimeEvent(redis, profileName, {
type: 'messagesChanged',
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
});
};