Files
EpicNext-Cms/src/features/housekeeping/domains/content/services/mutations-production.ts
T

224 lines
5.7 KiB
TypeScript

import type { AuditEntry } from "@/lib/services/audit";
import type {
ContentMutationAdapter,
ContentMutationContext,
ContentMutationOperation,
ContentMutationSnapshot,
} from "./mutations";
export const CONTENT_DATABASE_OPERATIONS = [
"article.change",
"ad.change",
"banner.change",
"event-type.change",
"event.change",
"event-prize.change",
"event-winner.add",
"poll.change",
"poll-question.change",
"tag.change",
"prefix.change",
"prefix-blacklist.change",
"prefix-settings.update",
"help-question.change",
"writeable-box.change",
"email-template.change",
] as const satisfies readonly ContentMutationOperation[];
export const CONTENT_EXTERNAL_OPERATIONS = [
"media.upload",
"media.delete",
"translation.cms.save",
"translation.client.save",
] as const satisfies readonly ContentMutationOperation[];
export const CONTENT_MIXED_OPERATIONS = [
"photo.delete",
"theme.update",
"theme.apply-preset",
"theme.custom-change",
"theme.apply-custom",
"favicon.save",
"favicon.delete",
"logo.save",
"translation.emulator.save",
] as const satisfies readonly ContentMutationOperation[];
type TransactionToken = unknown;
interface ContentTransactionOptions {
isolationLevel: "read committed";
}
export interface ContentProductionMutationDependencies {
transaction<T>(
run: (transaction: TransactionToken) => Promise<T>,
options?: ContentTransactionOptions,
): Promise<T>;
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
executeOperation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction?: TransactionToken,
): Promise<ContentMutationSnapshot>;
}
export class ContentCommittedExternalFailure extends Error {
constructor(readonly snapshot: ContentMutationSnapshot) {
super("Content database change committed but external effect failed");
this.name = "ContentCommittedExternalFailure";
}
}
function auditEntry(
operation: ContentMutationOperation,
context: ContentMutationContext,
outcome: NonNullable<AuditEntry["outcome"]>,
snapshot?: ContentMutationSnapshot,
): AuditEntry {
return {
userId: context.capability.actor.id,
action: `content.${operation}`,
target: "Content",
correlationId: context.correlationId,
domain: "content",
outcome,
before:
snapshot?.before === null || snapshot?.before === undefined
? undefined
: { ...snapshot.before },
after:
snapshot?.after === null || snapshot?.after === undefined
? undefined
: { ...snapshot.after },
};
}
function includesOperation(
operations: readonly ContentMutationOperation[],
operation: ContentMutationOperation,
): boolean {
return operations.includes(operation);
}
async function writeOutcomeOrMarkUnavailable(
dependencies: ContentProductionMutationDependencies,
operation: ContentMutationOperation,
context: ContentMutationContext,
snapshot: ContentMutationSnapshot,
outcome: "success" | "partial",
): Promise<ContentMutationSnapshot> {
try {
await dependencies.writeAudit(
auditEntry(operation, context, outcome, snapshot),
);
return snapshot;
} catch {
const completion = {
status: "partial" as const,
external:
outcome === "partial" ? ("failed" as const) : ("completed" as const),
audit: "unavailable" as const,
};
return { ...snapshot, completion };
}
}
export function createContentProductionMutationAdapter(
dependencies: ContentProductionMutationDependencies,
): ContentMutationAdapter {
return {
async execute(operation, input, context) {
if (includesOperation(CONTENT_DATABASE_OPERATIONS, operation)) {
const transactionOptions =
operation === "poll.change"
? ({ isolationLevel: "read committed" } as const)
: undefined;
return dependencies.transaction(async (transaction) => {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
transaction,
);
await dependencies.writeAudit(
auditEntry(operation, context, "success", snapshot),
transaction,
);
return snapshot;
}, transactionOptions);
}
await dependencies.writeAudit(auditEntry(operation, context, "intent"));
try {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
);
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"success",
);
} catch (error) {
if (
includesOperation(CONTENT_MIXED_OPERATIONS, operation) &&
error instanceof ContentCommittedExternalFailure
) {
const snapshot: ContentMutationSnapshot = {
...error.snapshot,
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
};
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"partial",
);
}
try {
await dependencies.writeAudit(
auditEntry(operation, context, "failure"),
);
} catch {
// Preserve the original dependency failure; the missing outcome is observable
// through the durable correlated intent.
}
throw error;
}
},
};
}
export const contentProductionMutationAdapter =
createContentProductionMutationAdapter({
async transaction(run, options) {
const { db } = await import("@/lib/db");
return db.transaction((transaction) => run(transaction), options);
},
async writeAudit(entry, transaction) {
const { logAudit } = await import("@/lib/services/audit");
await logAudit(entry, transaction as never);
},
async executeOperation(operation, input, context, transaction) {
const { executeContentMutationOperation } = await import(
"./mutations-runtime"
);
return executeContentMutationOperation(
operation,
input,
context,
transaction,
);
},
});