224 lines
5.7 KiB
TypeScript
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,
|
|
);
|
|
},
|
|
});
|