feat: expose archived general histories
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||||
@@ -5,7 +8,40 @@ import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
|||||||
const numberOrNull = (value: unknown): number | null =>
|
const numberOrNull = (value: unknown): number | null =>
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
|
||||||
const textOrNull = (value: unknown): string | null => (typeof value === 'string' ? value : null);
|
const firstNumber = (record: Record<string, unknown>, ...keys: string[]): number | null => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = numberOrNull(record[key]);
|
||||||
|
if (value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayTextOrNull = (value: unknown): string | null => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? String(value) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseHistory = (value: unknown): string[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.split(/<br\s*\/?>/i)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zPastPlayDetailInput = z.object({
|
||||||
|
serverId: z.string().trim().min(1).max(64),
|
||||||
|
generalNo: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
export const archiveRouter = router({
|
export const archiveRouter = router({
|
||||||
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
|
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
|
||||||
@@ -22,7 +58,7 @@ export const archiveRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverIds = [...new Set(generals.map((general) => general.serverId))];
|
const serverIds = [...new Set(generals.map((general) => general.serverId))];
|
||||||
const [games, nations] = await Promise.all([
|
const [games, nations, emperors] = await Promise.all([
|
||||||
ctx.db.gameHistory.findMany({
|
ctx.db.gameHistory.findMany({
|
||||||
where: { serverId: { in: serverIds } },
|
where: { serverId: { in: serverIds } },
|
||||||
}),
|
}),
|
||||||
@@ -30,9 +66,20 @@ export const archiveRouter = router({
|
|||||||
where: { serverId: { in: serverIds } },
|
where: { serverId: { in: serverIds } },
|
||||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||||
}),
|
}),
|
||||||
|
ctx.db.emperor.findMany({
|
||||||
|
where: { serverId: { in: serverIds } },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: { id: true, serverId: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const gameByServer = new Map(games.map((game) => [game.serverId, game]));
|
const gameByServer = new Map(games.map((game) => [game.serverId, game]));
|
||||||
|
const emperorByServer = new Map<string, number>();
|
||||||
|
for (const emperor of emperors) {
|
||||||
|
if (emperor.serverId && !emperorByServer.has(emperor.serverId)) {
|
||||||
|
emperorByServer.set(emperor.serverId, emperor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
const nationByServerAndId = new Map<string, (typeof nations)[number]>();
|
const nationByServerAndId = new Map<string, (typeof nations)[number]>();
|
||||||
for (const nation of nations) {
|
for (const nation of nations) {
|
||||||
const key = `${nation.serverId}:${nation.nation}`;
|
const key = `${nation.serverId}:${nation.nation}`;
|
||||||
@@ -49,6 +96,7 @@ export const archiveRouter = router({
|
|||||||
season: number | null;
|
season: number | null;
|
||||||
scenario: number | null;
|
scenario: number | null;
|
||||||
scenarioName: string | null;
|
scenarioName: string | null;
|
||||||
|
dynastyId: number | null;
|
||||||
generals: Array<{
|
generals: Array<{
|
||||||
generalNo: number;
|
generalNo: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -65,6 +113,7 @@ export const archiveRouter = router({
|
|||||||
personal: string | null;
|
personal: string | null;
|
||||||
special: string | null;
|
special: string | null;
|
||||||
special2: string | null;
|
special2: string | null;
|
||||||
|
historyCount: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
@@ -79,13 +128,16 @@ export const archiveRouter = router({
|
|||||||
season: game?.season ?? null,
|
season: game?.season ?? null,
|
||||||
scenario: game?.scenario ?? null,
|
scenario: game?.scenario ?? null,
|
||||||
scenarioName: game?.scenarioName ?? null,
|
scenarioName: game?.scenarioName ?? null,
|
||||||
|
dynastyId: emperorByServer.get(general.serverId) ?? null,
|
||||||
generals: [],
|
generals: [],
|
||||||
};
|
};
|
||||||
seasons.set(general.serverId, season);
|
seasons.set(general.serverId, season);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = asRecord(general.data);
|
const data = asRecord(general.data);
|
||||||
const nationId = numberOrNull(data.nation) ?? 0;
|
const stats = asRecord(data.stats);
|
||||||
|
const role = asRecord(data.role);
|
||||||
|
const nationId = firstNumber(data, 'nationId', 'nation') ?? 0;
|
||||||
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
|
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
|
||||||
const nationData = asRecord(nation?.data);
|
const nationData = asRecord(nation?.data);
|
||||||
season.generals.push({
|
season.generals.push({
|
||||||
@@ -93,17 +145,18 @@ export const archiveRouter = router({
|
|||||||
name: general.name,
|
name: general.name,
|
||||||
lastYearMonth: general.lastYearMonth,
|
lastYearMonth: general.lastYearMonth,
|
||||||
nationId,
|
nationId,
|
||||||
nationName: textOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'),
|
nationName: displayTextOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'),
|
||||||
nationColor: textOrNull(nationData.color) ?? '#000000',
|
nationColor: displayTextOrNull(nationData.color) ?? '#000000',
|
||||||
leadership: numberOrNull(data.leadership),
|
leadership: firstNumber(data, 'leadership', 'leader') ?? numberOrNull(stats.leadership),
|
||||||
strength: numberOrNull(data.strength),
|
strength: firstNumber(data, 'strength', 'power') ?? numberOrNull(stats.strength),
|
||||||
intel: numberOrNull(data.intel),
|
intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence),
|
||||||
experience: numberOrNull(data.experience),
|
experience: numberOrNull(data.experience),
|
||||||
dedication: numberOrNull(data.dedication),
|
dedication: numberOrNull(data.dedication),
|
||||||
officerLevel: numberOrNull(data.officer_level),
|
officerLevel: firstNumber(data, 'officerLevel', 'officer_level'),
|
||||||
personal: textOrNull(data.personal),
|
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality),
|
||||||
special: textOrNull(data.special),
|
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic),
|
||||||
special2: textOrNull(data.special2),
|
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar),
|
||||||
|
historyCount: parseHistory(data.history).length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,4 +168,28 @@ export const archiveRouter = router({
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => {
|
||||||
|
const owner = ctx.auth?.user.id;
|
||||||
|
if (!owner) {
|
||||||
|
throw new Error('Authenticated archive query is missing its user identity');
|
||||||
|
}
|
||||||
|
const general = await ctx.db.oldGeneral.findFirst({
|
||||||
|
where: {
|
||||||
|
owner,
|
||||||
|
serverId: input.serverId,
|
||||||
|
generalNo: input.generalNo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!general) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
const data = asRecord(general.data);
|
||||||
|
return {
|
||||||
|
serverId: general.serverId,
|
||||||
|
generalNo: general.generalNo,
|
||||||
|
name: general.name,
|
||||||
|
lastYearMonth: general.lastYearMonth,
|
||||||
|
history: parseHistory(data.history),
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,14 +41,51 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
|||||||
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
data: {
|
data: {
|
||||||
nation: 3,
|
nation: 3,
|
||||||
leadership: 80,
|
leader: 80,
|
||||||
strength: 70,
|
power: 70,
|
||||||
intel: 60,
|
intel: 60,
|
||||||
personal: 'che_의리',
|
officer_level: 8,
|
||||||
|
personal: 3,
|
||||||
|
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
serverId: 'che_legacy_1',
|
||||||
|
generalNo: 11,
|
||||||
|
owner: 'user-1',
|
||||||
|
name: '현재형장수',
|
||||||
|
lastYearMonth: 22012,
|
||||||
|
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
data: {
|
||||||
|
nationId: 3,
|
||||||
|
stats: { leadership: 81, strength: 71, intelligence: 61 },
|
||||||
|
officerLevel: 7,
|
||||||
|
role: {
|
||||||
|
personality: 'che_의리',
|
||||||
|
specialDomestic: 'che_상재',
|
||||||
|
specialWar: 'che_신산',
|
||||||
|
},
|
||||||
|
history: ['<C>●</>현재형 기록'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
|
findFirst: async ({ where }: { where: { owner: string; serverId: string; generalNo: number } }) =>
|
||||||
|
where.owner === 'user-1' && where.serverId === 'che_legacy_1' && where.generalNo === 10
|
||||||
|
? {
|
||||||
|
id: 1,
|
||||||
|
serverId: 'che_legacy_1',
|
||||||
|
generalNo: 10,
|
||||||
|
owner: 'user-1',
|
||||||
|
name: '과거장수',
|
||||||
|
lastYearMonth: 22012,
|
||||||
|
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
data: {
|
||||||
|
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
gameHistory: {
|
gameHistory: {
|
||||||
findMany: async () => [
|
findMany: async () => [
|
||||||
@@ -77,6 +114,9 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
emperor: {
|
||||||
|
findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const redis = {
|
const redis = {
|
||||||
get: async () => null,
|
get: async () => null,
|
||||||
@@ -108,14 +148,56 @@ describe('archive.myPastPlays', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
serverId: 'che_legacy_1',
|
serverId: 'che_legacy_1',
|
||||||
scenarioName: '테스트',
|
scenarioName: '테스트',
|
||||||
|
dynastyId: 7,
|
||||||
generals: [
|
generals: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: '과거장수',
|
name: '과거장수',
|
||||||
nationName: '촉',
|
nationName: '촉',
|
||||||
leadership: 80,
|
leadership: 80,
|
||||||
|
strength: 70,
|
||||||
|
officerLevel: 8,
|
||||||
|
personal: '3',
|
||||||
|
historyCount: 2,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: '현재형장수',
|
||||||
|
nationName: '촉',
|
||||||
|
leadership: 81,
|
||||||
|
strength: 71,
|
||||||
|
intel: 61,
|
||||||
|
officerLevel: 7,
|
||||||
|
personal: 'che_의리',
|
||||||
|
special: 'che_상재',
|
||||||
|
special2: 'che_신산',
|
||||||
|
historyCount: 1,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads archived history only for a general owned by the authenticated session', async () => {
|
||||||
|
const input = { serverId: 'che_legacy_1', generalNo: 10 };
|
||||||
|
await expect(appRouter.createCaller(context(null)).archive.myPastPlayDetail(input)).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input);
|
||||||
|
expect(result).toEqual({
|
||||||
|
serverId: 'che_legacy_1',
|
||||||
|
generalNo: 10,
|
||||||
|
name: '과거장수',
|
||||||
|
lastYearMonth: 22012,
|
||||||
|
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const otherUser = {
|
||||||
|
...auth,
|
||||||
|
sessionId: 'other-session',
|
||||||
|
user: { ...auth.user, id: 'user-2', username: 'user-2' },
|
||||||
|
};
|
||||||
|
await expect(appRouter.createCaller(context(otherUser)).archive.myPastPlayDetail(input)).rejects.toMatchObject({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
|
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
|
||||||
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||||
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
|
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
|
||||||
|
|
||||||
@@ -260,10 +261,20 @@ const archiveDeletedGeneral = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const serverId =
|
const serverId =
|
||||||
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
|
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
|
||||||
|
const history = await prisma.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
generalId: event.generalId,
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: { text: true },
|
||||||
|
});
|
||||||
const data = {
|
const data = {
|
||||||
...event.before,
|
...event.before,
|
||||||
turnTime: event.before.turnTime.toISOString(),
|
turnTime: event.before.turnTime.toISOString(),
|
||||||
recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
|
recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
|
||||||
|
history: history.map((entry) => entry.text),
|
||||||
};
|
};
|
||||||
await prisma.oldGeneral.upsert({
|
await prisma.oldGeneral.upsert({
|
||||||
where: { by_no: { serverId, generalNo: event.generalId } },
|
where: { by_no: { serverId, generalNo: event.generalId } },
|
||||||
|
|||||||
@@ -651,6 +651,26 @@ export const createUnificationHandler = (options: {
|
|||||||
const oldGeneralTargets = generalRows.filter(
|
const oldGeneralTargets = generalRows.filter(
|
||||||
(general) => general.nationId === 0 || general.nationId === winnerNationId
|
(general) => general.nationId === 0 || general.nationId === winnerNationId
|
||||||
);
|
);
|
||||||
|
const generalHistoryRows = oldGeneralTargets.length
|
||||||
|
? await prisma.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
generalId: { in: oldGeneralTargets.map((general) => general.id) },
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: { generalId: true, text: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const historyByGeneral = new Map<number, string[]>();
|
||||||
|
for (const row of generalHistoryRows) {
|
||||||
|
if (row.generalId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const history = historyByGeneral.get(row.generalId) ?? [];
|
||||||
|
history.push(row.text);
|
||||||
|
historyByGeneral.set(row.generalId, history);
|
||||||
|
}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
oldGeneralTargets.map((general) =>
|
oldGeneralTargets.map((general) =>
|
||||||
((snapshot) =>
|
((snapshot) =>
|
||||||
@@ -680,6 +700,7 @@ export const createUnificationHandler = (options: {
|
|||||||
}))({
|
}))({
|
||||||
...general,
|
...general,
|
||||||
turnTime: general.turnTime.toISOString(),
|
turnTime: general.turnTime.toISOString(),
|
||||||
|
history: historyByGeneral.get(general.id) ?? [],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
||||||
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
||||||
@@ -72,6 +74,7 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
let close: (() => Promise<void>) | undefined;
|
let close: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
|
await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
|
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
|
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
|
await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
|
||||||
@@ -116,6 +119,26 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
{ generalId: general.id, nationId: 0, type: 'firenum', value: 1 },
|
{ generalId: general.id, nationId: 0, type: 'firenum', value: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await db.logEntry.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
year: 199,
|
||||||
|
month: 12,
|
||||||
|
generalId: general.id,
|
||||||
|
text: '<C>●</>첫 기록',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
year: 200,
|
||||||
|
month: 1,
|
||||||
|
generalId: general.id,
|
||||||
|
text: '<Y>●</>둘째 기록',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
await db.$transaction((tx) =>
|
await db.$transaction((tx) =>
|
||||||
persistGeneralLifecycleEvents(
|
persistGeneralLifecycleEvents(
|
||||||
@@ -127,7 +150,10 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toBeNull();
|
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toBeNull();
|
||||||
expect(await db.oldGeneral.findUnique({ where: { by_no: { serverId, generalNo: general.id } } })).not.toBeNull();
|
const archived = await db.oldGeneral.findUniqueOrThrow({
|
||||||
|
where: { by_no: { serverId, generalNo: general.id } },
|
||||||
|
});
|
||||||
|
expect(asRecord(archived.data).history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
|
||||||
expect(
|
expect(
|
||||||
await db.inheritancePoint.findUnique({
|
await db.inheritancePoint.findUnique({
|
||||||
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
||||||
|
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral } from '../src/turn/types.js';
|
||||||
|
|
||||||
|
const archivedGeneral = (): TurnGeneral => ({
|
||||||
|
id: 91,
|
||||||
|
userId: null,
|
||||||
|
name: '기록장수',
|
||||||
|
nationId: 0,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||||
|
experience: 1_000,
|
||||||
|
dedication: 500,
|
||||||
|
officerLevel: 0,
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
injury: 0,
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 1_000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 1,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 70,
|
||||||
|
npcState: 2,
|
||||||
|
bornYear: 170,
|
||||||
|
deadYear: 240,
|
||||||
|
affinity: 50,
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: { killturn: 0 },
|
||||||
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('general lifecycle archive history', () => {
|
||||||
|
it('stores the general history in descending log order when a general is deleted', async () => {
|
||||||
|
const general = archivedGeneral();
|
||||||
|
const upsert = vi.fn(async () => undefined);
|
||||||
|
const prisma = {
|
||||||
|
generalAccessLog: {
|
||||||
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||||
|
deleteMany: vi.fn(async () => ({ count: 1 })),
|
||||||
|
},
|
||||||
|
logEntry: {
|
||||||
|
findMany: vi.fn(async () => [{ text: '<Y>●</>둘째 기록' }, { text: '<C>●</>첫 기록' }]),
|
||||||
|
},
|
||||||
|
oldGeneral: { upsert },
|
||||||
|
} as unknown as GamePrisma.TransactionClient;
|
||||||
|
const event: GeneralLifecycleEvent = {
|
||||||
|
generalId: general.id,
|
||||||
|
outcome: 'deleted',
|
||||||
|
before: general,
|
||||||
|
year: 200,
|
||||||
|
month: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
await persistGeneralLifecycleEvents(prisma, [event], { serverId: 'archive-fixture' }, {});
|
||||||
|
|
||||||
|
expect(prisma.logEntry.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
generalId: general.id,
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: { text: true },
|
||||||
|
});
|
||||||
|
expect(upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
create: expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ const installArchive = async (page: Page) => {
|
|||||||
season: 51,
|
season: 51,
|
||||||
scenario: 2,
|
scenario: 2,
|
||||||
scenarioName: '천하쟁패',
|
scenarioName: '천하쟁패',
|
||||||
|
dynastyId: 7,
|
||||||
generals: [
|
generals: [
|
||||||
{
|
{
|
||||||
generalNo: 17,
|
generalNo: 17,
|
||||||
@@ -41,12 +42,22 @@ const installArchive = async (page: Page) => {
|
|||||||
personal: '대담',
|
personal: '대담',
|
||||||
special: '상재',
|
special: '상재',
|
||||||
special2: '신산',
|
special2: '신산',
|
||||||
|
historyCount: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (operation === 'archive.myPastPlayDetail') {
|
||||||
|
return response({
|
||||||
|
serverId: 'che_2024_01',
|
||||||
|
generalNo: 17,
|
||||||
|
name: '관우',
|
||||||
|
lastYearMonth: 21403,
|
||||||
|
history: ['<C>●</>214년 3월: 촉에 임관', '<Y>●</>214년 1월: 성도에서 거병'],
|
||||||
|
});
|
||||||
|
}
|
||||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||||
});
|
});
|
||||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||||
@@ -65,6 +76,12 @@ test('past plays is available without a current general and preserves desktop in
|
|||||||
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
|
||||||
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
|
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
|
||||||
await expect(page.locator('.general-name')).toHaveText('관우');
|
await expect(page.locator('.general-name')).toHaveText('관우');
|
||||||
|
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', '/che/dynasty/7');
|
||||||
|
const historyToggle = page.locator('.history-toggle');
|
||||||
|
await expect(historyToggle).toHaveText('보기 (2)');
|
||||||
|
await historyToggle.click();
|
||||||
|
await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible();
|
||||||
|
await expect(historyToggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
const geometry = await root.evaluate((element) => {
|
const geometry = await root.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
@@ -117,6 +134,6 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
|
|||||||
overflowX: getComputedStyle(element).overflowX,
|
overflowX: getComputedStyle(element).overflowX,
|
||||||
}));
|
}));
|
||||||
// The shared legacy shell keeps its historical 500 px minimum canvas.
|
// The shared legacy shell keeps its historical 500 px minimum canvas.
|
||||||
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 820, overflowX: 'auto' });
|
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' });
|
||||||
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column');
|
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { onMounted, ref } from 'vue';
|
|||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
|
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
|
||||||
|
type PastPlayDetail = Awaited<ReturnType<typeof trpc.archive.myPastPlayDetail.query>>;
|
||||||
|
type DetailState = {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
detail: PastPlayDetail | null;
|
||||||
|
};
|
||||||
|
|
||||||
const archive = ref<Archive | null>(null);
|
const archive = ref<Archive | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
const details = ref<Record<string, DetailState>>({});
|
||||||
|
|
||||||
const loadArchive = async () => {
|
const loadArchive = async () => {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
@@ -24,6 +32,43 @@ const loadArchive = async () => {
|
|||||||
|
|
||||||
const yearMonth = (value: number): string => `${Math.floor(value / 100)}년 ${value % 100}월`;
|
const yearMonth = (value: number): string => `${Math.floor(value / 100)}년 ${value % 100}월`;
|
||||||
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
|
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
|
||||||
|
const plainLog = (value: string): string => value.replace(/<[^>]+>/g, '');
|
||||||
|
const detailKey = (serverId: string, generalNo: number): string => `${serverId}:${generalNo}`;
|
||||||
|
|
||||||
|
const toggleHistory = async (serverId: string, generalNo: number): Promise<void> => {
|
||||||
|
const key = detailKey(serverId, generalNo);
|
||||||
|
const current = details.value[key];
|
||||||
|
if (current?.open) {
|
||||||
|
details.value = { ...details.value, [key]: { ...current, open: false } };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current?.detail) {
|
||||||
|
details.value = { ...details.value, [key]: { ...current, open: true } };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.value = {
|
||||||
|
...details.value,
|
||||||
|
[key]: { open: true, loading: true, error: null, detail: null },
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const detail = await trpc.archive.myPastPlayDetail.query({ serverId, generalNo });
|
||||||
|
details.value = {
|
||||||
|
...details.value,
|
||||||
|
[key]: { open: true, loading: false, error: null, detail },
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
details.value = {
|
||||||
|
...details.value,
|
||||||
|
[key]: {
|
||||||
|
open: true,
|
||||||
|
loading: false,
|
||||||
|
error: cause instanceof Error ? cause.message : '장수 열전을 불러오지 못했습니다.',
|
||||||
|
detail: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadArchive();
|
void loadArchive();
|
||||||
@@ -48,10 +93,19 @@ onMounted(() => {
|
|||||||
<section v-for="season in archive?.seasons ?? []" :key="season.serverId" class="season-card">
|
<section v-for="season in archive?.seasons ?? []" :key="season.serverId" class="season-card">
|
||||||
<div class="season-heading legacy-bg2">
|
<div class="season-heading legacy-bg2">
|
||||||
<strong>{{ season.serverId }}</strong>
|
<strong>{{ season.serverId }}</strong>
|
||||||
<span>
|
<div class="season-actions">
|
||||||
{{ season.scenarioName ?? '시나리오 미상' }}
|
<span>
|
||||||
<template v-if="season.season !== null"> · {{ season.season }}기</template>
|
{{ season.scenarioName ?? '시나리오 미상' }}
|
||||||
</span>
|
<template v-if="season.season !== null"> · {{ season.season }}기</template>
|
||||||
|
</span>
|
||||||
|
<RouterLink
|
||||||
|
v-if="season.dynastyId !== null"
|
||||||
|
class="legacy-button nation-archive-link"
|
||||||
|
:to="`/dynasty/${season.dynastyId}`"
|
||||||
|
>
|
||||||
|
이 기수 국가 정보
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table>
|
<table>
|
||||||
@@ -67,28 +121,82 @@ onMounted(() => {
|
|||||||
<th>성격</th>
|
<th>성격</th>
|
||||||
<th>내정 특기</th>
|
<th>내정 특기</th>
|
||||||
<th>전투 특기</th>
|
<th>전투 특기</th>
|
||||||
|
<th>장수 열전</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="general in season.generals" :key="general.generalNo">
|
<template v-for="general in season.generals" :key="general.generalNo">
|
||||||
<td class="general-name">{{ general.name }}</td>
|
<tr>
|
||||||
<td>
|
<td class="general-name">{{ general.name }}</td>
|
||||||
<span
|
<td>
|
||||||
class="nation-name"
|
<span
|
||||||
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }"
|
class="nation-name"
|
||||||
>
|
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }"
|
||||||
{{ general.nationName }}
|
>
|
||||||
</span>
|
{{ general.nationName }}
|
||||||
</td>
|
</span>
|
||||||
<td>{{ yearMonth(general.lastYearMonth) }}</td>
|
</td>
|
||||||
<td>{{ valueOrDash(general.leadership) }}</td>
|
<td>{{ yearMonth(general.lastYearMonth) }}</td>
|
||||||
<td>{{ valueOrDash(general.strength) }}</td>
|
<td>{{ valueOrDash(general.leadership) }}</td>
|
||||||
<td>{{ valueOrDash(general.intel) }}</td>
|
<td>{{ valueOrDash(general.strength) }}</td>
|
||||||
<td>{{ valueOrDash(general.officerLevel) }}</td>
|
<td>{{ valueOrDash(general.intel) }}</td>
|
||||||
<td>{{ valueOrDash(general.personal) }}</td>
|
<td>{{ valueOrDash(general.officerLevel) }}</td>
|
||||||
<td>{{ valueOrDash(general.special) }}</td>
|
<td>{{ valueOrDash(general.personal) }}</td>
|
||||||
<td>{{ valueOrDash(general.special2) }}</td>
|
<td>{{ valueOrDash(general.special) }}</td>
|
||||||
</tr>
|
<td>{{ valueOrDash(general.special2) }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="legacy-button history-toggle"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="
|
||||||
|
details[detailKey(season.serverId, general.generalNo)]?.open ?? false
|
||||||
|
"
|
||||||
|
@click="toggleHistory(season.serverId, general.generalNo)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
details[detailKey(season.serverId, general.generalNo)]?.open
|
||||||
|
? '접기'
|
||||||
|
: `보기 (${general.historyCount})`
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="details[detailKey(season.serverId, general.generalNo)]?.open" class="history-row">
|
||||||
|
<td colspan="11">
|
||||||
|
<p
|
||||||
|
v-if="details[detailKey(season.serverId, general.generalNo)]?.loading"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
장수 열전을 불러오는 중...
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="details[detailKey(season.serverId, general.generalNo)]?.error"
|
||||||
|
class="history-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ details[detailKey(season.serverId, general.generalNo)]?.error }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="
|
||||||
|
details[detailKey(season.serverId, general.generalNo)]?.detail?.history
|
||||||
|
.length === 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
보관된 장수 열전이 없습니다.
|
||||||
|
</p>
|
||||||
|
<ol v-else class="history-list">
|
||||||
|
<li
|
||||||
|
v-for="(entry, index) in details[
|
||||||
|
detailKey(season.serverId, general.generalNo)
|
||||||
|
]?.detail?.history ?? []"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
{{ plainLog(entry) }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,13 +298,24 @@ onMounted(() => {
|
|||||||
color: #bbb;
|
color: #bbb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.season-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-archive-link {
|
||||||
|
min-height: 24px;
|
||||||
|
padding-block: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.table-scroll {
|
.table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 820px;
|
min-width: 940px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
background: #191919;
|
background: #191919;
|
||||||
@@ -229,11 +348,45 @@ th {
|
|||||||
text-shadow: 0 1px 1px #000;
|
text-shadow: 0 1px 1px #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-toggle {
|
||||||
|
min-height: 24px;
|
||||||
|
padding-block: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
background: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row p {
|
||||||
|
margin: 0;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-error {
|
||||||
|
color: #ff8d8d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 26px;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.title-row,
|
.title-row,
|
||||||
.season-heading {
|
.season-heading {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.season-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -77,6 +77,26 @@ excluded. In particular, `general`, `city`, `nation`, their turn queues,
|
|||||||
`ng_betting`, `reserved_open`, `select_pool`, `select_npc_token` and `plock`
|
`ng_betting`, `reserved_open`, `select_pool`, `select_npc_token` and `plock`
|
||||||
must not be used to reconstruct a running season.
|
must not be used to reconstruct a running season.
|
||||||
|
|
||||||
|
## Archived play read model
|
||||||
|
|
||||||
|
`/past-plays` is an authenticated, read-only projection. The server derives the
|
||||||
|
archive owner from the game session and never accepts an owner ID from the
|
||||||
|
browser.
|
||||||
|
|
||||||
|
- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with
|
||||||
|
`ng_games`, the latest matching `ng_old_nations` snapshot and an optional
|
||||||
|
`emperior` row. It returns summary fields and a link target for the existing
|
||||||
|
public dynasty/nation detail.
|
||||||
|
- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in
|
||||||
|
the database predicate before returning `data.history`. A foreign or missing
|
||||||
|
record uses the same not-found response.
|
||||||
|
- Legacy `data.history` may be either an array or a `<br>`-joined string. The API
|
||||||
|
normalizes both to a newest-first string array, and the frontend renders plain
|
||||||
|
text rather than archived markup.
|
||||||
|
- Runtime death and unification archival writes the current general
|
||||||
|
`GENERAL/HISTORY` rows into the same `data.history` field, so newly completed
|
||||||
|
seasons remain compatible with imported rows.
|
||||||
|
|
||||||
## Cutover procedure
|
## Cutover procedure
|
||||||
|
|
||||||
1. Keep the original compressed dumps immutable and restore each source to a
|
1. Keep the original compressed dumps immutable and restore each source to a
|
||||||
@@ -90,8 +110,8 @@ must not be used to reconstruct a running season.
|
|||||||
run the same commands with `--apply`.
|
run the same commands with `--apply`.
|
||||||
6. Repeat each apply. Counts must remain unchanged.
|
6. Repeat each apply. Counts must remain unchanged.
|
||||||
7. Verify Kakao migration timestamps, password-hash shapes, archive ownership,
|
7. Verify Kakao migration timestamps, password-hash shapes, archive ownership,
|
||||||
old-nation/history duplicate preservation and the `/past-plays` authenticated
|
old-nation/history duplicate preservation, `/past-plays` list/detail access,
|
||||||
read path.
|
foreign-owner denial and the dynasty link.
|
||||||
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
|
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
|
||||||
pre-cutover PostgreSQL backup; it does not reverse individual importer
|
pre-cutover PostgreSQL backup; it does not reverse individual importer
|
||||||
upserts.
|
upserts.
|
||||||
|
|||||||
Reference in New Issue
Block a user