1
0
Fork 0

fix(generate-mission): strip array row ids when cloning missions

Cloning a mission whose array rows still carry the source doc's row
ids trips a unique-constraint violation in the drizzle adapter,
surfacing as 'ValidationError: The following field is invalid: id'.
Walk the collection's field schema (groups, tabs, rows, nested
arrays) and strip row ids before create — schema-driven so Lexical
richText blobs are cloned verbatim.

Also adopt the bin file logger and the standard fatal-error exit
path.
This commit is contained in:
Jason Fraley 2026-08-25 12:29:26 -04:00
parent e72b849774
commit de1230a757

View file

@ -1,10 +1,69 @@
import type { SanitizedConfig } from "payload"; import type { Field, SanitizedConfig } from "payload";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { Missions } from "@/collections/intelligence/Missions";
import { createBinLogger } from "./lib/binFileLogger";
/**
* Deletes the `id` property from every array row in `data`, walking the
* collection's field schema (groups, tabs, rows, nested arrays).
*
* Payload array rows in Postgres carry their own primary keys (UUID strings).
* Cloning a doc whose array rows still hold the source doc's row ids makes the
* drizzle adapter insert rows whose ids already exist — unique-constraint
* violation, surfacing as `ValidationError: The following field is invalid: id`.
*
* Schema-driven on purpose: a blind deep-strip of `id` keys would also walk
* richText (Lexical) JSON blobs, which must be cloned verbatim.
*/
function stripArrayRowIds(data: unknown, fields: readonly Field[]): void {
if (!data || typeof data !== "object") return;
const record = data as Record<string, unknown>;
for (const field of fields) {
switch (field.type) {
case "array": {
const rows = record[field.name];
if (Array.isArray(rows)) {
for (const row of rows) {
if (row && typeof row === "object") {
delete (row as Record<string, unknown>).id;
stripArrayRowIds(row, field.fields);
}
}
}
break;
}
case "group":
if ("name" in field) {
stripArrayRowIds(record[field.name], field.fields);
} else {
stripArrayRowIds(record, field.fields);
}
break;
case "tabs":
for (const tab of field.tabs) {
if ("name" in tab) {
stripArrayRowIds(record[tab.name], tab.fields);
} else {
stripArrayRowIds(record, tab.fields);
}
}
break;
case "row":
stripArrayRowIds(record, field.fields);
break;
default:
break;
}
}
}
export const script = async (config: SanitizedConfig) => { export const script = async (config: SanitizedConfig) => {
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const log = createBinLogger("generate-mission", payload.logger);
payload.logger.info("[Generate Mission] Starting next mission template generation..."); try {
log.info("[Generate Mission] Starting next mission template generation...");
const latest = await payload.find({ const latest = await payload.find({
collection: "missions", collection: "missions",
@ -16,7 +75,7 @@ export const script = async (config: SanitizedConfig) => {
const source = latest.docs[0]; const source = latest.docs[0];
if (!source) { if (!source) {
payload.logger.info( log.info(
'[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.', '[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.',
); );
process.exit(0); process.exit(0);
@ -37,7 +96,12 @@ export const script = async (config: SanitizedConfig) => {
String(nextSaturday.getDate()).padStart(2, "0"), String(nextSaturday.getDate()).padStart(2, "0"),
].join(""); ].join("");
const codeName = `${source.codeName}-${dateStamp}`; // The source may itself be a previously generated clone whose codeName
// already ends in a datestamp — strip it so reruns derive a stable
// codeName (same target Saturday → same codeName → idempotency check
// fires) instead of stacking a new suffix on every run.
const baseCodeName = source.codeName.replace(/-\d{8}$/, "");
const codeName = `${baseCodeName}-${dateStamp}`;
const existing = await payload.find({ const existing = await payload.find({
collection: "missions", collection: "missions",
@ -47,7 +111,7 @@ export const script = async (config: SanitizedConfig) => {
}); });
if (existing.docs.length > 0) { if (existing.docs.length > 0) {
payload.logger.info( log.info(
`[Generate Mission] Mission with codeName "${codeName}" already exists; skipping.`, `[Generate Mission] Mission with codeName "${codeName}" already exists; skipping.`,
); );
process.exit(0); process.exit(0);
@ -60,11 +124,18 @@ export const script = async (config: SanitizedConfig) => {
...template ...template
} = source; } = source;
stripArrayRowIds(template, Missions.fields);
const mission = await payload.create({ const mission = await payload.create({
collection: "missions", collection: "missions",
data: { data: {
...template, ...template,
codeName, codeName,
// Bot-managed per-mission state must never be inherited: the clone
// would otherwise edit the source mission's attendance embed.
discordMessageId: null,
discordAttendanceHash: null,
discordAttendanceSentAt: null,
classification: { classification: {
...template.classification, ...template.classification,
startDateTime: nextSaturday.toISOString(), startDateTime: nextSaturday.toISOString(),
@ -78,8 +149,14 @@ export const script = async (config: SanitizedConfig) => {
depth: 0, depth: 0,
}); });
payload.logger.info( log.info(
`[Generate Mission] Created next mission template: id=${mission.id} name="${mission.name}" codeName="${mission.codeName}" startDateTime=${mission.classification.startDateTime}`, `[Generate Mission] Created next mission template: id=${mission.id} name="${mission.name}" codeName="${mission.codeName}" startDateTime=${mission.classification.startDateTime}`,
); );
process.exit(0); process.exit(0);
} catch (error) {
log.error(
`[Generate Mission] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
);
process.exit(1);
}
}; };