Skip to content

Commit 91f4e08

Browse files
Marfuenclaude
andcommitted
fix(frameworks): normalize EvidenceFormType in sync manifests
Sync to a newly-published framework version was 500ing on prod for any customer whose instance was pinned to a backfilled v1.0.0. Root cause: - EvidenceFormType has `@map` directives, so underscored Prisma enum names (`infrastructure_inventory`) have hyphenated DB labels (`infrastructure-inventory`). - The one-shot backfill data migration serialized doc types via `to_jsonb(ct."documentTypes"::text[])`, which renders the DB @Map'd hyphen form. So every backfilled v1.0.0 manifest stored hyphens. - The TS manifest builder reads `ct.documentTypes` from Prisma client, which returns the underscored Prisma enum name. So CX-published v2+ manifests store underscores. - When a customer synced v1.0.0 -> v2, the diff treated every doc type on every control as an add-underscore + remove-hyphen pair. The remove path called `controlDocumentType.findUnique({ formType: "infrastructure-inventory" })`; Prisma 7's strict enum validator rejected the hyphen form and 500'd the whole sync transaction. Fix normalizes to the Prisma-client (underscored) form: - new form-type-normalize.ts maps hyphens -> underscores for every @Map'd EvidenceFormType value - framework-diff.sanitizeManifestEdges applies it before diffing, so the diff doesn't spuriously report identical types as add+remove - framework-sync-apply normalizes edge.formType before every Prisma call (belt-and-suspenders) - framework-rollback defensively normalizes stored undo payloads in case an older sync persisted the raw hyphen value Existing backfilled manifests don't need to be rewritten - they're normalized at read time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cc7e756 commit 91f4e08

4 files changed

Lines changed: 45 additions & 5 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* EvidenceFormType has `@map` directives that expose hyphenated labels at the
3+
* DB level (e.g. `"infrastructure-inventory"`) while the Prisma client uses
4+
* underscored names (e.g. `"infrastructure_inventory"`).
5+
*
6+
* The one-shot backfill data migration
7+
* (20260423121434_backfill_framework_versions) serialized doc types via
8+
* `to_jsonb(ct."documentTypes"::text[])`, which renders the DB @map'd form.
9+
* So every backfilled v1.0.0 manifest stored hyphens. Newer manifests built
10+
* through the TS manifest builder store underscores (what Prisma client
11+
* returns).
12+
*
13+
* Any code that passes `formType` from a manifest into the Prisma client
14+
* must normalize first, otherwise Prisma throws
15+
* `PrismaClientValidationError: Invalid value for argument `formType`` on
16+
* the hyphen form.
17+
*
18+
* Every `@map` in the schema just swaps `_` → `-`, so hyphens-to-underscores
19+
* covers all existing values and any future ones CX adds — no hardcoded list
20+
* to keep in sync.
21+
*/
22+
export function normalizeFormType(value: string): string {
23+
return value.replace(/-/g, '_');
24+
}

apps/api/src/frameworks/framework-versioning/framework-diff.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizeFormType } from './form-type-normalize';
12
import type {
23
FrameworkManifest,
34
ManifestControl,
@@ -67,6 +68,15 @@ function sanitizeManifestEdges(m: FrameworkManifest): FrameworkManifest {
6768
requirementIds: c.requirementIds.filter((id) => reqIds.has(id)),
6869
policyIds: c.policyIds.filter((id) => policyIds.has(id)),
6970
taskIds: c.taskIds.filter((id) => taskIds.has(id)),
71+
// Normalize formTypes to the Prisma-client form (underscored). Backfilled
72+
// v1.0.0 manifests stored DB-mapped hyphen forms; collapsing both forms
73+
// to the canonical name here means the diff doesn't spuriously report
74+
// an add+remove for identical types, and downstream callers never see
75+
// the hyphenated shape. Preserve undefined-ness when the input didn't
76+
// carry documentTypes at all (older manifest shape).
77+
...(c.documentTypes === undefined
78+
? {}
79+
: { documentTypes: c.documentTypes.map(normalizeFormType) }),
7080
})),
7181
};
7282
}

apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
22
import { db, Prisma, Frequency, Departments } from '@db';
33
import { lockOrganizationForSync } from './org-advisory-lock';
4+
import { normalizeFormType } from './form-type-normalize';
45
import type { UndoPayload } from './undo-payload.types';
56

67
export interface RollbackParams {
@@ -129,7 +130,9 @@ async function replayUndo(
129130
}
130131
for (const d of cdtDeleted) {
131132
await tx.controlDocumentType.create({
132-
data: { controlId: d.controlId, formType: d.formType as never },
133+
// Defensive normalization — older undo payloads may have stored the
134+
// DB-mapped hyphen form before the sync-apply normalization was added.
135+
data: { controlId: d.controlId, formType: normalizeFormType(d.formType) as never },
133136
});
134137
}
135138

apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Prisma, Frequency, Departments, type FrameworkInstance, type FrameworkV
22
import { diffManifests } from './framework-diff';
33
import { isControlEdited, isPolicyEdited, isTaskEdited } from './framework-drift';
44
import { buildCrossFrameworkRefs } from './cross-framework-refs';
5+
import { normalizeFormType } from './form-type-normalize';
56
import type { FrameworkManifest } from './manifest.types';
67
import type { UndoPayload, SyncSummary } from './undo-payload.types';
78

@@ -325,28 +326,30 @@ export async function applySync(
325326
for (const edge of diff.controlDocumentTypeEdges.added) {
326327
const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
327328
if (!ctlInst) continue;
329+
const formType = normalizeFormType(edge.formType);
328330
// Idempotent create: skip if already present (shared-entity case).
329331
const existing = await tx.controlDocumentType.findUnique({
330-
where: { controlId_formType: { controlId: ctlInst.id, formType: edge.formType as never } },
332+
where: { controlId_formType: { controlId: ctlInst.id, formType: formType as never } },
331333
select: { id: true },
332334
});
333335
if (existing) continue;
334336
const created = await tx.controlDocumentType.create({
335-
data: { controlId: ctlInst.id, formType: edge.formType as never },
337+
data: { controlId: ctlInst.id, formType: formType as never },
336338
});
337339
undo.controlDocumentTypes.created.push(created.id);
338340
summary.controlDocumentTypesAdded += 1;
339341
}
340342
for (const edge of diff.controlDocumentTypeEdges.removed) {
341343
const ctlInst = ctlByTemplate.get(edge.controlTemplateId);
342344
if (!ctlInst) continue;
345+
const formType = normalizeFormType(edge.formType);
343346
const existing = await tx.controlDocumentType.findUnique({
344-
where: { controlId_formType: { controlId: ctlInst.id, formType: edge.formType as never } },
347+
where: { controlId_formType: { controlId: ctlInst.id, formType: formType as never } },
345348
select: { id: true },
346349
});
347350
if (!existing) continue;
348351
await tx.controlDocumentType.delete({ where: { id: existing.id } });
349-
undo.controlDocumentTypes.deleted.push({ controlId: ctlInst.id, formType: edge.formType });
352+
undo.controlDocumentTypes.deleted.push({ controlId: ctlInst.id, formType });
350353
summary.controlDocumentTypesArchived += 1;
351354
}
352355

0 commit comments

Comments
 (0)