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,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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue