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,85 +1,162 @@
import type { SanitizedConfig } from "payload";
import type { Field, SanitizedConfig } 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) => {
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({
collection: "missions",
where: { operationType: { equals: "main" } },
sort: "-createdAt",
limit: 1,
depth: 0,
});
const latest = await payload.find({
collection: "missions",
where: { operationType: { equals: "main" } },
sort: "-createdAt",
limit: 1,
depth: 0,
});
const source = latest.docs[0];
if (!source) {
payload.logger.info(
'[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.',
const source = latest.docs[0];
if (!source) {
log.info(
'[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.',
);
process.exit(0);
}
// Same logic as the Missions collection startDateTime default: the upcoming
// Saturday at 20:00, always at least a week out.
const today = new Date();
const day = today.getDay();
const diff = (6 - day) % 7;
const nextSaturday = new Date(today);
nextSaturday.setDate(today.getDate() + diff + 7);
nextSaturday.setHours(20, 0, 0, 0);
const dateStamp = [
nextSaturday.getFullYear(),
String(nextSaturday.getMonth() + 1).padStart(2, "0"),
String(nextSaturday.getDate()).padStart(2, "0"),
].join("");
// 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({
collection: "missions",
where: { codeName: { equals: codeName } },
limit: 1,
depth: 0,
});
if (existing.docs.length > 0) {
log.info(
`[Generate Mission] Mission with codeName "${codeName}" already exists; skipping.`,
);
process.exit(0);
}
const {
id: _sourceId,
createdAt: _sourceCreatedAt,
updatedAt: _sourceUpdatedAt,
...template
} = source;
stripArrayRowIds(template, Missions.fields);
const mission = await payload.create({
collection: "missions",
data: {
...template,
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: {
...template.classification,
startDateTime: nextSaturday.toISOString(),
},
ownershipAndStatus: {
...template.ownershipAndStatus,
status: "Concept",
},
},
overrideAccess: true,
depth: 0,
});
log.info(
`[Generate Mission] Created next mission template: id=${mission.id} name="${mission.name}" codeName="${mission.codeName}" startDateTime=${mission.classification.startDateTime}`,
);
process.exit(0);
}
// Same logic as the Missions collection startDateTime default: the upcoming
// Saturday at 20:00, always at least a week out.
const today = new Date();
const day = today.getDay();
const diff = (6 - day) % 7;
const nextSaturday = new Date(today);
nextSaturday.setDate(today.getDate() + diff + 7);
nextSaturday.setHours(20, 0, 0, 0);
const dateStamp = [
nextSaturday.getFullYear(),
String(nextSaturday.getMonth() + 1).padStart(2, "0"),
String(nextSaturday.getDate()).padStart(2, "0"),
].join("");
const codeName = `${source.codeName}-${dateStamp}`;
const existing = await payload.find({
collection: "missions",
where: { codeName: { equals: codeName } },
limit: 1,
depth: 0,
});
if (existing.docs.length > 0) {
payload.logger.info(
`[Generate Mission] Mission with codeName "${codeName}" already exists; skipping.`,
} catch (error) {
log.error(
`[Generate Mission] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
);
process.exit(0);
process.exit(1);
}
const {
id: _sourceId,
createdAt: _sourceCreatedAt,
updatedAt: _sourceUpdatedAt,
...template
} = source;
const mission = await payload.create({
collection: "missions",
data: {
...template,
codeName,
classification: {
...template.classification,
startDateTime: nextSaturday.toISOString(),
},
ownershipAndStatus: {
...template.ownershipAndStatus,
status: "Concept",
},
},
overrideAccess: true,
depth: 0,
});
payload.logger.info(
`[Generate Mission] Created next mission template: id=${mission.id} name="${mission.name}" codeName="${mission.codeName}" startDateTime=${mission.classification.startDateTime}`,
);
process.exit(0);
};