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 { 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",
|
||||||
where: { operationType: { equals: "main" } },
|
where: { operationType: { equals: "main" } },
|
||||||
sort: "-createdAt",
|
sort: "-createdAt",
|
||||||
limit: 1,
|
limit: 1,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
process.exit(0);
|
||||||
}
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
// Same logic as the Missions collection startDateTime default: the upcoming
|
`[Generate Mission] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
||||||
// 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.`,
|
|
||||||
);
|
);
|
||||||
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