Skip to content
Open
74 changes: 74 additions & 0 deletions migrations/20260623080105-remove-versions-metadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* eslint-disable no-console */
const METADATA_COLLECTION_NAME = 'form-metadata'

/**
* @param {MongoClient} client
* @param {Collection<FormMetadata>} metadataCollection
*/
async function removeVersionsAttribute(client, metadataCollection) {
const stats = {
updated: 0,
errors: 0
}

const session = client.startSession()

await session.withTransaction(async () => {
const updatesCursor = metadataCollection.find({
versions: { $exists: true }
})

for await (const forUpdate of updatesCursor) {
try {
await metadataCollection.updateOne(
{
_id: forUpdate._id
},
{ $unset: { versions: '' } }
)
stats.updated++
} catch (error) {
console.error(
`Removing 'versions' attribute failed for slug ${forUpdate.slug}:`,
error instanceof Error ? error.message : String(error)
)
stats.errors++
}
}
})

console.log(`\n=== Migration Summary - remove 'versions' attributes ===`)
console.log(`Successfully updated: ${stats.updated}`)
console.log(`Errors: ${stats.errors}`)

console.log(' ')
console.log(' ')
}

/**
* Removes the 'versions' attribute from all metadata records
* @param {import('mongodb').Db} db
* @param {import('mongodb').MongoClient} client
* @returns {Promise<void>}
*/
export async function up(db, client) {
const metadataCollection = /** @type {Collection<FormMetadata>} */ (
db.collection(METADATA_COLLECTION_NAME)
)
await removeVersionsAttribute(client, metadataCollection)
}

/**
* This migration is a one-way data consolidation fix.
* @returns {Promise<void>}
*/
export function down() {
return Promise.reject(
new Error('Migration rollback is not supported for data safety reasons')
)
}

/**
* @import { FormMetadata } from '@defra/forms-model'
* @import { Collection, MongoClient, Db } from 'mongodb'
*/
18 changes: 1 addition & 17 deletions src/api/forms/repositories/form-definition-repository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -953,7 +953,7 @@ describe('form-definition-repository', () => {
}
})

