fix(housekeeping): correct people partial audit evidence
This commit is contained in:
1 parent
91c9efcbb4
commit
fcd1dfd96e
5 files changed
+339
-89
No files matched your search
@@ -254,4 +254,57 @@ pnpm exec biome check --formatter-enabled=false <28 exact changed TypeScript/TSX
|
||||
Checked 28 files. No fixes applied.
|
||||
```
|
||||
|
||||
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
|
||||
|
||||
## Official review fix round 3
|
||||
|
||||
The round-2 re-review reported 0 Critical, 2 Important, and 2 adjacent Minor findings. This round addresses all four findings without changing the nine-route manifest, the public command IDs, or legacy external call ordering and permissions.
|
||||
|
||||
### RED evidence
|
||||
|
||||
```text
|
||||
Focused external-audit, bulk-production, and dispatcher matrix
|
||||
Test Files 2 failed (2)
|
||||
Tests 15 failed | 54 passed (69)
|
||||
|
||||
The ten external-only false/throw cases persisted optimistic desired after-state instead of confirmed unchanged or unknown delivery evidence. Four bulk currency/badge false/throw cases reported completed=0 and Database error after a committed database write. The dispatcher accepted one ok:false result carrying impossible completion metadata.
|
||||
```
|
||||
|
||||
### GREEN implementation
|
||||
|
||||
- External-only alert, disconnect, mute, unmute, and send-currency keep desired state in intent/success evidence. Confirmed RCON `false` now writes a dedicated unchanged/no-delivery failure snapshot; an exception writes unknown delivery with a null after-state. Correlation and failure outcome stay identical across intent/outcome records.
|
||||
- Bulk currency and badge count a successful database write before RCON. RCON false/throw is additive `externalSyncFailures` sync debt, never a database failure; `failedIds` remains reserved for database/business failures and the result is typed partial with an explicit no-automatic-retry warning.
|
||||
- Webhook notification remains explicit fire-and-forget best effort (`void notify(...)`) and no longer participates in mutation completion. The impossible promise-rejection test was replaced with the real void contract.
|
||||
- The dispatcher runtime schema now accepts `completion` only for `ok: true`, matching the TypeScript `HousekeepingResult` contract; failure envelopes containing it are rejected as malformed.
|
||||
|
||||
### Final verification after review fix round 3
|
||||
|
||||
```text
|
||||
Focused external audit + production bulk + dispatcher
|
||||
Test Files 3 passed (3)
|
||||
Tests 73 passed (73)
|
||||
|
||||
People + foundation + legacy wrappers + staff-smoke matrix
|
||||
Test Files 48 passed (48)
|
||||
Tests 470 passed (470)
|
||||
|
||||
pnpm test:housekeeping
|
||||
Test Files 58 passed (58)
|
||||
Tests 516 passed (516)
|
||||
|
||||
pnpm test
|
||||
Test Files 206 passed | 3 skipped (209)
|
||||
Tests 1359 passed | 5 skipped (1364)
|
||||
|
||||
pnpm typecheck
|
||||
tsc --noEmit
|
||||
Exit 0
|
||||
|
||||
pnpm exec biome check --formatter-enabled=false <4 exact changed source/test files>
|
||||
Checked 4 files. No fixes applied.
|
||||
|
||||
git diff --check
|
||||
Exit 0
|
||||
```
|
||||
|
||||
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
|
||||
+211
-50
@@ -578,6 +578,117 @@ describe("People production workflow adapter", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"user.alert",
|
||||
"alertUser",
|
||||
{ userId: 7, message: "Hello" },
|
||||
{ userId: 7, alertDelivered: false },
|
||||
],
|
||||
[
|
||||
"user.disconnect",
|
||||
"disconnectUser",
|
||||
{ userId: 7, reason: "Stuck session" },
|
||||
target({ online: true }),
|
||||
],
|
||||
[
|
||||
"user.mute",
|
||||
"muteUser",
|
||||
{ userId: 7, duration: 30, reason: "Chat abuse" },
|
||||
target({ online: true }),
|
||||
],
|
||||
[
|
||||
"user.unmute",
|
||||
"unmuteUser",
|
||||
{ userId: 7, reason: "Appeal accepted" },
|
||||
target({ online: true }),
|
||||
],
|
||||
[
|
||||
"user.send-currency",
|
||||
"giveCredits",
|
||||
{ userId: 7, amount: 25, reason: "Event award" },
|
||||
target({ online: true }),
|
||||
],
|
||||
] as const)(
|
||||
"audits confirmed external-only %s failure without optimistic state",
|
||||
async (operation, rconMethod, input, failureAfter) => {
|
||||
if (operation !== "user.alert") mocks.selectQueue.push([target()]);
|
||||
mocks.rcon[rconMethod].mockResolvedValueOnce(false);
|
||||
const correlationId = `confirmed-${operation}`;
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId, legacy: false },
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
correlationId,
|
||||
});
|
||||
const entries = mocks.audit.mock.calls.map(
|
||||
(call) => call[0] as AuditEntry,
|
||||
);
|
||||
expect(entries.map(({ outcome }) => outcome)).toEqual([
|
||||
"intent",
|
||||
"failure",
|
||||
]);
|
||||
expect(entries[1]).toMatchObject({ correlationId, after: failureAfter });
|
||||
expect(entries[1]?.after).not.toEqual(entries[0]?.after);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["user.alert", "alertUser", { userId: 7, message: "Hello" }],
|
||||
[
|
||||
"user.disconnect",
|
||||
"disconnectUser",
|
||||
{ userId: 7, reason: "Stuck session" },
|
||||
],
|
||||
[
|
||||
"user.mute",
|
||||
"muteUser",
|
||||
{ userId: 7, duration: 30, reason: "Chat abuse" },
|
||||
],
|
||||
["user.unmute", "unmuteUser", { userId: 7, reason: "Appeal accepted" }],
|
||||
[
|
||||
"user.send-currency",
|
||||
"giveCredits",
|
||||
{ userId: 7, amount: 25, reason: "Event award" },
|
||||
],
|
||||
] as const)(
|
||||
"audits unknown external-only %s delivery with a null after-state",
|
||||
async (operation, rconMethod, input) => {
|
||||
if (operation !== "user.alert") mocks.selectQueue.push([target()]);
|
||||
mocks.rcon[rconMethod].mockRejectedValueOnce(
|
||||
new Error("RCON unavailable"),
|
||||
);
|
||||
const correlationId = `unknown-${operation}`;
|
||||
|
||||
const result = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId, legacy: false },
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
correlationId,
|
||||
});
|
||||
const entries = mocks.audit.mock.calls.map(
|
||||
(call) => call[0] as AuditEntry,
|
||||
);
|
||||
expect(entries.map(({ outcome }) => outcome)).toEqual([
|
||||
"intent",
|
||||
"failure",
|
||||
]);
|
||||
expect(entries[1]).toMatchObject({ correlationId });
|
||||
expect(entries[1]?.after).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("executes bulk ban, currency, and badge with database, RCON, and legacy activity outcomes", async () => {
|
||||
await expect(
|
||||
peopleMutationService.execute(invocation, "users.bulk-ban", {
|
||||
@@ -625,81 +736,131 @@ describe("People production workflow adapter", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports bulk currency as partial after database commit when RCON reports false", async () => {
|
||||
mocks.rcon.giveCredits.mockResolvedValue(false);
|
||||
const result = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "bulk-currency-partial", legacy: false },
|
||||
"users.bulk-currency",
|
||||
{ userIds: [7], type: "credits", amount: 50 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
data: {
|
||||
after: {
|
||||
completed: 0,
|
||||
total: 1,
|
||||
failedIds: [{ userId: 7, reason: "Database error" }],
|
||||
it.each(["false", "throw"] as const)(
|
||||
"reports bulk currency database completion separately when RCON reports %s",
|
||||
async (failureMode) => {
|
||||
if (failureMode === "false") {
|
||||
mocks.rcon.giveCredits.mockResolvedValue(false);
|
||||
} else {
|
||||
mocks.rcon.giveCredits.mockRejectedValue(new Error("RCON unavailable"));
|
||||
}
|
||||
const result = await peopleMutationService.execute(
|
||||
{
|
||||
...invocation,
|
||||
correlationId: "bulk-currency-partial",
|
||||
legacy: false,
|
||||
},
|
||||
},
|
||||
correlationId: "bulk-currency-partial",
|
||||
});
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
});
|
||||
it("returns typed partial after committed update or reset when notification fails", async () => {
|
||||
mocks.notify.mockRejectedValue(new Error("notification unavailable"));
|
||||
"users.bulk-currency",
|
||||
{ userIds: [7], type: "credits", amount: 50 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
data: {
|
||||
after: {
|
||||
completed: 1,
|
||||
total: 1,
|
||||
failedIds: [],
|
||||
externalSyncFailures: [
|
||||
{
|
||||
userId: 7,
|
||||
reason:
|
||||
"Database applied; emulator sync failed. Do not retry automatically.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
correlationId: "bulk-currency-partial",
|
||||
});
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["false", "throw"] as const)(
|
||||
"reports bulk badge database completion separately when RCON reports %s",
|
||||
async (failureMode) => {
|
||||
mocks.selectQueue.push([], [{ maxSlot: 2 }]);
|
||||
if (failureMode === "false") {
|
||||
mocks.rcon.giveBadge.mockResolvedValue(false);
|
||||
} else {
|
||||
mocks.rcon.giveBadge.mockRejectedValue(new Error("RCON unavailable"));
|
||||
}
|
||||
const result = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "bulk-badge-partial", legacy: false },
|
||||
"users.bulk-badge",
|
||||
{ userIds: [7], badgeCode: "ADM" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
data: {
|
||||
after: {
|
||||
completed: 1,
|
||||
total: 1,
|
||||
failedIds: [],
|
||||
externalSyncFailures: [
|
||||
{
|
||||
userId: 7,
|
||||
reason:
|
||||
"Database applied; emulator sync failed. Do not retry automatically.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
correlationId: "bulk-badge-partial",
|
||||
});
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps best-effort notification outside update and reset completion", async () => {
|
||||
mocks.selectQueue.push([target()]);
|
||||
const updated = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "update-notify-partial", legacy: false },
|
||||
{ ...invocation, correlationId: "update-notify", legacy: false },
|
||||
"user.update",
|
||||
{ userId: 7, fields: { motto: "Updated" } },
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
correlationId: "update-notify-partial",
|
||||
});
|
||||
expect(updated).toMatchObject({ ok: true, correlationId: "update-notify" });
|
||||
expect(updated).not.toHaveProperty("completion");
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
).toEqual(["intent", "success"]);
|
||||
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveServerContext.mockResolvedValue(context());
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.hashPassword.mockResolvedValue("hashed-password");
|
||||
mocks.notify.mockRejectedValue(new Error("notification unavailable"));
|
||||
mocks.selectQueue.push([target()]);
|
||||
const reset = await peopleMutationService.execute(
|
||||
{ ...invocation, correlationId: "reset-notify-partial", legacy: false },
|
||||
{ ...invocation, correlationId: "reset-notify", legacy: false },
|
||||
"user.reset-password",
|
||||
{ userId: 7, reason: "Verified owner" },
|
||||
);
|
||||
expect(reset).toMatchObject({
|
||||
ok: true,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
data: { output: { newPassword: expect.any(String) } },
|
||||
correlationId: "reset-notify-partial",
|
||||
correlationId: "reset-notify",
|
||||
});
|
||||
expect(reset).not.toHaveProperty("completion");
|
||||
expect(
|
||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||
).toEqual(["intent", "partial"]);
|
||||
).toEqual(["intent", "success"]);
|
||||
expect(mocks.notify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves legacy throw and false semantics while commands retain partial truth", async () => {
|
||||
mocks.selectQueue.push(
|
||||
[target()],
|
||||
|
||||
@@ -118,6 +118,8 @@ class PeopleMutationFailure extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class ConfirmedExternalNoopFailure extends PeopleMutationFailure {}
|
||||
|
||||
function operationCapability(operation: PeopleMutationOperation) {
|
||||
if (operation === "user.ban" || operation === "user.unban") {
|
||||
return anyCapability(PERMS.USERS_BAN);
|
||||
@@ -307,7 +309,7 @@ function userIds(value: unknown, legacy: boolean): number[] {
|
||||
|
||||
async function requireRcon(result: boolean): Promise<void> {
|
||||
if (!result) {
|
||||
throw new PeopleMutationFailure(
|
||||
throw new ConfirmedExternalNoopFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
@@ -402,10 +404,16 @@ async function finalizeExternalWithAudit(
|
||||
snapshot: PeopleMutationSnapshot,
|
||||
execute: () => Promise<void>,
|
||||
mutationCommitted = true,
|
||||
confirmedFailureSnapshot?: PeopleMutationSnapshot,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
try {
|
||||
await execute();
|
||||
} catch (error) {
|
||||
const failureSnapshot = mutationCommitted
|
||||
? snapshot
|
||||
: error instanceof ConfirmedExternalNoopFailure
|
||||
? (confirmedFailureSnapshot ?? { before: snapshot.before, after: null })
|
||||
: { before: snapshot.before, after: null };
|
||||
let audit: HousekeepingPartialCompletion["audit"] = "persisted";
|
||||
try {
|
||||
await logAudit(
|
||||
@@ -414,7 +422,7 @@ async function finalizeExternalWithAudit(
|
||||
operation,
|
||||
target,
|
||||
targetId,
|
||||
snapshot,
|
||||
failureSnapshot,
|
||||
mutationCommitted ? "partial" : "failure",
|
||||
),
|
||||
);
|
||||
@@ -482,6 +490,7 @@ async function runExternalWithAudit(
|
||||
targetId: number | undefined,
|
||||
snapshot: PeopleMutationSnapshot,
|
||||
execute: () => Promise<void>,
|
||||
confirmedFailureSnapshot: PeopleMutationSnapshot,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
@@ -501,6 +510,7 @@ async function runExternalWithAudit(
|
||||
snapshot,
|
||||
execute,
|
||||
false,
|
||||
confirmedFailureSnapshot,
|
||||
);
|
||||
}
|
||||
async function executeUserMutation(
|
||||
@@ -524,6 +534,7 @@ async function executeUserMutation(
|
||||
userId,
|
||||
snapshot,
|
||||
async () => requireRcon(await rcon.alertUser(userId, message)),
|
||||
{ before: null, after: { userId, alertDelivered: false } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -592,13 +603,12 @@ async function executeUserMutation(
|
||||
snapshot,
|
||||
async () => {
|
||||
invalidateLoginCache(target.username);
|
||||
const notification = notify({
|
||||
void notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
targetId: userId,
|
||||
});
|
||||
if (!context.legacy) await notification;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -704,7 +714,7 @@ async function executeUserMutation(
|
||||
snapshot,
|
||||
async () => {
|
||||
await requireRcon(await rcon.disconnectUser(userId));
|
||||
await notify({
|
||||
void notify({
|
||||
action: "ban",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
@@ -787,7 +797,7 @@ async function executeUserMutation(
|
||||
);
|
||||
return value;
|
||||
});
|
||||
notify({
|
||||
void notify({
|
||||
action: "unban",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
@@ -805,12 +815,13 @@ async function executeUserMutation(
|
||||
snapshot,
|
||||
async () => {
|
||||
await requireRcon(await rcon.disconnectUser(userId));
|
||||
await notify({
|
||||
void notify({
|
||||
action: "disconnect",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
});
|
||||
},
|
||||
{ before, after: before },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -838,6 +849,7 @@ async function executeUserMutation(
|
||||
await requireRcon(await rcon.unmuteUser(userId));
|
||||
}
|
||||
},
|
||||
{ before, after: before },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -877,13 +889,12 @@ async function executeUserMutation(
|
||||
snapshot,
|
||||
async () => {
|
||||
invalidateLoginCache(target.username);
|
||||
const notification = notify({
|
||||
void notify({
|
||||
action: "user_edit",
|
||||
actor: context.capability.actor.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
if (!context.legacy) await notification;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -898,6 +909,7 @@ async function executeUserMutation(
|
||||
userId,
|
||||
snapshot,
|
||||
async () => requireRcon(await rcon.giveCredits(userId, amount)),
|
||||
{ before, after: before },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1014,8 +1026,10 @@ async function executeBulkMutation(
|
||||
const data = record(input);
|
||||
const ids = userIds(data.userIds, context.legacy);
|
||||
const failures: Array<{ userId: number; reason: string }> = [];
|
||||
const externalSyncFailures: Array<{ userId: number; reason: string }> = [];
|
||||
const externalSyncFailureReason =
|
||||
"Database applied; emulator sync failed. Do not retry automatically.";
|
||||
let completed = 0;
|
||||
let externalFailed = false;
|
||||
let mutationReason: string | undefined;
|
||||
|
||||
if (operation === "users.bulk-unban") {
|
||||
@@ -1126,16 +1140,12 @@ async function executeBulkMutation(
|
||||
| "pixels"
|
||||
| "points";
|
||||
for (const userId of ids) {
|
||||
let databaseCommitted = false;
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await db
|
||||
.update(User)
|
||||
.set({ credits: sql`${User.credits} + ${amount}` })
|
||||
.where(eq(User.id, userId));
|
||||
databaseCommitted = true;
|
||||
const delivered = await rcon.giveCredits(userId, amount);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
} else {
|
||||
const currencyType = type === "pixels" ? 0 : 101;
|
||||
await db
|
||||
@@ -1144,19 +1154,25 @@ async function executeBulkMutation(
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
|
||||
});
|
||||
databaseCommitted = true;
|
||||
if (type === "pixels") {
|
||||
const delivered = await rcon.giveDuckets(userId, amount);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
} else {
|
||||
const delivered = await rcon.givePointsGotw(userId, amount);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
}
|
||||
}
|
||||
completed += 1;
|
||||
} catch {
|
||||
if (databaseCommitted) externalFailed = true;
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
continue;
|
||||
}
|
||||
completed += 1;
|
||||
try {
|
||||
const delivered =
|
||||
type === "credits"
|
||||
? await rcon.giveCredits(userId, amount)
|
||||
: type === "pixels"
|
||||
? await rcon.giveDuckets(userId, amount)
|
||||
: await rcon.givePointsGotw(userId, amount);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
} catch {
|
||||
externalSyncFailures.push({
|
||||
userId,
|
||||
reason: externalSyncFailureReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
await logStaffActivity({
|
||||
@@ -1168,7 +1184,7 @@ async function executeBulkMutation(
|
||||
} else {
|
||||
const badgeCode = normalizedText(data.badgeCode, 20);
|
||||
for (const userId of ids) {
|
||||
let databaseCommitted = false;
|
||||
let shouldSync = false;
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
@@ -1190,14 +1206,22 @@ async function executeBulkMutation(
|
||||
slotId: (aggregate?.maxSlot ?? 0) + 1,
|
||||
badgeCode,
|
||||
});
|
||||
databaseCommitted = true;
|
||||
const delivered = await rcon.giveBadge(userId, badgeCode);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
shouldSync = true;
|
||||
}
|
||||
completed += 1;
|
||||
} catch {
|
||||
if (databaseCommitted) externalFailed = true;
|
||||
failures.push({ userId, reason: "Database error" });
|
||||
continue;
|
||||
}
|
||||
if (!shouldSync) continue;
|
||||
try {
|
||||
const delivered = await rcon.giveBadge(userId, badgeCode);
|
||||
if (!context.legacy) await requireRcon(delivered);
|
||||
} catch {
|
||||
externalSyncFailures.push({
|
||||
userId,
|
||||
reason: externalSyncFailureReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
await logStaffActivity({
|
||||
@@ -1208,11 +1232,13 @@ async function executeBulkMutation(
|
||||
});
|
||||
}
|
||||
|
||||
const hasPartialCompletion =
|
||||
failures.length > 0 || externalSyncFailures.length > 0;
|
||||
const completion: HousekeepingPartialCompletion | undefined =
|
||||
failures.length > 0
|
||||
hasPartialCompletion
|
||||
? {
|
||||
status: "partial",
|
||||
external: externalFailed ? "failed" : "not-required",
|
||||
external: externalSyncFailures.length > 0 ? "failed" : "not-required",
|
||||
audit: "persisted",
|
||||
}
|
||||
: undefined;
|
||||
@@ -1222,6 +1248,7 @@ async function executeBulkMutation(
|
||||
completed,
|
||||
total: ids.length,
|
||||
failedIds: failures,
|
||||
...(externalSyncFailures.length > 0 ? { externalSyncFailures } : {}),
|
||||
...(mutationReason ? { reason: mutationReason } : {}),
|
||||
},
|
||||
...(completion === undefined ? {} : { completion }),
|
||||
@@ -1234,7 +1261,7 @@ async function executeBulkMutation(
|
||||
"User",
|
||||
undefined,
|
||||
snapshot,
|
||||
failures.length > 0 ? "partial" : "success",
|
||||
hasPartialCompletion ? "partial" : "success",
|
||||
),
|
||||
);
|
||||
} catch (auditOutcomeError) {
|
||||
|
||||
@@ -952,6 +952,22 @@ describe("dispatchHousekeepingCommand", () => {
|
||||
"invalid failure error",
|
||||
{ ok: false, error: null, correlationId: "forged" },
|
||||
],
|
||||
[
|
||||
"failure completion metadata",
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||
},
|
||||
correlationId: "forged",
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"throwing getter",
|
||||
Object.defineProperty({}, "ok", {
|
||||
|
||||
@@ -71,13 +71,6 @@ const housekeepingResultSchema = z.discriminatedUnion("ok", [
|
||||
ok: z.literal(false),
|
||||
error: housekeepingErrorSchema,
|
||||
correlationId: z.string().min(1).max(160),
|
||||
completion: z
|
||||
.strictObject({
|
||||
status: z.literal("partial"),
|
||||
external: z.enum(["not-required", "completed", "failed"]),
|
||||
audit: z.enum(["persisted", "unavailable"]),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user