Submit Pipeline
How a form submission is validated, written, extended by hooks, rolled back on failure, and logged.
Code: packages/nextjs-cms/src/core/submit/
Class hierarchy
Submit (abstract) submit.ts shared pipeline
├── NewSubmit NewItemSubmit.ts INSERT; beforeCreate / afterCreate
└── EditSubmit ItemEditSubmit.ts UPDATE; beforeUpdate / afterUpdate; file replacement
├── SimpleSectionSubmit SimpleSectionSubmit.ts INSERT row 1 on first save, UPDATE afterwards
└── LocaleSubmit LocaleSubmit.ts writes {table}_locales; hooks gated by runForLocalesRoute handlers in the admin (apps/cms/app/api/submit/section/...) pick the class, then:
const submit = new EditSubmit({ sectionName, itemId, user, postData: formData, requestMetadata })
await submit.initialize() // load section (privilege-checked), build fields, load current values
await submit.submit()
if (submit.error) return Response.json({ error: submit.errorMessage }, { status: 400 })submit() step by step
submit()
1. handleFields() for each field:
- skip if conditional rules don't match (checkConditionalField)
- setValue(postData) parse the raw value / File
- checkRequired()
- prepareForSubmission() validate + transform
- collect sqlNamesAndValues (or getSqlNamesAndValues for date ranges)
2. checkUniqueness() one SELECT per db.unique constraint (excluding the current item)
3. (preSubmit? stop here) dry-run mode used for validation-only requests
4. runPreSubmitHooks() beforeCreate / beforeUpdate. Throw → abort with the message
5. buildSqlQuery() INSERT / UPDATE from sqlNamesAndValues (+ created_by / updated_by)
6. executeQuery() captures insertId for new items
7. postSubmit() field.postSubmit(): write files, join tables, inline images
8. on failure in 7: submitRollback() + field.postSubmitRollback() + onError hook
9. runPostSubmitHooks() afterCreate / afterUpdate (errors logged, never thrown)
10. handleGalleryOrder() apply drag-and-drop order (sort_order)
11. handleGallery() process uploaded gallery photos (failures become notices)
12. logOperation() audit log entryErrors never throw out of submit(). They set _error / _errorMessage (translated for the admin's language), and the route handler turns them into a 400 response.
sqlNamesAndValues
The single source of truth for the write. It's a plain Record<string, unknown> keyed by column name. Hooks receive this object as values, so before* hooks can modify it and the change is persisted. Keep that property when refactoring; it's documented behavior.
Uniqueness
checkUniqueness() iterates section.db.unique (which always contains the identifier). For each constraint whose columns all have values, it runs a SELECT excluding the current item id and, on a match, fails with a translated message naming the fields. LocaleSubmit overrides this to check localized constraints per locale in {table}_locales. The database constraint is a safety net; the pre-check is what gives users a readable error.
Hooks
resolveHook(hook) normalizes the two hook shapes into { handler, runForLocales }.
| Class | Pre hook | Post hook | runForLocales |
|---|---|---|---|
NewSubmit | beforeCreate | afterCreate | ignored |
EditSubmit / SimpleSectionSubmit | beforeUpdate | afterUpdate | ignored |
LocaleSubmit | beforeUpdate (row exists) or beforeCreate (new locale row) | matching after* | required to fire |
Delete hooks live outside the submit classes, in api/actions/pages.ts (deleteSectionItem, deleteLocaleTranslation).
Rollback
Two kinds of work can fail after the row is written: files and join tables. postSubmit() runs every field's postSubmit; if any throws:
submitRollback()undoes the SQL write.NewSubmitdeletes the inserted row.EditSubmitdoesn't restore previous column values yet (the method is aTODO), so a failed edit can leave the row updated while its files are rolled back. This is a good first contribution.- Each field's
postSubmitRollback()removes files it wrote and restores join rows. - The
onErrorhook runs.
Gallery processing happens after this and deliberately doesn't roll back the item: failed gallery photos become notices the admin can retry from the edit page.
EditSubmit specifics
- Loads existing values first, so file fields can tell "unchanged" from "replaced", and
requiredfile fields pass when a file already exists. - Deletes replaced files (
cleanupReplacedFiles()→field.cleanupReplacedFile()) after the write and post-submit steps. - Excludes the current item from uniqueness checks.
LocaleSubmit specifics
- Validates that the parent item exists in the base table.
- Only localized fields are processed;
valuescontains only localized columns. - Decides create vs. update by checking for an existing
(parent_id, locale)row, which drives hook selection and rollback strategy. - Localized
destinationDbfields and inline editor photos are scoped bylocale.
Adding behavior
- Per-field behavior belongs in the field class (
prepareForSubmission,postSubmit), not inSubmit. - Cross-field behavior (uniqueness, conditional visibility, gallery) belongs in
Submit. - Use
is(field, SomeField)guards for type-specific handling inSubmit, as done forDateRangeField,SelectField, andSlugField. - Integration tests for the pipeline live next to the code (
core/submit/__tests__) and inapi/__tests__/integration.