Track traffic by game month
This commit is contained in:
@@ -173,26 +173,6 @@ const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): numbe
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: TrafficHistoryItem[] = [];
|
|
||||||
for (const item of value) {
|
|
||||||
const row = asRecord(item);
|
|
||||||
const year = readFiniteMetaNumber(row, 'year');
|
|
||||||
const month = readFiniteMetaNumber(row, 'month');
|
|
||||||
const refresh = readFiniteMetaNumber(row, 'refresh');
|
|
||||||
const online = readFiniteMetaNumber(row, 'online');
|
|
||||||
const date = typeof row.date === 'string' ? row.date : '';
|
|
||||||
if (year > 0 && month > 0 && date) {
|
|
||||||
result.push({ year, month, refresh, online, date });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const compareString = (left: string, right: string): number => {
|
const compareString = (left: string, right: string): number => {
|
||||||
if (left === right) {
|
if (left === right) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -298,69 +278,120 @@ export const publicRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta = asRecord(worldState.meta);
|
const meta = asRecord(worldState.meta);
|
||||||
const rawOnlineSince = meta.lastTurnTime ?? meta.turntime;
|
const [currentPeriod, previousPeriods, periodMaximums, accessTotal] = await Promise.all([
|
||||||
const parsedOnlineSince =
|
ctx.db.trafficPeriod.findUnique({
|
||||||
typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date
|
|
||||||
? new Date(rawOnlineSince)
|
|
||||||
: null;
|
|
||||||
const onlineSince =
|
|
||||||
parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime())
|
|
||||||
? parsedOnlineSince
|
|
||||||
: new Date(Date.now() - worldState.tickSeconds * 1_000);
|
|
||||||
const [accessTotal, currentOnline, topAccess] = await Promise.all([
|
|
||||||
ctx.db.generalAccessLog.aggregate({
|
|
||||||
_sum: {
|
|
||||||
refresh: true,
|
|
||||||
refreshScoreTotal: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
ctx.db.generalAccessLog.count({
|
|
||||||
where: {
|
where: {
|
||||||
lastRefresh: {
|
worldStateId_year_month: {
|
||||||
gte: onlineSince,
|
worldStateId: worldState.id,
|
||||||
|
year: worldState.currentYear,
|
||||||
|
month: worldState.currentMonth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { generals: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ctx.db.generalAccessLog.findMany({
|
ctx.db.trafficPeriod.findMany({
|
||||||
orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }],
|
where: {
|
||||||
|
worldStateId: worldState.id,
|
||||||
|
NOT: {
|
||||||
|
year: worldState.currentYear,
|
||||||
|
month: worldState.currentMonth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
|
||||||
take: 5,
|
take: 5,
|
||||||
select: {
|
include: {
|
||||||
generalId: true,
|
_count: {
|
||||||
|
select: { generals: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.trafficPeriod.aggregate({
|
||||||
|
where: { worldStateId: worldState.id },
|
||||||
|
_max: {
|
||||||
refresh: true,
|
refresh: true,
|
||||||
|
online: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.generalAccessLog.aggregate({
|
||||||
|
_sum: {
|
||||||
refreshScoreTotal: true,
|
refreshScoreTotal: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const topAccess = currentPeriod
|
||||||
|
? await ctx.db.trafficPeriodGeneral.findMany({
|
||||||
|
where: { periodId: currentPeriod.id },
|
||||||
|
orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }],
|
||||||
|
take: 5,
|
||||||
|
select: {
|
||||||
|
generalId: true,
|
||||||
|
refresh: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
const generalIds = topAccess.map((entry) => entry.generalId);
|
const generalIds = topAccess.map((entry) => entry.generalId);
|
||||||
const generalRows =
|
const [generalRows, generalAccessRows] =
|
||||||
generalIds.length > 0
|
generalIds.length > 0
|
||||||
? await ctx.db.general.findMany({
|
? await Promise.all([
|
||||||
where: { id: { in: generalIds } },
|
ctx.db.general.findMany({
|
||||||
select: { id: true, name: true },
|
where: { id: { in: generalIds } },
|
||||||
})
|
select: { id: true, name: true },
|
||||||
: [];
|
}),
|
||||||
|
ctx.db.generalAccessLog.findMany({
|
||||||
|
where: { generalId: { in: generalIds } },
|
||||||
|
select: { generalId: true, refreshScoreTotal: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: [[], []];
|
||||||
const generalName = new Map(generalRows.map((general) => [general.id, general.name]));
|
const generalName = new Map(generalRows.map((general) => [general.id, general.name]));
|
||||||
const totalRefresh = accessTotal._sum.refresh ?? 0;
|
const accessScore = new Map(generalAccessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal]));
|
||||||
|
const totalRefresh = currentPeriod?.refresh ?? 0;
|
||||||
const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0;
|
const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0;
|
||||||
const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh);
|
const currentOnline = currentPeriod ? Math.max(currentPeriod.online, currentPeriod._count.generals) : 0;
|
||||||
const history = parseTrafficHistory(meta.recentTraffic);
|
const history: TrafficHistoryItem[] = previousPeriods.reverse().map((period) => ({
|
||||||
history.push({
|
year: period.year,
|
||||||
year: worldState.currentYear,
|
month: period.month,
|
||||||
month: worldState.currentMonth,
|
refresh: period.refresh,
|
||||||
refresh: currentRefresh,
|
online: Math.max(period.online, period._count.generals),
|
||||||
online: currentOnline,
|
date: period.lastRefresh.toISOString(),
|
||||||
date: new Date().toISOString(),
|
}));
|
||||||
});
|
history.push(
|
||||||
|
currentPeriod
|
||||||
|
? {
|
||||||
|
year: currentPeriod.year,
|
||||||
|
month: currentPeriod.month,
|
||||||
|
refresh: currentPeriod.refresh,
|
||||||
|
online: currentOnline,
|
||||||
|
date: currentPeriod.lastRefresh.toISOString(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
year: worldState.currentYear,
|
||||||
|
month: worldState.currentMonth,
|
||||||
|
refresh: 0,
|
||||||
|
online: 0,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
history,
|
history,
|
||||||
maxRefresh: Math.max(
|
maxRefresh: Math.max(
|
||||||
1,
|
1,
|
||||||
readFiniteMetaNumber(meta, 'maxrefresh'),
|
readFiniteMetaNumber(meta, 'maxrefresh'),
|
||||||
|
periodMaximums._max.refresh ?? 0,
|
||||||
...history.map((entry) => entry.refresh)
|
...history.map((entry) => entry.refresh)
|
||||||
),
|
),
|
||||||
maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)),
|
maxOnline: Math.max(
|
||||||
|
1,
|
||||||
|
readFiniteMetaNumber(meta, 'maxonline'),
|
||||||
|
periodMaximums._max.online ?? 0,
|
||||||
|
...history.map((entry) => entry.online)
|
||||||
|
),
|
||||||
suspects: [
|
suspects: [
|
||||||
{
|
{
|
||||||
generalId: null,
|
generalId: null,
|
||||||
@@ -372,7 +403,7 @@ export const publicRouter = router({
|
|||||||
generalId: entry.generalId,
|
generalId: entry.generalId,
|
||||||
name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`,
|
name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`,
|
||||||
refresh: entry.refresh,
|
refresh: entry.refresh,
|
||||||
refreshScoreTotal: entry.refreshScoreTotal,
|
refreshScoreTotal: accessScore.get(entry.generalId) ?? 0,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ export const resolveAccessWindows = (
|
|||||||
now: Date,
|
now: Date,
|
||||||
tickSeconds: number,
|
tickSeconds: number,
|
||||||
worldMeta: unknown
|
worldMeta: unknown
|
||||||
): { dayStartedAt: Date; scoreStartedAt: Date } => {
|
): { periodStartedAt: Date; scoreStartedAt: Date } => {
|
||||||
const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
||||||
const meta = asRecord(worldMeta);
|
const meta = asRecord(worldMeta);
|
||||||
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
|
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
|
||||||
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
|
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
|
||||||
@@ -85,60 +84,140 @@ export const resolveAccessWindows = (
|
|||||||
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
|
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
|
||||||
? tickStartedAt
|
? tickStartedAt
|
||||||
: new Date(now.getTime() - fallbackTickMs);
|
: new Date(now.getTime() - fallbackTickMs);
|
||||||
return { dayStartedAt, scoreStartedAt };
|
return { periodStartedAt: scoreStartedAt, scoreStartedAt };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const upsertGeneralAccess = async (
|
export const upsertGeneralAccess = async (
|
||||||
db: Pick<GameApiContext['db'], '$executeRaw'>,
|
db: Pick<GameApiContext['db'], '$transaction'>,
|
||||||
input: {
|
input: {
|
||||||
|
worldStateId: number;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
weight: number;
|
weight: number;
|
||||||
now: Date;
|
now: Date;
|
||||||
dayStartedAt: Date;
|
periodStartedAt: Date;
|
||||||
scoreStartedAt: Date;
|
scoreStartedAt: Date;
|
||||||
}
|
}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await db.$executeRaw(
|
if (!db.$transaction) {
|
||||||
GamePrisma.sql`
|
throw new Error('Traffic access persistence requires transaction support.');
|
||||||
INSERT INTO general_access_log (
|
}
|
||||||
general_id,
|
await db.$transaction(async (transaction) => {
|
||||||
user_id,
|
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
|
||||||
last_refresh,
|
GamePrisma.sql`
|
||||||
refresh,
|
INSERT INTO traffic_period (
|
||||||
refresh_total,
|
world_state_id,
|
||||||
refresh_score,
|
year,
|
||||||
refresh_score_total
|
month,
|
||||||
)
|
started_at,
|
||||||
VALUES (
|
last_refresh,
|
||||||
${input.generalId},
|
refresh
|
||||||
${input.userId},
|
)
|
||||||
${input.now},
|
VALUES (
|
||||||
${input.weight},
|
${input.worldStateId},
|
||||||
${input.weight},
|
${input.year},
|
||||||
${input.weight},
|
${input.month},
|
||||||
${input.weight}
|
${input.periodStartedAt},
|
||||||
)
|
${input.now},
|
||||||
ON CONFLICT (general_id) DO UPDATE SET
|
${input.weight}
|
||||||
user_id = EXCLUDED.user_id,
|
)
|
||||||
last_refresh = EXCLUDED.last_refresh,
|
ON CONFLICT (world_state_id, year, month) DO UPDATE SET
|
||||||
refresh = CASE
|
started_at = LEAST(traffic_period.started_at, EXCLUDED.started_at),
|
||||||
WHEN general_access_log.last_refresh IS NULL
|
last_refresh = GREATEST(traffic_period.last_refresh, EXCLUDED.last_refresh),
|
||||||
OR general_access_log.last_refresh < ${input.dayStartedAt}
|
refresh = traffic_period.refresh + EXCLUDED.refresh
|
||||||
THEN EXCLUDED.refresh
|
RETURNING id
|
||||||
ELSE general_access_log.refresh + EXCLUDED.refresh
|
`
|
||||||
END,
|
);
|
||||||
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total,
|
const periodId = periodRows[0]?.id;
|
||||||
refresh_score = CASE
|
if (periodId === undefined) {
|
||||||
WHEN general_access_log.last_refresh IS NULL
|
throw new Error('Failed to resolve the traffic period.');
|
||||||
OR general_access_log.last_refresh < ${input.scoreStartedAt}
|
}
|
||||||
THEN EXCLUDED.refresh_score
|
|
||||||
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score
|
await transaction.$executeRaw(
|
||||||
END,
|
GamePrisma.sql`
|
||||||
refresh_score_total =
|
WITH inserted_general AS (
|
||||||
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
|
INSERT INTO traffic_period_general (
|
||||||
`
|
period_id,
|
||||||
);
|
general_id,
|
||||||
|
user_id,
|
||||||
|
refresh,
|
||||||
|
last_refresh
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${periodId},
|
||||||
|
${input.generalId},
|
||||||
|
${input.userId},
|
||||||
|
${input.weight},
|
||||||
|
${input.now}
|
||||||
|
)
|
||||||
|
ON CONFLICT (period_id, general_id) DO NOTHING
|
||||||
|
RETURNING period_id
|
||||||
|
),
|
||||||
|
updated_general AS (
|
||||||
|
UPDATE traffic_period_general
|
||||||
|
SET
|
||||||
|
user_id = ${input.userId},
|
||||||
|
refresh = traffic_period_general.refresh + ${input.weight},
|
||||||
|
last_refresh = GREATEST(
|
||||||
|
traffic_period_general.last_refresh,
|
||||||
|
${input.now}
|
||||||
|
)
|
||||||
|
WHERE period_id = ${periodId}
|
||||||
|
AND general_id = ${input.generalId}
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM inserted_general)
|
||||||
|
RETURNING period_id
|
||||||
|
)
|
||||||
|
UPDATE traffic_period
|
||||||
|
SET online = traffic_period.online + (
|
||||||
|
SELECT COUNT(*)::INTEGER FROM inserted_general
|
||||||
|
)
|
||||||
|
WHERE id = ${periodId}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
INSERT INTO general_access_log (
|
||||||
|
general_id,
|
||||||
|
user_id,
|
||||||
|
last_refresh,
|
||||||
|
refresh,
|
||||||
|
refresh_total,
|
||||||
|
refresh_score,
|
||||||
|
refresh_score_total
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${input.generalId},
|
||||||
|
${input.userId},
|
||||||
|
${input.now},
|
||||||
|
${input.weight},
|
||||||
|
${input.weight},
|
||||||
|
${input.weight},
|
||||||
|
${input.weight}
|
||||||
|
)
|
||||||
|
ON CONFLICT (general_id) DO UPDATE SET
|
||||||
|
user_id = EXCLUDED.user_id,
|
||||||
|
last_refresh = EXCLUDED.last_refresh,
|
||||||
|
refresh = CASE
|
||||||
|
WHEN general_access_log.last_refresh IS NULL
|
||||||
|
OR general_access_log.last_refresh < ${input.periodStartedAt}
|
||||||
|
THEN EXCLUDED.refresh
|
||||||
|
ELSE general_access_log.refresh + EXCLUDED.refresh
|
||||||
|
END,
|
||||||
|
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total,
|
||||||
|
refresh_score = CASE
|
||||||
|
WHEN general_access_log.last_refresh IS NULL
|
||||||
|
OR general_access_log.last_refresh < ${input.scoreStartedAt}
|
||||||
|
THEN EXCLUDED.refresh_score
|
||||||
|
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score
|
||||||
|
END,
|
||||||
|
refresh_score_total =
|
||||||
|
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
|
||||||
|
`
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recordGeneralAccess = async (
|
export const recordGeneralAccess = async (
|
||||||
@@ -159,7 +238,13 @@ export const recordGeneralAccess = async (
|
|||||||
}),
|
}),
|
||||||
ctx.db.worldState.findFirst({
|
ctx.db.worldState.findFirst({
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { tickSeconds: true, meta: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
currentYear: true,
|
||||||
|
currentMonth: true,
|
||||||
|
tickSeconds: true,
|
||||||
|
meta: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (!general || !worldState) {
|
if (!general || !worldState) {
|
||||||
@@ -174,14 +259,17 @@ export const recordGeneralAccess = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const weight = accessPageWeights[page];
|
const weight = accessPageWeights[page];
|
||||||
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
||||||
|
|
||||||
await upsertGeneralAccess(ctx.db, {
|
await upsertGeneralAccess(ctx.db, {
|
||||||
|
worldStateId: worldState.id,
|
||||||
|
year: worldState.currentYear,
|
||||||
|
month: worldState.currentMonth,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
weight,
|
weight,
|
||||||
now,
|
now,
|
||||||
dayStartedAt,
|
periodStartedAt,
|
||||||
scoreStartedAt,
|
scoreStartedAt,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -6,30 +6,54 @@ import { upsertGeneralAccess } from '../src/services/generalAccess.js';
|
|||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const generalId = 9_980_071;
|
const generalId = 9_980_071;
|
||||||
|
const secondGeneralId = generalId + 1;
|
||||||
|
const rollbackGeneralId = generalId + 2;
|
||||||
|
const scenarioCode = `traffic-period-${generalId}`;
|
||||||
|
|
||||||
integration('general access tracking persistence', () => {
|
integration('general access tracking persistence', () => {
|
||||||
let db: GamePrismaClient;
|
let db: GamePrismaClient;
|
||||||
let closeDb: (() => Promise<void>) | undefined;
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
let worldStateId: number;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
closeDb = () => connector.disconnect();
|
closeDb = () => connector.disconnect();
|
||||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
await db.generalAccessLog.deleteMany({
|
||||||
|
where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } },
|
||||||
|
});
|
||||||
|
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||||
|
const world = await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
scenarioCode,
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 3,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
worldStateId = world.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
await db.generalAccessLog.deleteMany({
|
||||||
|
where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } },
|
||||||
|
});
|
||||||
|
await db.worldState.deleteMany({ where: { id: worldStateId } });
|
||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('atomically increments concurrent requests and resets only windowed counters', async () => {
|
it('atomically increments one game-month bucket and opens a new bucket at the next month', async () => {
|
||||||
const firstWindow = {
|
const firstWindow = {
|
||||||
|
worldStateId,
|
||||||
|
year: 185,
|
||||||
|
month: 3,
|
||||||
generalId,
|
generalId,
|
||||||
userId: 'access-user-a',
|
userId: 'access-user-a',
|
||||||
now: new Date('2026-07-26T03:05:00.000Z'),
|
now: new Date('2026-07-26T03:05:00.000Z'),
|
||||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
periodStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||||
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||||
};
|
};
|
||||||
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
||||||
@@ -50,13 +74,37 @@ integration('general access tracking persistence', () => {
|
|||||||
refreshScore: 22,
|
refreshScore: 22,
|
||||||
refreshScoreTotal: 22,
|
refreshScoreTotal: 22,
|
||||||
});
|
});
|
||||||
|
const firstPeriod = await db.trafficPeriod.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
worldStateId_year_month: {
|
||||||
|
worldStateId,
|
||||||
|
year: 185,
|
||||||
|
month: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { generals: { orderBy: { generalId: 'asc' } } },
|
||||||
|
});
|
||||||
|
expect(firstPeriod).toMatchObject({
|
||||||
|
refresh: 22,
|
||||||
|
online: 1,
|
||||||
|
generals: [
|
||||||
|
{
|
||||||
|
generalId,
|
||||||
|
userId: 'access-user-a',
|
||||||
|
refresh: 22,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
await upsertGeneralAccess(db, {
|
await upsertGeneralAccess(db, {
|
||||||
|
worldStateId,
|
||||||
|
year: 185,
|
||||||
|
month: 4,
|
||||||
generalId,
|
generalId,
|
||||||
userId: 'access-user-b',
|
userId: 'access-user-b',
|
||||||
now: new Date('2026-07-27T00:05:00.000Z'),
|
now: new Date('2026-07-26T03:15:00.000Z'),
|
||||||
dayStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
weight: 1,
|
weight: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,5 +115,89 @@ integration('general access tracking persistence', () => {
|
|||||||
refreshScore: 1,
|
refreshScore: 1,
|
||||||
refreshScoreTotal: 23,
|
refreshScoreTotal: 23,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await upsertGeneralAccess(db, {
|
||||||
|
worldStateId,
|
||||||
|
year: 185,
|
||||||
|
month: 4,
|
||||||
|
generalId: secondGeneralId,
|
||||||
|
userId: 'access-user-c',
|
||||||
|
now: new Date('2026-07-26T03:15:01.000Z'),
|
||||||
|
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
|
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
|
weight: 1,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
db.trafficPeriod.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
worldStateId_year_month: {
|
||||||
|
worldStateId,
|
||||||
|
year: 185,
|
||||||
|
month: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { generals: { orderBy: { generalId: 'asc' } } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
refresh: 2,
|
||||||
|
online: 2,
|
||||||
|
generals: [
|
||||||
|
{
|
||||||
|
generalId,
|
||||||
|
userId: 'access-user-b',
|
||||||
|
refresh: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
generalId: secondGeneralId,
|
||||||
|
userId: 'access-user-c',
|
||||||
|
refresh: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back the period and member rows when the legacy access update fails', async () => {
|
||||||
|
await db.generalAccessLog.create({
|
||||||
|
data: {
|
||||||
|
generalId: rollbackGeneralId,
|
||||||
|
userId: 'rollback-user',
|
||||||
|
lastRefresh: new Date('2026-07-26T03:19:00.000Z'),
|
||||||
|
refresh: 1,
|
||||||
|
refreshTotal: 2_147_483_647,
|
||||||
|
refreshScore: 1,
|
||||||
|
refreshScoreTotal: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
upsertGeneralAccess(db, {
|
||||||
|
worldStateId,
|
||||||
|
year: 186,
|
||||||
|
month: 1,
|
||||||
|
generalId: rollbackGeneralId,
|
||||||
|
userId: 'rollback-user',
|
||||||
|
now: new Date('2026-07-26T03:20:00.000Z'),
|
||||||
|
periodStartedAt: new Date('2026-07-26T03:20:00.000Z'),
|
||||||
|
scoreStartedAt: new Date('2026-07-26T03:20:00.000Z'),
|
||||||
|
weight: 1,
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.trafficPeriod.findUnique({
|
||||||
|
where: {
|
||||||
|
worldStateId_year_month: {
|
||||||
|
worldStateId,
|
||||||
|
year: 186,
|
||||||
|
month: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: rollbackGeneralId } })
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
refreshTotal: 2_147_483_647,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,8 +21,17 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
|||||||
|
|
||||||
const buildDb = (meta: Record<string, unknown> = {}) => {
|
const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||||
|
const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]);
|
||||||
|
const transaction = vi.fn(
|
||||||
|
async (
|
||||||
|
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
|
||||||
|
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
|
||||||
|
);
|
||||||
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
||||||
const findWorld = vi.fn(async () => ({
|
const findWorld = vi.fn(async () => ({
|
||||||
|
id: 3,
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 4,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
meta: {
|
meta: {
|
||||||
opentime: '2026-07-25T00:00:00.000Z',
|
opentime: '2026-07-25T00:00:00.000Z',
|
||||||
@@ -31,11 +40,11 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const db = {
|
const db = {
|
||||||
$executeRaw: executeRaw,
|
$transaction: transaction,
|
||||||
general: { findFirst: findGeneral },
|
general: { findFirst: findGeneral },
|
||||||
worldState: { findFirst: findWorld },
|
worldState: { findFirst: findWorld },
|
||||||
} as unknown as DatabaseClient;
|
} as unknown as DatabaseClient;
|
||||||
return { db, executeRaw, findGeneral, findWorld };
|
return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('general access tracking', () => {
|
describe('general access tracking', () => {
|
||||||
@@ -44,19 +53,19 @@ describe('general access tracking', () => {
|
|||||||
expect(accessPageWeights['general-list']).toBe(2);
|
expect(accessPageWeights['general-list']).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves the UTC day and latest processed turn windows', () => {
|
it('uses the latest processed game turn as the traffic period and score window', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
||||||
lastTurnTime: '2026-07-26T03:10:00.000Z',
|
lastTurnTime: '2026-07-26T03:10:00.000Z',
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
||||||
const { db, executeRaw, findGeneral } = buildDb();
|
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
|
||||||
const now = new Date('2026-07-26T03:05:00.000Z');
|
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||||
|
|
||||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
||||||
@@ -65,22 +74,28 @@ describe('general access tracking', () => {
|
|||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { id: true, userId: true },
|
select: { id: true, userId: true },
|
||||||
});
|
});
|
||||||
expect(executeRaw).toHaveBeenCalledTimes(1);
|
expect(transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||||
expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
|
||||||
expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
expect(periodStatement.sql).toContain('ON CONFLICT (world_state_id, year, month) DO UPDATE');
|
||||||
expect(statement.values).toEqual([
|
expect(periodStatement.values).toEqual([3, 185, 4, new Date('2026-07-26T03:00:00.000Z'), now, 2]);
|
||||||
7,
|
|
||||||
'user-7',
|
const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||||
now,
|
expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
|
||||||
2,
|
expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
|
||||||
2,
|
expect(memberStatement.sql).toContain('SET online = traffic_period.online');
|
||||||
2,
|
|
||||||
2,
|
const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
|
||||||
new Date('2026-07-26T00:00:00.000Z'),
|
expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||||
new Date('2026-07-26T03:00:00.000Z'),
|
expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||||
]);
|
expect(accessStatement.values).toContain(7);
|
||||||
|
expect(accessStatement.values).toContain('user-7');
|
||||||
|
expect(accessStatement.values).toContain(2);
|
||||||
|
expect(accessStatement.values).toContain(now);
|
||||||
|
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
||||||
@@ -96,10 +111,10 @@ describe('general access tracking', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
||||||
).resolves.toBe(false);
|
).resolves.toBe(false);
|
||||||
expect(future.executeRaw).not.toHaveBeenCalled();
|
expect(future.transaction).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const united = buildDb({ isUnited: 2 });
|
const united = buildDb({ isUnited: 2 });
|
||||||
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
||||||
expect(united.executeRaw).not.toHaveBeenCalled();
|
expect(united.transaction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,17 +44,50 @@ const buildContext = (): GameApiContext => {
|
|||||||
generalAccessLog: {
|
generalAccessLog: {
|
||||||
aggregate: async () => ({
|
aggregate: async () => ({
|
||||||
_sum: {
|
_sum: {
|
||||||
refresh: 12,
|
|
||||||
refreshScoreTotal: 21,
|
refreshScoreTotal: 21,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
count: async (args: { where: { lastRefresh: { gte: Date } } }) => {
|
|
||||||
expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z'));
|
|
||||||
return 2;
|
|
||||||
},
|
|
||||||
findMany: async () => [
|
findMany: async () => [
|
||||||
{ generalId: 7, refresh: 9, refreshScoreTotal: 15 },
|
{ generalId: 7, refreshScoreTotal: 15 },
|
||||||
{ generalId: 8, refresh: 3, refreshScoreTotal: 6 },
|
{ generalId: 8, refreshScoreTotal: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
trafficPeriod: {
|
||||||
|
findUnique: async () => ({
|
||||||
|
id: 12,
|
||||||
|
worldStateId: 1,
|
||||||
|
year: 185,
|
||||||
|
month: 3,
|
||||||
|
startedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||||
|
lastRefresh: new Date('2026-07-26T03:05:00.000Z'),
|
||||||
|
refresh: 12,
|
||||||
|
online: 0,
|
||||||
|
_count: { generals: 2 },
|
||||||
|
}),
|
||||||
|
findMany: async () => [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
worldStateId: 1,
|
||||||
|
year: 185,
|
||||||
|
month: 2,
|
||||||
|
startedAt: new Date('2026-07-26T02:50:00.000Z'),
|
||||||
|
lastRefresh: new Date('2026-07-26T02:50:00.000Z'),
|
||||||
|
refresh: 30,
|
||||||
|
online: 5,
|
||||||
|
_count: { generals: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aggregate: async () => ({
|
||||||
|
_max: {
|
||||||
|
refresh: 30,
|
||||||
|
online: 5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
trafficPeriodGeneral: {
|
||||||
|
findMany: async () => [
|
||||||
|
{ generalId: 7, refresh: 9 },
|
||||||
|
{ generalId: 8, refresh: 3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
@@ -95,13 +128,14 @@ describe('public.getTraffic', () => {
|
|||||||
month: 2,
|
month: 2,
|
||||||
refresh: 30,
|
refresh: 30,
|
||||||
online: 5,
|
online: 5,
|
||||||
date: '2026-07-26 02:50:00',
|
date: '2026-07-26T02:50:00.000Z',
|
||||||
});
|
});
|
||||||
expect(result.history[1]).toMatchObject({
|
expect(result.history[1]).toMatchObject({
|
||||||
year: 185,
|
year: 185,
|
||||||
month: 3,
|
month: 3,
|
||||||
refresh: 12,
|
refresh: 12,
|
||||||
online: 2,
|
online: 2,
|
||||||
|
date: '2026-07-26T03:05:00.000Z',
|
||||||
});
|
});
|
||||||
expect(result.maxRefresh).toBe(30);
|
expect(result.maxRefresh).toBe(30);
|
||||||
expect(result.maxOnline).toBe(5);
|
expect(result.maxOnline).toBe(5);
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ model WorldState {
|
|||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
trafficPeriods TrafficPeriod[]
|
||||||
|
|
||||||
@@map("world_state")
|
@@map("world_state")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +206,38 @@ model GeneralAccessLog {
|
|||||||
@@map("general_access_log")
|
@@map("general_access_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model TrafficPeriod {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
worldStateId Int @map("world_state_id")
|
||||||
|
year Int
|
||||||
|
month Int
|
||||||
|
startedAt DateTime @map("started_at")
|
||||||
|
lastRefresh DateTime @map("last_refresh")
|
||||||
|
refresh Int @default(0)
|
||||||
|
online Int @default(0)
|
||||||
|
|
||||||
|
worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade)
|
||||||
|
generals TrafficPeriodGeneral[]
|
||||||
|
|
||||||
|
@@unique([worldStateId, year, month])
|
||||||
|
@@index([worldStateId, startedAt])
|
||||||
|
@@map("traffic_period")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TrafficPeriodGeneral {
|
||||||
|
periodId Int @map("period_id")
|
||||||
|
generalId Int @map("general_id")
|
||||||
|
userId String? @map("user_id")
|
||||||
|
refresh Int @default(0)
|
||||||
|
lastRefresh DateTime @map("last_refresh")
|
||||||
|
|
||||||
|
period TrafficPeriod @relation(fields: [periodId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([periodId, generalId])
|
||||||
|
@@index([periodId, refresh, generalId])
|
||||||
|
@@map("traffic_period_general")
|
||||||
|
}
|
||||||
|
|
||||||
model MessageReadState {
|
model MessageReadState {
|
||||||
generalId Int @id @map("general_id")
|
generalId Int @id @map("general_id")
|
||||||
latestPrivateMessage Int @default(0) @map("latest_private_message")
|
latestPrivateMessage Int @default(0) @map("latest_private_message")
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
CREATE TABLE "traffic_period" (
|
||||||
|
"id" SERIAL PRIMARY KEY,
|
||||||
|
"world_state_id" INTEGER NOT NULL,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"month" INTEGER NOT NULL,
|
||||||
|
"started_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"last_refresh" TIMESTAMP(3) NOT NULL,
|
||||||
|
"refresh" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"online" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT "traffic_period_world_state_id_fkey"
|
||||||
|
FOREIGN KEY ("world_state_id") REFERENCES "world_state"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "traffic_period_month_check" CHECK ("month" BETWEEN 1 AND 12),
|
||||||
|
CONSTRAINT "traffic_period_refresh_check" CHECK ("refresh" >= 0),
|
||||||
|
CONSTRAINT "traffic_period_online_check" CHECK ("online" >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "traffic_period_world_state_year_month_key"
|
||||||
|
ON "traffic_period"("world_state_id", "year", "month");
|
||||||
|
CREATE INDEX "traffic_period_world_state_started_at_idx"
|
||||||
|
ON "traffic_period"("world_state_id", "started_at");
|
||||||
|
|
||||||
|
CREATE TABLE "traffic_period_general" (
|
||||||
|
"period_id" INTEGER NOT NULL,
|
||||||
|
"general_id" INTEGER NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"refresh" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"last_refresh" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "traffic_period_general_pkey" PRIMARY KEY ("period_id", "general_id"),
|
||||||
|
CONSTRAINT "traffic_period_general_period_id_fkey"
|
||||||
|
FOREIGN KEY ("period_id") REFERENCES "traffic_period"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "traffic_period_general_refresh_check" CHECK ("refresh" >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "traffic_period_general_period_refresh_general_idx"
|
||||||
|
ON "traffic_period_general"("period_id", "refresh", "general_id");
|
||||||
|
|
||||||
|
-- Preserve legacy completed-period snapshots. Invalid or missing legacy dates use
|
||||||
|
-- the world row timestamp so one malformed JSON item cannot block deployment.
|
||||||
|
WITH legacy_rows AS (
|
||||||
|
SELECT
|
||||||
|
world."id" AS "world_state_id",
|
||||||
|
item,
|
||||||
|
ordinality
|
||||||
|
FROM "world_state" AS world
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(world."meta"->'recentTraffic') = 'array'
|
||||||
|
THEN world."meta"->'recentTraffic'
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS legacy(item, ordinality)
|
||||||
|
),
|
||||||
|
normalized AS (
|
||||||
|
SELECT
|
||||||
|
legacy_rows."world_state_id",
|
||||||
|
CASE
|
||||||
|
WHEN pg_input_is_valid(legacy_rows.item->>'year', 'integer')
|
||||||
|
THEN (legacy_rows.item->>'year')::INTEGER
|
||||||
|
ELSE NULL
|
||||||
|
END AS "year",
|
||||||
|
CASE
|
||||||
|
WHEN pg_input_is_valid(legacy_rows.item->>'month', 'integer')
|
||||||
|
THEN (legacy_rows.item->>'month')::INTEGER
|
||||||
|
ELSE NULL
|
||||||
|
END AS "month",
|
||||||
|
CASE
|
||||||
|
WHEN pg_input_is_valid(legacy_rows.item->>'refresh', 'integer')
|
||||||
|
THEN (legacy_rows.item->>'refresh')::INTEGER
|
||||||
|
ELSE 0
|
||||||
|
END AS "refresh",
|
||||||
|
CASE
|
||||||
|
WHEN pg_input_is_valid(legacy_rows.item->>'online', 'integer')
|
||||||
|
THEN (legacy_rows.item->>'online')::INTEGER
|
||||||
|
ELSE 0
|
||||||
|
END AS "online",
|
||||||
|
CASE
|
||||||
|
WHEN pg_input_is_valid(
|
||||||
|
legacy_rows.item->>'date',
|
||||||
|
'timestamp without time zone'
|
||||||
|
)
|
||||||
|
THEN (legacy_rows.item->>'date')::TIMESTAMP
|
||||||
|
ELSE world."updated_at"
|
||||||
|
END AS "observed_at",
|
||||||
|
legacy_rows.ordinality
|
||||||
|
FROM legacy_rows
|
||||||
|
JOIN "world_state" AS world ON world."id" = legacy_rows."world_state_id"
|
||||||
|
)
|
||||||
|
INSERT INTO "traffic_period" (
|
||||||
|
"world_state_id",
|
||||||
|
"year",
|
||||||
|
"month",
|
||||||
|
"started_at",
|
||||||
|
"last_refresh",
|
||||||
|
"refresh",
|
||||||
|
"online"
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON ("world_state_id", "year", "month")
|
||||||
|
"world_state_id",
|
||||||
|
"year",
|
||||||
|
"month",
|
||||||
|
"observed_at",
|
||||||
|
"observed_at",
|
||||||
|
"refresh",
|
||||||
|
"online"
|
||||||
|
FROM normalized
|
||||||
|
WHERE "year" IS NOT NULL
|
||||||
|
AND "month" BETWEEN 1 AND 12
|
||||||
|
ORDER BY "world_state_id", "year", "month", ordinality DESC
|
||||||
|
ON CONFLICT ("world_state_id", "year", "month") DO NOTHING;
|
||||||
@@ -33,6 +33,8 @@ pnpm --filter @sammo-ts/infra prisma:migrate:status:game
|
|||||||
- `nation.chief_general_id` 존재
|
- `nation.chief_general_id` 존재
|
||||||
- `city.trade` nullable, `city.trust` REAL
|
- `city.trade` nullable, `city.trust` REAL
|
||||||
- `auction_bid.meta` JSONB, NOT NULL
|
- `auction_bid.meta` JSONB, NOT NULL
|
||||||
|
- `traffic_period`, `traffic_period_general` 존재 및
|
||||||
|
`(world_state_id, year, month)`/`(period_id, general_id)` unique key
|
||||||
|
|
||||||
검증 뒤에는 이름을 확인한 임시 database와 role만 제거한다. 공유 개발
|
검증 뒤에는 이름을 확인한 임시 database와 role만 제거한다. 공유 개발
|
||||||
database나 Compose volume을 삭제하지 않는다.
|
database나 Compose volume을 삭제하지 않는다.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface DatabaseClient {
|
|||||||
worldState: GamePrisma.WorldStateDelegate;
|
worldState: GamePrisma.WorldStateDelegate;
|
||||||
general: GamePrisma.GeneralDelegate;
|
general: GamePrisma.GeneralDelegate;
|
||||||
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
|
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
|
||||||
|
trafficPeriod: GamePrisma.TrafficPeriodDelegate;
|
||||||
|
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
|
||||||
messageReadState: GamePrisma.MessageReadStateDelegate;
|
messageReadState: GamePrisma.MessageReadStateDelegate;
|
||||||
message: GamePrisma.MessageDelegate;
|
message: GamePrisma.MessageDelegate;
|
||||||
city: GamePrisma.CityDelegate;
|
city: GamePrisma.CityDelegate;
|
||||||
|
|||||||
Reference in New Issue
Block a user