feat: 내구성 있는 read model 변화 저널 기반을 추가
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user