feat: 내구성 있는 read model 변화 저널 기반을 추가

This commit is contained in:
2026-08-16 18:01:22 +00:00
parent aa51f1b7d9
commit 86733b99b7
16 changed files with 653 additions and 4 deletions
+1
View File
@@ -18,6 +18,7 @@ export * from './turnDaemon/types.js';
export * from './realtime/keys.js';
export * from './realtime/types.js';
export * from './realtime/delta.js';
export * from './realtime/changeJournal.js';
export * from './ranking/types.js';
export * from './ranking/legacyColor.js';
export * from './auth/accountIconProjection.js';
@@ -0,0 +1,155 @@
export const READ_MODEL_DOMAINS = [
'general.content',
'city.content',
'nation.content',
'world.content',
'map.world',
'map.general',
'records.general',
'records.global',
'records.history',
'front.general',
'front.nation',
'front.global',
'access.general',
'lobby.world',
'lobby.general',
'contacts.world',
'reserved.general',
'tournament',
'betting',
] as const;
export type ReadModelDomain = (typeof READ_MODEL_DOMAINS)[number];
export interface ReadModelRevisionKey {
domain: ReadModelDomain;
entityId: number;
}
export interface CommittedReadModelRevision extends ReadModelRevisionKey {
revision: bigint;
}
/**
* Internal, post-commit invalidation contract. Entity IDs and durable
* revisions are intentionally retained here for server-side projection and
* viewer filtering; this value must not cross the public SSE boundary.
*/
export interface CommittedReadModelInvalidation {
revisions: readonly CommittedReadModelRevision[];
}
export const READ_MODEL_OUTBOX_PAYLOAD_VERSION = 1 as const;
export type ReadModelOutboxChange = readonly [domain: ReadModelDomain, entityId: number, revision: string];
export interface ReadModelOutboxPayloadV1 {
version: typeof READ_MODEL_OUTBOX_PAYLOAD_VERSION;
changes: readonly ReadModelOutboxChange[];
}
const READ_MODEL_DOMAIN_SET: ReadonlySet<string> = new Set(READ_MODEL_DOMAINS);
export const isReadModelDomain = (value: string): value is ReadModelDomain => READ_MODEL_DOMAIN_SET.has(value);
const assertRevisionKey = (key: ReadModelRevisionKey): void => {
if (!isReadModelDomain(key.domain)) {
throw new TypeError(`Unknown read-model domain: ${String(key.domain)}`);
}
if (!Number.isSafeInteger(key.entityId) || key.entityId < 0) {
throw new RangeError(`Read-model entity ID must be a non-negative safe integer: ${String(key.entityId)}`);
}
};
const compareRevisionKeys = (left: ReadModelRevisionKey, right: ReadModelRevisionKey): number => {
const domainOrder = left.domain < right.domain ? -1 : left.domain > right.domain ? 1 : 0;
return domainOrder === 0 ? left.entityId - right.entityId : domainOrder;
};
/**
* Dedupe and sort keys before acquiring revision row locks. Stable lock order
* is part of the transaction contract for concurrent writers.
*/
export const normalizeReadModelRevisionKeys = (
keys: Iterable<ReadModelRevisionKey>
): readonly ReadModelRevisionKey[] => {
const byDomain = new Map<ReadModelDomain, Set<number>>();
for (const key of keys) {
assertRevisionKey(key);
const entityIds = byDomain.get(key.domain) ?? new Set<number>();
entityIds.add(key.entityId);
byDomain.set(key.domain, entityIds);
}
const normalized: ReadModelRevisionKey[] = [];
for (const [domain, entityIds] of byDomain) {
for (const entityId of entityIds) {
normalized.push({ domain, entityId });
}
}
return normalized.sort(compareRevisionKeys);
};
export const createReadModelOutboxPayload = (
invalidation: CommittedReadModelInvalidation
): ReadModelOutboxPayloadV1 => ({
version: READ_MODEL_OUTBOX_PAYLOAD_VERSION,
changes: [...invalidation.revisions]
.sort(compareRevisionKeys)
.map(({ domain, entityId, revision }) => [domain, entityId, revision.toString()] as const),
});
/** Mutable transaction-local collector. It has no I/O and exposes snapshots. */
export class ChangeJournal {
readonly #keys = new Map<ReadModelDomain, Set<number>>();
mark(domain: ReadModelDomain, entityId = 0): this {
return this.markKey({ domain, entityId });
}
markKey(key: ReadModelRevisionKey): this {
assertRevisionKey(key);
const entityIds = this.#keys.get(key.domain) ?? new Set<number>();
entityIds.add(key.entityId);
this.#keys.set(key.domain, entityIds);
return this;
}
markAll(keys: Iterable<ReadModelRevisionKey>): this {
for (const key of keys) {
this.markKey(key);
}
return this;
}
merge(other: ChangeJournal): this {
return this.markAll(other.snapshot());
}
get size(): number {
let size = 0;
for (const entityIds of this.#keys.values()) {
size += entityIds.size;
}
return size;
}
get isEmpty(): boolean {
return this.size === 0;
}
snapshot(): readonly ReadModelRevisionKey[] {
const keys: ReadModelRevisionKey[] = [];
for (const [domain, entityIds] of this.#keys) {
for (const entityId of entityIds) {
keys.push({ domain, entityId });
}
}
return normalizeReadModelRevisionKeys(keys);
}
clear(): void {
this.#keys.clear();
}
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import {
ChangeJournal,
createReadModelOutboxPayload,
normalizeReadModelRevisionKeys,
READ_MODEL_DOMAINS,
type ReadModelDomain,
} from '../src/realtime/changeJournal.js';
describe('ChangeJournal', () => {
it('keeps viewer-filtering semantics as distinct durable domains', () => {
expect(READ_MODEL_DOMAINS).toEqual(
expect.arrayContaining(['map.general', 'lobby.general', 'contacts.world', 'reserved.general'])
);
});
it('dedupes keys and returns them in a stable lock order', () => {
const journal = new ChangeJournal()
.mark('nation.content', 4)
.mark('general.content', 9)
.mark('nation.content', 4)
.mark('general.content', 2)
.mark('map.world');
expect(journal.size).toBe(4);
expect(journal.snapshot()).toEqual([
{ domain: 'general.content', entityId: 2 },
{ domain: 'general.content', entityId: 9 },
{ domain: 'map.world', entityId: 0 },
{ domain: 'nation.content', entityId: 4 },
]);
});
it('merges another journal without sharing mutable state', () => {
const source = new ChangeJournal().mark('records.general', 7);
const destination = new ChangeJournal().mark('records.global').merge(source);
source.clear();
expect(source.isEmpty).toBe(true);
expect(destination.snapshot()).toEqual([
{ domain: 'records.general', entityId: 7 },
{ domain: 'records.global', entityId: 0 },
]);
});
it('rejects unknown domains and unstable entity IDs at runtime', () => {
expect(() => new ChangeJournal().mark('unknown' as ReadModelDomain, 1)).toThrow(TypeError);
expect(() => new ChangeJournal().mark('general.content', -1)).toThrow(RangeError);
expect(() => new ChangeJournal().mark('general.content', Number.MAX_SAFE_INTEGER + 1)).toThrow(RangeError);
});
});
describe('durable read-model invalidation contract', () => {
it('normalizes an arbitrary iterable independently of the collector', () => {
expect(
normalizeReadModelRevisionKeys([
{ domain: 'front.nation', entityId: 3 },
{ domain: 'front.global', entityId: 0 },
{ domain: 'front.nation', entityId: 3 },
])
).toEqual([
{ domain: 'front.global', entityId: 0 },
{ domain: 'front.nation', entityId: 3 },
]);
});
it('serializes bigint revisions to a compact JSON-safe payload', () => {
const payload = createReadModelOutboxPayload({
revisions: [
{ domain: 'world.content', entityId: 0, revision: 12n },
{ domain: 'general.content', entityId: 7, revision: 4n },
],
});
expect(payload).toEqual({
version: 1,
changes: [
['general.content', 7, '4'],
['world.content', 0, '12'],
],
});
expect(() => JSON.stringify(payload)).not.toThrow();
});
});