it('should allocate a version exactly once, stamp the inserted draft, and snapshot to form-versions exactly once', async () => {
it('should allocate a version exactly once, stamp the inserted draft', async () => {
const definitionV1 = { ...draft, conditions: [] }
const createdAt = new Date('2026-04-24T10:00:00Z')
mockCollection.findOneAndUpdate.mockResolvedValue({ definitionV1 })
Expand All @@ -970,12 +970,6 @@ describe('form-definition-repository', () => {
expect(
formMetadataRepository.getAndIncrementVersionNumber
).toHaveBeenCalledWith(formId, mockSession)
expect(formMetadataRepository.addVersionMetadata).toHaveBeenCalledTimes(1)
expect(formMetadataRepository.addVersionMetadata).toHaveBeenCalledWith(
formId,
{ versionNumber: 7, createdAt },
mockSession
)

const [, update] = mockCollection.findOneAndUpdate.mock.calls[0]
/** @type {UpdateFilter<{ draft: FormDefinition }>} */
Expand Down Expand Up @@ -1066,16 +1060,6 @@ describe('form-definition-repository', () => {
expect(
formMetadataRepository.getAndIncrementVersionNumber
).toHaveBeenCalledWith(formId, mockSession)
expect(
formMetadataRepository.addVersionMetadata
).toHaveBeenCalledTimes(1)
expect(
formMetadataRepository.addVersionMetadata
).toHaveBeenCalledWith(
formId,
{ versionNumber: 11, createdAt },
mockSession
)
expect(persistedDraft.metadata?.[FORM_VERSION_METADATA_KEY]).toEqual({
versionNumber: 11,
createdAt
Expand Down
31 changes: 0 additions & 31 deletions src/api/forms/repositories/form-metadata-repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,37 +401,6 @@ export async function remove(formId, session) {
logger.info(`Removed form metadata with ID ${formId}`)
}

/**
* Adds version metadata to a form
* @param {string} formId - ID of the form
* @param {FormVersionMetadata} versionMetadata - Version metadata to add
* @param {ClientSession} session - mongo transaction session
*/
export async function addVersionMetadata(formId, versionMetadata, session) {
logger.info(
`Adding version metadata ${versionMetadata.versionNumber} to form ID ${formId}`
)

const result = await update(
formId,
{
$push: {
versions: {
$each: [versionMetadata],
$sort: { versionNumber: -1 }
}
}
},
session
)

logger.info(
`Added version metadata ${versionMetadata.versionNumber} to form ID ${formId}`
)

return result
}

/**
* Gets version metadata for a form
* @param {string} formId - ID of the form
Expand Down
35 changes: 0 additions & 35 deletions src/api/forms/repositories/form-metadata-repository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
import { buildMockCollection } from '~/src/api/forms/__stubs__/mongo.js'
import { FormAlreadyExistsError } from '~/src/api/forms/errors.js'
import {
addVersionMetadata,
create,
get,
getAndIncrementVersionNumber,
Expand Down Expand Up @@ -648,40 +647,6 @@ describe('form-metadata-repository', () => {
})
})

describe('addVersionMetadata', () => {
const versionMetadata = {
versionNumber: 1,
createdAt: new Date()
}

it('should add version metadata to form', async () => {
mockCollection.updateOne.mockResolvedValue({
modifiedCount: 1
})
mockCollection.findOne.mockResolvedValue(metadataAfter)

const result = await addVersionMetadata(
metadataId,
versionMetadata,
mockSession
)

expect(mockCollection.updateOne).toHaveBeenCalledWith(
{ _id: new ObjectId(metadataId) },
{
$push: {
versions: {
$each: [versionMetadata],
$sort: { versionNumber: -1 }
}
}
},
{ session: mockSession }
)
expect(result).toEqual(metadataAfter)
})
})

describe('getVersionMetadata', () => {
it('should get version metadata for a form', async () => {
const versions = [
Expand Down
7 changes: 1 addition & 6 deletions src/api/forms/repositories/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,18 +337,13 @@ export async function insertDraft(
export async function allocateDraftVersion(formId, session) {
const versionNumber =
await formMetadataRepository.getAndIncrementVersionNumber(formId, session)

const createdAt = new Date()
const versionMetadata = /** @type {FormVersionMetadata} */ ({
versionNumber,
createdAt
})

await formMetadataRepository.addVersionMetadata(
formId,
versionMetadata,
session
)

return versionMetadata
}

Expand Down
6 changes: 2 additions & 4 deletions src/api/forms/service/__stubs__/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ export const formMetadataOutput = {
createdAt: BASE_CREATED_DATE,
createdBy: author,
updatedAt: BASE_CREATED_DATE,
updatedBy: author,
versions: []
updatedBy: author
}

/**
Expand Down Expand Up @@ -85,8 +84,7 @@ export const formMetadataDocument = {
createdAt: BASE_CREATED_DATE,
createdBy: author,
updatedAt: BASE_CREATED_DATE,
updatedBy: author,
versions: []
updatedBy: author
}

/**
Expand Down
24 changes: 2 additions & 22 deletions src/api/forms/service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ import {
mapForm,
partialAuditFields
} from '~/src/api/forms/service/shared.js'
import {
createFormVersion,
removeFormVersions
} from '~/src/api/forms/service/versioning.js'
import { removeFormVersions } from '~/src/api/forms/service/versioning.js'
import * as formTemplates from '~/src/api/forms/templates.js'
import { config } from '~/src/config/index.js'
import { logger } from '~/src/helpers/logging/logger.js'
Expand Down Expand Up @@ -111,20 +108,6 @@ export async function handleTitleUpdate(
await publishFormTitleUpdatedEvent({ ...form, ...updatedForm }, form)
}

/**
* Handles versioning for non-title metadata updates
* @param {string} formId - The form ID
* @param {Partial<FormMetadataInput>} formUpdate - The update payload
* @param {ClientSession} session - MongoDB session
*/
export async function handleMetadataVersioning(formId, formUpdate, session) {
if (Object.keys(formUpdate).length > 0) {
await createFormVersion(formId, session)
} else {
logger.debug(`No metadata changes to process for form ID ${formId}`)
}
}

/**
* Creates a new empty form
* @param {FormMetadataInput} metadataInput - the form metadata to save
Expand Down Expand Up @@ -159,8 +142,7 @@ export async function createForm(metadataInput, author) {
createdAt: now,
createdBy: author,
updatedAt: now,
updatedBy: author,
versions: []
updatedBy: author
}

const session = client.startSession()
Expand Down Expand Up @@ -232,8 +214,6 @@ export async function updateFormMetadata(formId, formUpdate, author) {

if (formUpdate.title) {
await handleTitleUpdate(formId, form, formUpdate, updatedForm, session)
} else {
await handleMetadataVersioning(formId, formUpdate, session)
}

await sendEmailIfRequired(form, updatedForm)
Expand Down
16 changes: 0 additions & 16 deletions src/api/forms/service/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -858,22 +858,6 @@ describe('Forms service', () => {
})
})

describe('handleMetadataVersioning', () => {
it('should not create version when there are no changes', async () => {
const formUpdate = {}
const mockSession = /** @type {import('mongodb').ClientSession} */ ({})

jest.clearAllMocks()

const { handleMetadataVersioning } =
await import('~/src/api/forms/service/index.js')

await handleMetadataVersioning(id, formUpdate, mockSession)

expect(versioningService.createFormVersion).not.toHaveBeenCalled()
})
})

describe('sendEmailIfRequired', () => {
it('should not send if no team email setup', async () => {
const metadata = /** @type {FormMetadata} */ ({
Expand Down
3 changes: 1 addition & 2 deletions src/api/forms/service/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ export function mapForm(document) {
createdBy: created.createdBy,
createdAt: created.createdAt,
updatedBy: lastUpdated.updatedBy,
updatedAt: lastUpdated.updatedAt,
versions: document.versions
updatedAt: lastUpdated.updatedAt
}
}

Expand Down
26 changes: 10 additions & 16 deletions src/api/forms/service/shared.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,19 @@ const baseDocument = {
}

describe('mapForm', () => {
it('should return versions from document when present', () => {
const versions = [
{ versionNumber: 2, createdAt: new Date('2025-10-01') },
{ versionNumber: 1, createdAt: new Date('2025-09-01') }
]
const result = mapForm({ ...baseDocument, versions })

expect(result.versions).toEqual(versions)
})

it('should return undefined for versions when document has no versions field', () => {
it('should return document', () => {
const result = mapForm(baseDocument)

expect(result.versions).toBeUndefined()
expect(result.draft).toBeDefined()
expect(result.id).toBe(baseDocument._id.toString())
})

it('should return empty array for versions when document has empty versions array', () => {
const result = mapForm({ ...baseDocument, versions: [] })

expect(result.versions).toEqual([])
it('should throw if invalid', () => {
expect(() =>
mapForm({
...baseDocument,
title: undefined
})
).toThrow('Form is malformed in the database. Expected fields are missing.')
})
})
13 changes: 0 additions & 13 deletions src/api/forms/service/versioning.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ describe('versioning service', () => {
jest
.mocked(formDefinitionRepository.get)
.mockResolvedValue(mockFormDefinition)
jest
.mocked(formMetadataRepository.addVersionMetadata)
.mockResolvedValue(/** @type {any} */ ({}))
jest
.mocked(formVersionsRepository.createVersion)
.mockResolvedValue(mockVersionDocument)
Expand All @@ -98,11 +95,6 @@ describe('versioning service', () => {
FormStatus.Draft,
expect.any(Object)
)
expect(formMetadataRepository.addVersionMetadata).toHaveBeenCalledWith(
formId,
{ versionNumber: 1, createdAt: now },
expect.any(Object)
)
expect(formDefinitionRepository.setFormVersion).toHaveBeenCalledWith(
formId,
FormStatus.Draft,
Expand Down Expand Up @@ -145,11 +137,6 @@ describe('versioning service', () => {
expect(
formMetadataRepository.getAndIncrementVersionNumber
).toHaveBeenCalledWith(formId, expect.any(Object))
expect(formMetadataRepository.addVersionMetadata).toHaveBeenCalledWith(
formId,
{ versionNumber: 4, createdAt: now },
expect.any(Object)
)
})

it('should create its own session when none provided', async () => {
Expand Down
Loading
Loading