fix(economy): make catalog translation atomic
This commit is contained in:
1 parent
daf272bad4
commit
3b568e30b4
2 files changed
+472
-50
No files matched your search
+323
@@ -0,0 +1,323 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
const doubles = vi.hoisted(() => ({
|
||||
events: [] as string[],
|
||||
globalSelect: vi.fn(),
|
||||
globalUpdate: vi.fn(),
|
||||
invalidateFurniDataCache: vi.fn(),
|
||||
patchFurniEntryNames: vi.fn(),
|
||||
rconUpdateCatalog: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
transactionSelect: vi.fn(),
|
||||
transactionUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
const transactionToken = {
|
||||
select: doubles.transactionSelect,
|
||||
update: doubles.transactionUpdate,
|
||||
};
|
||||
|
||||
vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...actual.db,
|
||||
select: doubles.globalSelect,
|
||||
transaction: doubles.transaction,
|
||||
update: doubles.globalUpdate,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/services/catalog-items-loader", () => ({
|
||||
invalidateFurniDataCache: doubles.invalidateFurniDataCache,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/furni-data", () => ({
|
||||
patchFurniEntryNames: doubles.patchFurniEntryNames,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: { updateCatalog: doubles.rconUpdateCatalog },
|
||||
}));
|
||||
|
||||
import { executeEconomyCatalogMutation } from "./mutation-runtime-catalog";
|
||||
import { EconomyCommittedExternalFailure } from "./mutations-production";
|
||||
|
||||
const capability: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
|
||||
const context = {
|
||||
capability,
|
||||
correlationId: "catalog-translation",
|
||||
legacy: false,
|
||||
};
|
||||
|
||||
const baseOne = {
|
||||
id: 1,
|
||||
publicName: "Old chair",
|
||||
itemName: "chair_class",
|
||||
type: "s",
|
||||
spriteId: 10,
|
||||
};
|
||||
const baseTwo = {
|
||||
id: 2,
|
||||
publicName: "Old table",
|
||||
itemName: "table_class",
|
||||
type: "s",
|
||||
spriteId: 20,
|
||||
};
|
||||
|
||||
function selection(rows: readonly unknown[], event: string) {
|
||||
const resolve = () => {
|
||||
doubles.events.push(event);
|
||||
return Promise.resolve(rows);
|
||||
};
|
||||
const thenable = {
|
||||
for: resolve,
|
||||
limit: resolve,
|
||||
};
|
||||
return { from: () => ({ where: () => thenable }) };
|
||||
}
|
||||
|
||||
function update(event: string) {
|
||||
return {
|
||||
set: () => ({
|
||||
where: async () => {
|
||||
doubles.events.push(event);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(doubles)) {
|
||||
if (Array.isArray(mock)) mock.length = 0;
|
||||
else mock.mockReset();
|
||||
}
|
||||
doubles.transaction.mockImplementation(async (run) => {
|
||||
doubles.events.push("transaction:begin");
|
||||
const result = await run(transactionToken);
|
||||
doubles.events.push("transaction:commit");
|
||||
return result;
|
||||
});
|
||||
doubles.transactionUpdate.mockImplementation(() =>
|
||||
update("transaction:update"),
|
||||
);
|
||||
doubles.globalUpdate.mockImplementation(() => update("global:update"));
|
||||
doubles.patchFurniEntryNames.mockImplementation(async () => {
|
||||
doubles.events.push("external:furni");
|
||||
return { updated: 2, inserted: 0 };
|
||||
});
|
||||
doubles.invalidateFurniDataCache.mockImplementation(() => {
|
||||
doubles.events.push("external:invalidate");
|
||||
});
|
||||
doubles.rconUpdateCatalog.mockImplementation(async () => {
|
||||
doubles.events.push("external:rcon");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog item translation atomicity", () => {
|
||||
it("reports every missing or rejected target before issuing any update", async () => {
|
||||
doubles.globalSelect
|
||||
.mockImplementationOnce(() => selection([baseOne], "global:base-1"))
|
||||
.mockImplementationOnce(() => selection([], "global:catalog-1"))
|
||||
.mockImplementationOnce(() => selection([], "global:base-999"));
|
||||
doubles.transactionSelect.mockImplementationOnce(() =>
|
||||
selection([baseOne], "transaction:bases"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeEconomyCatalogMutation(
|
||||
"catalog-item.translate",
|
||||
{
|
||||
items: [
|
||||
{ id: 1, publicName: "New chair" },
|
||||
{ id: 999, publicName: "Missing" },
|
||||
{ id: "bad", publicName: "Rejected" },
|
||||
],
|
||||
},
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
name: "EconomyMutationFailure",
|
||||
code: "VALIDATION",
|
||||
fieldErrors: {
|
||||
missingIds: ["999"],
|
||||
rejectedIds: ["bad"],
|
||||
},
|
||||
});
|
||||
expect(doubles.transaction).toHaveBeenCalledOnce();
|
||||
expect(doubles.transactionUpdate).not.toHaveBeenCalled();
|
||||
expect(doubles.globalUpdate).not.toHaveBeenCalled();
|
||||
expect(doubles.patchFurniEntryNames).not.toHaveBeenCalled();
|
||||
expect(doubles.rconUpdateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preloads every target, commits exact before/after changes atomically, then patches external state", async () => {
|
||||
doubles.globalSelect
|
||||
.mockImplementationOnce(() => selection([baseOne], "global:base-1"))
|
||||
.mockImplementationOnce(() =>
|
||||
selection([{ id: 101, catalogName: "Old chair" }], "global:catalog-1"),
|
||||
)
|
||||
.mockImplementationOnce(() => selection([baseTwo], "global:base-2"))
|
||||
.mockImplementationOnce(() =>
|
||||
selection([{ id: 102, catalogName: "Old table" }], "global:catalog-2"),
|
||||
);
|
||||
doubles.transactionSelect
|
||||
.mockImplementationOnce(() =>
|
||||
selection([baseTwo, baseOne], "transaction:bases"),
|
||||
)
|
||||
.mockImplementationOnce(() =>
|
||||
selection(
|
||||
[{ id: 101, catalogName: "Old chair" }],
|
||||
"transaction:catalog-1",
|
||||
),
|
||||
)
|
||||
.mockImplementationOnce(() =>
|
||||
selection(
|
||||
[{ id: 102, catalogName: "Old table" }],
|
||||
"transaction:catalog-2",
|
||||
),
|
||||
);
|
||||
|
||||
const result = await executeEconomyCatalogMutation(
|
||||
"catalog-item.translate",
|
||||
{
|
||||
items: [
|
||||
{ id: 2, publicName: "New table", description: "Table text" },
|
||||
{ id: 1, publicName: "New chair", description: "Chair text" },
|
||||
],
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
before: {
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
publicName: "Old chair",
|
||||
catalogItems: [{ id: "101", catalogName: "Old chair" }],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
publicName: "Old table",
|
||||
catalogItems: [{ id: "102", catalogName: "Old table" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
after: {
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
publicName: "New chair",
|
||||
catalogItems: [{ id: "101", catalogName: "New chair" }],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
publicName: "New table",
|
||||
catalogItems: [{ id: "102", catalogName: "New table" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
namesUpdated: 2,
|
||||
descriptionsUpdated: 2,
|
||||
updatedIds: ["1", "2"],
|
||||
missingIds: [],
|
||||
rejectedIds: [],
|
||||
furniDataUpdated: 2,
|
||||
furniDataInserted: 0,
|
||||
},
|
||||
});
|
||||
expect(doubles.globalSelect).not.toHaveBeenCalled();
|
||||
expect(doubles.globalUpdate).not.toHaveBeenCalled();
|
||||
expect(doubles.transactionUpdate).toHaveBeenCalledTimes(4);
|
||||
expect(doubles.events).toEqual([
|
||||
"transaction:begin",
|
||||
"transaction:bases",
|
||||
"transaction:catalog-1",
|
||||
"transaction:catalog-2",
|
||||
"transaction:update",
|
||||
"transaction:update",
|
||||
"transaction:update",
|
||||
"transaction:update",
|
||||
"transaction:commit",
|
||||
"external:furni",
|
||||
"external:invalidate",
|
||||
"external:rcon",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rolls the batch back and skips external work when a later database update fails", async () => {
|
||||
doubles.transactionSelect
|
||||
.mockImplementationOnce(() =>
|
||||
selection([baseOne, baseTwo], "transaction:bases"),
|
||||
)
|
||||
.mockImplementationOnce(() => selection([], "transaction:catalog-1"))
|
||||
.mockImplementationOnce(() => selection([], "transaction:catalog-2"));
|
||||
let updateCount = 0;
|
||||
doubles.transactionUpdate.mockImplementation(() => ({
|
||||
set: () => ({
|
||||
where: async () => {
|
||||
updateCount += 1;
|
||||
doubles.events.push(`transaction:update-${updateCount}`);
|
||||
if (updateCount === 2) throw new Error("database update failed");
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
executeEconomyCatalogMutation(
|
||||
"catalog-item.translate",
|
||||
{
|
||||
items: [
|
||||
{ id: 1, publicName: "New chair" },
|
||||
{ id: 2, publicName: "New table" },
|
||||
],
|
||||
},
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow("database update failed");
|
||||
expect(doubles.transaction).toHaveBeenCalledOnce();
|
||||
expect(doubles.events).not.toContain("transaction:commit");
|
||||
expect(doubles.patchFurniEntryNames).not.toHaveBeenCalled();
|
||||
expect(doubles.rconUpdateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the committed-external-failure envelope only after the database transaction commits", async () => {
|
||||
doubles.globalSelect
|
||||
.mockImplementationOnce(() => selection([baseOne], "global:base-1"))
|
||||
.mockImplementationOnce(() => selection([], "global:catalog-1"));
|
||||
doubles.transactionSelect
|
||||
.mockImplementationOnce(() => selection([baseOne], "transaction:bases"))
|
||||
.mockImplementationOnce(() => selection([], "transaction:catalog-1"));
|
||||
doubles.patchFurniEntryNames.mockImplementationOnce(async () => {
|
||||
doubles.events.push("external:furni-failed");
|
||||
throw new Error("furni store unavailable");
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeEconomyCatalogMutation(
|
||||
"catalog-item.translate",
|
||||
{ items: [{ id: 1, publicName: "New chair" }] },
|
||||
context,
|
||||
),
|
||||
).rejects.toBeInstanceOf(EconomyCommittedExternalFailure);
|
||||
expect(doubles.transaction).toHaveBeenCalledOnce();
|
||||
expect(doubles.events).toContain("transaction:commit");
|
||||
expect(doubles.events.indexOf("transaction:commit")).toBeLessThan(
|
||||
doubles.events.indexOf("external:furni-failed"),
|
||||
);
|
||||
expect(doubles.rconUpdateCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -799,20 +799,49 @@ async function catalogItemTranslate(
|
||||
data.items.length > 500
|
||||
)
|
||||
throw validation();
|
||||
const furniPatches: Array<{
|
||||
classname: string;
|
||||
itemType: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
spriteId?: number;
|
||||
createIfMissing?: boolean;
|
||||
}> = [];
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
for (const candidate of data.items) {
|
||||
const item = record(candidate);
|
||||
const id = positiveInteger(item.id);
|
||||
const [base] = await db
|
||||
|
||||
type TranslationRequest = {
|
||||
readonly id: number;
|
||||
readonly nextName: string;
|
||||
readonly nextDescription: string;
|
||||
};
|
||||
const requests: TranslationRequest[] = [];
|
||||
const rejectedIds: string[] = [];
|
||||
const requestedIds = new Set<number>();
|
||||
for (const [index, candidate] of data.items.entries()) {
|
||||
const rejectedLabel =
|
||||
typeof candidate === "object" &&
|
||||
candidate !== null &&
|
||||
!Array.isArray(candidate) &&
|
||||
"id" in candidate
|
||||
? String((candidate as Record<string, unknown>).id).slice(0, 64)
|
||||
: `index:${index}`;
|
||||
try {
|
||||
const item = record(candidate);
|
||||
const id = positiveInteger(item.id);
|
||||
const nextName = text(item.publicName, 56);
|
||||
const nextDescription = text(item.description, 500);
|
||||
if ((!nextName && !nextDescription) || requestedIds.has(id)) {
|
||||
rejectedIds.push(String(id));
|
||||
continue;
|
||||
}
|
||||
requestedIds.add(id);
|
||||
requests.push({ id, nextName, nextDescription });
|
||||
} catch {
|
||||
rejectedIds.push(rejectedLabel || `index:${index}`);
|
||||
}
|
||||
}
|
||||
requests.sort((left, right) => left.id - right.id);
|
||||
if (requests.length === 0) {
|
||||
throw validation({
|
||||
missingIds: [],
|
||||
rejectedIds: [...new Set(rejectedIds)],
|
||||
});
|
||||
}
|
||||
|
||||
const committed = await db.transaction(async (transaction) => {
|
||||
const connection = transaction as unknown as EconomyDatabase;
|
||||
const bases = await connection
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
publicName: ItemsBase.publicName,
|
||||
@@ -821,19 +850,36 @@ async function catalogItemTranslate(
|
||||
spriteId: ItemsBase.spriteId,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, id))
|
||||
.limit(1);
|
||||
if (!base) continue;
|
||||
const nextName = text(item.publicName, 56);
|
||||
const nextDescription = text(item.description, 500);
|
||||
if (nextName && nextName !== base.publicName) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set({ publicName: nextName })
|
||||
.where(eq(ItemsBase.id, id));
|
||||
const idText = String(id);
|
||||
const related = await db
|
||||
.select({ id: CatalogItems.id })
|
||||
.where(
|
||||
inArray(
|
||||
ItemsBase.id,
|
||||
requests.map((request) => request.id),
|
||||
),
|
||||
)
|
||||
.for("update");
|
||||
const baseById = new Map(bases.map((base) => [base.id, base]));
|
||||
const missingIds = requests
|
||||
.filter((request) => !baseById.has(request.id))
|
||||
.map((request) => String(request.id));
|
||||
for (const request of requests) {
|
||||
const base = baseById.get(request.id);
|
||||
if (base && !base.itemName) rejectedIds.push(String(request.id));
|
||||
}
|
||||
if (missingIds.length > 0 || rejectedIds.length > 0) {
|
||||
throw validation({
|
||||
missingIds,
|
||||
rejectedIds: [...new Set(rejectedIds)],
|
||||
});
|
||||
}
|
||||
|
||||
const relatedByBaseId = new Map<
|
||||
number,
|
||||
Array<{ id: number; catalogName: string }>
|
||||
>();
|
||||
for (const request of requests) {
|
||||
const idText = String(request.id);
|
||||
const related = await connection
|
||||
.select({ id: CatalogItems.id, catalogName: CatalogItems.catalogName })
|
||||
.from(CatalogItems)
|
||||
.where(
|
||||
or(
|
||||
@@ -842,57 +888,110 @@ async function catalogItemTranslate(
|
||||
like(CatalogItems.itemIds, `%;${idText};%`),
|
||||
like(CatalogItems.itemIds, `%;${idText}`),
|
||||
),
|
||||
);
|
||||
for (const row of related)
|
||||
await db
|
||||
.update(CatalogItems)
|
||||
.set({ catalogName: nextName })
|
||||
.where(eq(CatalogItems.id, row.id));
|
||||
namesUpdated += 1;
|
||||
)
|
||||
.for("update");
|
||||
relatedByBaseId.set(request.id, related);
|
||||
}
|
||||
if (nextDescription || nextName) {
|
||||
if (nextDescription) descriptionsUpdated += 1;
|
||||
|
||||
const beforeItems: Array<Record<string, unknown>> = [];
|
||||
const afterItems: Array<Record<string, unknown>> = [];
|
||||
const furniPatches: Array<{
|
||||
classname: string;
|
||||
itemType: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
spriteId?: number;
|
||||
createIfMissing?: boolean;
|
||||
}> = [];
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
for (const request of requests) {
|
||||
const base = baseById.get(request.id);
|
||||
if (!base) throw validation({ missingIds: [String(request.id)] });
|
||||
const related = relatedByBaseId.get(request.id) ?? [];
|
||||
const nameChanged =
|
||||
Boolean(request.nextName) && request.nextName !== base.publicName;
|
||||
const nextPublicName = nameChanged ? request.nextName : base.publicName;
|
||||
beforeItems.push({
|
||||
id: String(request.id),
|
||||
publicName: base.publicName,
|
||||
catalogItems: related.map((row) => ({
|
||||
id: String(row.id),
|
||||
catalogName: row.catalogName,
|
||||
})),
|
||||
});
|
||||
afterItems.push({
|
||||
id: String(request.id),
|
||||
publicName: nextPublicName,
|
||||
catalogItems: related.map((row) => ({
|
||||
id: String(row.id),
|
||||
catalogName: nameChanged ? request.nextName : row.catalogName,
|
||||
})),
|
||||
});
|
||||
if (nameChanged) {
|
||||
await connection
|
||||
.update(ItemsBase)
|
||||
.set({ publicName: request.nextName })
|
||||
.where(eq(ItemsBase.id, request.id));
|
||||
for (const row of related) {
|
||||
await connection
|
||||
.update(CatalogItems)
|
||||
.set({ catalogName: request.nextName })
|
||||
.where(eq(CatalogItems.id, row.id));
|
||||
}
|
||||
namesUpdated += 1;
|
||||
}
|
||||
if (request.nextDescription) descriptionsUpdated += 1;
|
||||
furniPatches.push({
|
||||
classname: base.itemName,
|
||||
itemType: base.type || "s",
|
||||
name: nextName || base.publicName || base.itemName,
|
||||
description: nextDescription,
|
||||
name: request.nextName || base.publicName || base.itemName,
|
||||
description: request.nextDescription,
|
||||
spriteId: base.spriteId,
|
||||
createIfMissing: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const snapshot: EconomyMutationSnapshot = {
|
||||
before: null,
|
||||
after: { namesUpdated, descriptionsUpdated },
|
||||
output: { namesUpdated, descriptionsUpdated },
|
||||
};
|
||||
const output = {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
updatedIds: requests.map((request) => String(request.id)),
|
||||
missingIds: [] as string[],
|
||||
rejectedIds: [] as string[],
|
||||
};
|
||||
return {
|
||||
furniPatches,
|
||||
snapshot: {
|
||||
before: { items: beforeItems },
|
||||
after: { items: afterItems },
|
||||
output,
|
||||
} satisfies EconomyMutationSnapshot,
|
||||
};
|
||||
});
|
||||
try {
|
||||
let furniDataUpdated = 0;
|
||||
let furniDataInserted = 0;
|
||||
if (furniPatches.length > 0) {
|
||||
if (committed.furniPatches.length > 0) {
|
||||
const [{ patchFurniEntryNames }, { invalidateFurniDataCache }] =
|
||||
await Promise.all([
|
||||
import("@/lib/services/furni-data"),
|
||||
import("@/lib/services/catalog-items-loader"),
|
||||
]);
|
||||
const result = await patchFurniEntryNames(furniPatches);
|
||||
const result = await patchFurniEntryNames(committed.furniPatches);
|
||||
if (result.updated > 0 || result.inserted > 0) invalidateFurniDataCache();
|
||||
furniDataUpdated = result.updated;
|
||||
furniDataInserted = result.inserted;
|
||||
}
|
||||
return synchronizeCatalog({
|
||||
...snapshot,
|
||||
...committed.snapshot,
|
||||
output: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
...committed.snapshot.output,
|
||||
furniDataUpdated,
|
||||
furniDataInserted,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof EconomyCommittedExternalFailure) throw error;
|
||||
throw new EconomyCommittedExternalFailure(snapshot);
|
||||
throw new EconomyCommittedExternalFailure(committed.snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user