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:
parent
e72b849774
commit
de1230a757
1 changed files with 150 additions and 73 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue