fix: 모든 토너먼트 writer의 source revision을 원자화
API store뿐 아니라 월 자동 개막과 runtime clock shift도 공통 Lua writer를 사용한다. 프로필 초기화 시 revision key도 함께 제거한다.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { TournamentKeys } from './keys.js';
|
import type { TournamentKeys } from './keys.js';
|
||||||
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
|
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
|
||||||
@@ -18,31 +19,6 @@ interface RedisClientLike {
|
|||||||
publish?(channel: string, message: string): Promise<unknown>;
|
publish?(channel: string, message: string): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeWithSourceRevisionScript = `
|
|
||||||
local current = redis.call('GET', KEYS[2])
|
|
||||||
if current then
|
|
||||||
if not string.match(current, '^%d+$') then
|
|
||||||
return redis.error_reply('invalid tournament source revision')
|
|
||||||
end
|
|
||||||
if string.len(current) > 18 then
|
|
||||||
return redis.error_reply('tournament source revision exhausted')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
redis.call('SET', KEYS[1], ARGV[1])
|
|
||||||
local revision = redis.call('INCR', KEYS[2])
|
|
||||||
return tostring(revision)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const parseSourceRevision = (value: unknown): string | null => {
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
|
|
||||||
}
|
|
||||||
if (typeof value === 'bigint') {
|
|
||||||
return value >= 0n ? value.toString() : null;
|
|
||||||
}
|
|
||||||
return typeof value === 'string' && /^(?:0|[1-9]\d*)$/u.test(value) ? value : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const safeJsonParse = <T>(raw: string | null): T | null => {
|
const safeJsonParse = <T>(raw: string | null): T | null => {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
@@ -89,30 +65,11 @@ export class TournamentStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSourceRevision(): Promise<string | null> {
|
async getSourceRevision(): Promise<string | null> {
|
||||||
return parseSourceRevision(await this.redis.get(this.keys.sourceRevisionKey));
|
return parseTournamentSourceRevision(await this.redis.get(this.keys.sourceRevisionKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
|
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
|
||||||
const result = await this.redis.eval(writeWithSourceRevisionScript, {
|
return writeTournamentProjection(this.redis, this.keys, [{ key, value }]);
|
||||||
keys: [key, this.keys.sourceRevisionKey],
|
|
||||||
arguments: [JSON.stringify(value)],
|
|
||||||
});
|
|
||||||
const sourceRevision = parseSourceRevision(result);
|
|
||||||
if (sourceRevision === null) {
|
|
||||||
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.redis.publish) {
|
|
||||||
try {
|
|
||||||
await this.redis.publish(
|
|
||||||
this.keys.sourceRevisionChannel,
|
|
||||||
JSON.stringify({ sourceRevision })
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// State and revision are already committed atomically; publication is best effort.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sourceRevision;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setState(state: TournamentState): Promise<string> {
|
async setState(state: TournamentState): Promise<string> {
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ class MemoryRedis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
|
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
|
||||||
const [valueKey, revisionKey] = options.keys;
|
const revisionKey = options.keys.at(-1);
|
||||||
const [value] = options.arguments;
|
if (!revisionKey || options.keys.length !== options.arguments.length + 1) {
|
||||||
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
|
throw new Error('invalid eval arguments');
|
||||||
|
}
|
||||||
const revision = Number(this.store.get(revisionKey) ?? '0') + 1;
|
const revision = Number(this.store.get(revisionKey) ?? '0') + 1;
|
||||||
this.store.set(valueKey, value);
|
options.arguments.forEach((value, index) => this.store.set(options.keys[index]!, value));
|
||||||
this.store.set(revisionKey, String(revision));
|
this.store.set(revisionKey, String(revision));
|
||||||
return String(revision);
|
return String(revision);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
|
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
import { writeTournamentProjection, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
|
|
||||||
import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js';
|
import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js';
|
||||||
|
|
||||||
@@ -17,6 +17,8 @@ interface RuntimeRedisClient {
|
|||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
del(key: string): Promise<unknown>;
|
del(key: string): Promise<unknown>;
|
||||||
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
||||||
|
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||||
|
publish?(channel: string, message: string): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TournamentClockState = {
|
type TournamentClockState = {
|
||||||
@@ -71,6 +73,10 @@ const shiftTournamentClock = async (
|
|||||||
deltaMinutes: number
|
deltaMinutes: number
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const stateKey = `sammo:${profileName}:tournament:state`;
|
const stateKey = `sammo:${profileName}:tournament:state`;
|
||||||
|
const sourceKeys = {
|
||||||
|
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
||||||
|
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
||||||
|
};
|
||||||
const lockKey = `${stateKey}:mutation-lock`;
|
const lockKey = `${stateKey}:mutation-lock`;
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const deadline = Date.now() + 2_000;
|
const deadline = Date.now() + 2_000;
|
||||||
@@ -95,7 +101,7 @@ const shiftTournamentClock = async (
|
|||||||
bettingCloseAt: shiftDateText(state.bettingCloseAt, deltaMinutes) as string | undefined,
|
bettingCloseAt: shiftDateText(state.bettingCloseAt, deltaMinutes) as string | undefined,
|
||||||
runtimeClockShiftActionIds: [...applied, actionId],
|
runtimeClockShiftActionIds: [...applied, actionId],
|
||||||
};
|
};
|
||||||
await redis.set(stateKey, JSON.stringify(nextState));
|
await writeTournamentProjection(redis, sourceKeys, [{ key: stateKey, value: nextState }]);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
if ((await redis.get(lockKey)) === token) {
|
if ((await redis.get(lockKey)) === token) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { asRecord, LiteHashDRBG, RandUtil, writeTournamentProjection } from '@sammo-ts/common';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
@@ -73,6 +73,8 @@ export const createTournamentAutoStartHandler = (options: {
|
|||||||
participants: `sammo:${options.profileName}:tournament:participants`,
|
participants: `sammo:${options.profileName}:tournament:participants`,
|
||||||
matches: `sammo:${options.profileName}:tournament:matches`,
|
matches: `sammo:${options.profileName}:tournament:matches`,
|
||||||
betting: `sammo:${options.profileName}:tournament:betting`,
|
betting: `sammo:${options.profileName}:tournament:betting`,
|
||||||
|
sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`,
|
||||||
|
sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
onMonthChanged: async (context) => {
|
onMonthChanged: async (context) => {
|
||||||
@@ -139,10 +141,12 @@ export const createTournamentAutoStartHandler = (options: {
|
|||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
lastErrorAt: undefined,
|
lastErrorAt: undefined,
|
||||||
};
|
};
|
||||||
await redis.set(keys.participants, '[]');
|
await writeTournamentProjection(redis, keys, [
|
||||||
await redis.set(keys.matches, '[]');
|
{ key: keys.participants, value: [] },
|
||||||
await redis.set(keys.betting, '[]');
|
{ key: keys.matches, value: [] },
|
||||||
await redis.set(keys.state, JSON.stringify(nextState));
|
{ key: keys.betting, value: [] },
|
||||||
|
{ key: keys.state, value: nextState },
|
||||||
|
]);
|
||||||
|
|
||||||
const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0];
|
const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0];
|
||||||
const emperor = world
|
const emperor = world
|
||||||
|
|||||||
@@ -244,6 +244,13 @@ describe('runtime clock shift projection', () => {
|
|||||||
},
|
},
|
||||||
del: async (key: string) => (values.delete(key) ? 1 : 0),
|
del: async (key: string) => (values.delete(key) ? 1 : 0),
|
||||||
zAdd,
|
zAdd,
|
||||||
|
eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => {
|
||||||
|
const revisionKey = options.keys.at(-1)!;
|
||||||
|
options.arguments.forEach((value, index) => values.set(options.keys[index]!, value));
|
||||||
|
const revision = Number(values.get(revisionKey) ?? '0') + 1;
|
||||||
|
values.set(revisionKey, String(revision));
|
||||||
|
return String(revision);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const action = {
|
const action = {
|
||||||
id: actionId,
|
id: actionId,
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ describe('monthly tournament auto start', () => {
|
|||||||
values.set(key, value);
|
values.set(key, value);
|
||||||
return 'OK';
|
return 'OK';
|
||||||
},
|
},
|
||||||
|
eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => {
|
||||||
|
const revisionKey = options.keys.at(-1)!;
|
||||||
|
options.arguments.forEach((value, index) => values.set(options.keys[index]!, value));
|
||||||
|
const revision = Number(values.get(revisionKey) ?? '0') + 1;
|
||||||
|
values.set(revisionKey, String(revision));
|
||||||
|
return String(revision);
|
||||||
|
},
|
||||||
} as unknown as RedisConnector['client'];
|
} as unknown as RedisConnector['client'];
|
||||||
let world: InMemoryTurnWorld | null = null;
|
let world: InMemoryTurnWorld | null = null;
|
||||||
const consumed: boolean[] = [];
|
const consumed: boolean[] = [];
|
||||||
@@ -186,6 +193,13 @@ describe('monthly tournament auto start', () => {
|
|||||||
values.set(key, value);
|
values.set(key, value);
|
||||||
return 'OK';
|
return 'OK';
|
||||||
},
|
},
|
||||||
|
eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => {
|
||||||
|
const revisionKey = options.keys.at(-1)!;
|
||||||
|
options.arguments.forEach((value, index) => values.set(options.keys[index]!, value));
|
||||||
|
const revision = Number(values.get(revisionKey) ?? '0') + 1;
|
||||||
|
values.set(revisionKey, String(revision));
|
||||||
|
return String(revision);
|
||||||
|
},
|
||||||
} as unknown as RedisConnector['client'];
|
} as unknown as RedisConnector['client'];
|
||||||
let world: InMemoryTurnWorld | null = null;
|
let world: InMemoryTurnWorld | null = null;
|
||||||
world = new InMemoryTurnWorld(state, snapshot, {
|
world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export const buildTournamentRuntimeKeys = (profileName: string): string[] => [
|
|||||||
`sammo:${profileName}:tournament:participants`,
|
`sammo:${profileName}:tournament:participants`,
|
||||||
`sammo:${profileName}:tournament:matches`,
|
`sammo:${profileName}:tournament:matches`,
|
||||||
`sammo:${profileName}:tournament:betting`,
|
`sammo:${profileName}:tournament:betting`,
|
||||||
|
`sammo:${profileName}:tournament:source-revision`,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const clearTournamentRuntimeKeys = async (
|
export const clearTournamentRuntimeKeys = async (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('tournament reset state', () => {
|
|||||||
'sammo:che:1010:tournament:participants',
|
'sammo:che:1010:tournament:participants',
|
||||||
'sammo:che:1010:tournament:matches',
|
'sammo:che:1010:tournament:matches',
|
||||||
'sammo:che:1010:tournament:betting',
|
'sammo:che:1010:tournament:betting',
|
||||||
|
'sammo:che:1010:tournament:source-revision',
|
||||||
]);
|
]);
|
||||||
expect(buildTournamentRuntimeKeys('hwe:915')).not.toContain('sammo:che:1010:tournament:state');
|
expect(buildTournamentRuntimeKeys('hwe:915')).not.toContain('sammo:che:1010:tournament:state');
|
||||||
});
|
});
|
||||||
@@ -28,7 +29,7 @@ describe('tournament reset state', () => {
|
|||||||
'che:1010'
|
'che:1010'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(deleted).toBe(4);
|
expect(deleted).toBe(5);
|
||||||
expect(calls).toEqual([buildTournamentRuntimeKeys('che:1010')]);
|
expect(calls).toEqual([buildTournamentRuntimeKeys('che:1010')]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export * from './util/TournamentRNG.js';
|
|||||||
export * from './util/sha512.js';
|
export * from './util/sha512.js';
|
||||||
export * from './util/parse.js';
|
export * from './util/parse.js';
|
||||||
export * from './tournament/autoStart.js';
|
export * from './tournament/autoStart.js';
|
||||||
|
export * from './tournament/sourceRevision.js';
|
||||||
export * from './turnDaemon/types.js';
|
export * from './turnDaemon/types.js';
|
||||||
export * from './realtime/keys.js';
|
export * from './realtime/keys.js';
|
||||||
export * from './realtime/types.js';
|
export * from './realtime/types.js';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { asRecord } from '../util/parse.js';
|
import { asRecord } from '../util/parse.js';
|
||||||
|
import { writeTournamentProjection } from './sourceRevision.js';
|
||||||
|
|
||||||
interface TournamentState {
|
interface TournamentState {
|
||||||
stage: number;
|
stage: number;
|
||||||
@@ -21,7 +22,8 @@ interface TournamentState {
|
|||||||
|
|
||||||
interface RedisClientLike {
|
interface RedisClientLike {
|
||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
set(key: string, value: string): Promise<unknown>;
|
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||||
|
publish?(channel: string, message: string): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeJsonParse = <T>(raw: string | null): T | null => {
|
const safeJsonParse = <T>(raw: string | null): T | null => {
|
||||||
@@ -40,6 +42,8 @@ const buildTournamentKeys = (profileName: string) => ({
|
|||||||
participantsKey: `sammo:${profileName}:tournament:participants`,
|
participantsKey: `sammo:${profileName}:tournament:participants`,
|
||||||
matchesKey: `sammo:${profileName}:tournament:matches`,
|
matchesKey: `sammo:${profileName}:tournament:matches`,
|
||||||
bettingKey: `sammo:${profileName}:tournament:betting`,
|
bettingKey: `sammo:${profileName}:tournament:betting`,
|
||||||
|
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
||||||
|
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveTermSeconds = (tickSeconds: number): number => {
|
const resolveTermSeconds = (tickSeconds: number): number => {
|
||||||
@@ -99,10 +103,12 @@ export const createTournamentAutoStartHandler = (options: {
|
|||||||
lastErrorAt: undefined,
|
lastErrorAt: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await redis.set(keys.participantsKey, '[]');
|
await writeTournamentProjection(redis, keys, [
|
||||||
await redis.set(keys.matchesKey, '[]');
|
{ key: keys.participantsKey, value: [] },
|
||||||
await redis.set(keys.bettingKey, '[]');
|
{ key: keys.matchesKey, value: [] },
|
||||||
await redis.set(keys.stateKey, JSON.stringify(nextState));
|
{ key: keys.bettingKey, value: [] },
|
||||||
|
{ key: keys.stateKey, value: nextState },
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export interface TournamentSourceKeys {
|
||||||
|
sourceRevisionKey: string;
|
||||||
|
sourceRevisionChannel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentProjectionRedis {
|
||||||
|
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||||
|
publish?(channel: string, message: string): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentProjectionWrite {
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRITE_TOURNAMENT_PROJECTION_SCRIPT = `
|
||||||
|
local revision_key = KEYS[#KEYS]
|
||||||
|
local current = redis.call('GET', revision_key)
|
||||||
|
if current then
|
||||||
|
if not string.match(current, '^%d+$') then
|
||||||
|
return redis.error_reply('invalid tournament source revision')
|
||||||
|
end
|
||||||
|
if string.len(current) > 18 then
|
||||||
|
return redis.error_reply('tournament source revision exhausted')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for index = 1, #KEYS - 1 do
|
||||||
|
redis.call('SET', KEYS[index], ARGV[index])
|
||||||
|
end
|
||||||
|
local revision = redis.call('INCR', revision_key)
|
||||||
|
return tostring(revision)
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const parseTournamentSourceRevision = (value: unknown): string | null => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
|
||||||
|
}
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value >= 0n ? value.toString() : null;
|
||||||
|
}
|
||||||
|
return typeof value === 'string' && /^(?:0|[1-9]\d*)$/u.test(value) ? value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Atomically stores one or more tournament payloads and advances one profile head. */
|
||||||
|
export const writeTournamentProjection = async (
|
||||||
|
redis: TournamentProjectionRedis,
|
||||||
|
keys: TournamentSourceKeys,
|
||||||
|
writes: readonly TournamentProjectionWrite[]
|
||||||
|
): Promise<string> => {
|
||||||
|
if (writes.length === 0) {
|
||||||
|
throw new Error('Tournament projection write must contain at least one payload.');
|
||||||
|
}
|
||||||
|
if (new Set(writes.map(({ key }) => key)).size !== writes.length) {
|
||||||
|
throw new Error('Tournament projection write keys must be unique.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, {
|
||||||
|
keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey],
|
||||||
|
arguments: writes.map(({ value }) => JSON.stringify(value)),
|
||||||
|
});
|
||||||
|
const sourceRevision = parseTournamentSourceRevision(result);
|
||||||
|
if (sourceRevision === null) {
|
||||||
|
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redis.publish) {
|
||||||
|
try {
|
||||||
|
await redis.publish(keys.sourceRevisionChannel, JSON.stringify({ sourceRevision }));
|
||||||
|
} catch {
|
||||||
|
// Payload and revision are committed; publication remains best effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sourceRevision;
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { writeTournamentProjection } from '../src/tournament/sourceRevision.js';
|
||||||
|
|
||||||
|
describe('tournament source revision', () => {
|
||||||
|
it('passes every payload and one profile revision key to a single atomic script', async () => {
|
||||||
|
const calls: Array<{ keys: string[]; arguments: string[] }> = [];
|
||||||
|
const published: string[] = [];
|
||||||
|
const redis = {
|
||||||
|
eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => {
|
||||||
|
calls.push(options);
|
||||||
|
return '7';
|
||||||
|
},
|
||||||
|
publish: async (_channel: string, message: string) => {
|
||||||
|
published.push(message);
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
writeTournamentProjection(
|
||||||
|
redis,
|
||||||
|
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' },
|
||||||
|
[
|
||||||
|
{ key: 'state', value: { stage: 1 } },
|
||||||
|
{ key: 'matches', value: [] },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
).resolves.toBe('7');
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{
|
||||||
|
keys: ['state', 'matches', 'revision'],
|
||||||
|
arguments: [JSON.stringify({ stage: 1 }), '[]'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(published).toEqual([JSON.stringify({ sourceRevision: '7' })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty or duplicate writes before evaluating Redis', async () => {
|
||||||
|
const redis = { eval: async () => '1' };
|
||||||
|
await expect(
|
||||||
|
writeTournamentProjection(redis, { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, [])
|
||||||
|
).rejects.toThrow('at least one');
|
||||||
|
await expect(
|
||||||
|
writeTournamentProjection(
|
||||||
|
redis,
|
||||||
|
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' },
|
||||||
|
[
|
||||||
|
{ key: 'state', value: 1 },
|
||||||
|
{ key: 'state', value: 2 },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
).rejects.toThrow('unique');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user