Update
Change a document with the update handler, including partial bodies, protected fields and files
The update handler changes one document. It finds the document by the id in the URL, checks that the caller may change it, validates the body, writes only the fields that the body contains and answers 200 OK with the updated document. This page shows how to declare an update route, how to reuse your create rules for it, what happens to encrypted fields and to fields that a client may not write, and which errors to expect.
Declare an Update Route
An update route is a route whose handler is "update". Its path ends with the parameter :id, and it names a validation group, because the handler discards every body field that the group does not list. Use patch as the method. A put route behaves the same way, since the handler never replaces the whole document.
// src/collections/products.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";
export const products = collection({
type: "crud",
route: "products",
model: "Product",
schema: {
name: fields.requiredString,
price: fields.requiredNumber,
sku: { mongoose: { type: String, unique: true, sparse: true }, zod: z.string().optional() },
},
validations: {
update: {
name: z.string().trim().min(1).max(120).optional(),
price: z.number().min(0).optional(),
},
},
routes: defineRoutes([
{ method: "patch", path: "/update/:id", handler: "update", auth: "authenticated", roles: ["editor", "admin"], validation: "update" },
]),
});With the collection route products, this makes PATCH /api/v1/products/update/:id, and only editors and administrators may call it. Every rule in the update group is optional, so a request can send just the fields it changes. The group leaves out sku on purpose: a client can set it when it creates a product but cannot change it afterward.
Send a Request
Put the id of the document in the URL and the new values in the body:
PATCH /api/v1/products/update/665f1c2e8b3a4d0012a10001
Content-Type: application/json
{
"price": 79.9
}HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Product updated successfully.",
"data": {
"name": "Mechanical keyboard",
"price": 79.9,
"sku": "KB-001",
"_id": "665f1c2e8b3a4d0012a10001",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
}The response is the whole document after the change, not only the changed fields. The fields that the body did not name keep their values. The database sets updatedAt to the time of the update, and createdAt stays as it was.
What the Handler Does
The handler works through these steps in order, and stops at the first one that fails:
- 1
- The authenticator checks the access level and the roles. A caller who is not signed in gets 401, and one with the wrong role gets 403.
- Your
beforeactions run. Any of them can stop the request by throwing an error. - The id in the URL must be 24 hexadecimal characters, or the handler answers 400. The document is then loaded, and a missing document answers 404.
- On an
admin-or-ownerroute, the handler checks that the caller owns the document or is an administrator. Otherwise it answers 403. - The body is validated against the validation group of the route, which also removes the fields the group does not list. A bad value answers 422.
- For a caller who is not an administrator, the protected fields and
ownerare removed from the body. - Uploaded files are stored, and fields listed in
encryptedFieldsandhashedFieldsare encrypted and hashed. - The document is updated, and the files that the update replaced are removed from storage.
- Your
afteractions run, and the handler answers200with the updated document.
A request for a document that does not exist answers 404, and a request for someone else's document answers 403, before the handler looks at the body. A bad body gets its 422 only after both checks pass.
Which Fields Change
Only a field that the validation group lists and that the request sends can change. The handler writes those fields with a $set, so everything else in the document stays as it is. The schema validators that you declared, such as enum or min, still run on the fields being written.
A field that the group does not list is dropped without an error. This request also sends role and a $set key, and neither one has any effect:
PATCH /api/v1/products/update/665f1c2e8b3a4d0012a10001
Content-Type: application/json
{
"price": 79.9,
"role": "admin",
"$set": {
"price": 0
}
}HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Product updated successfully.",
"data": {
"name": "Mechanical keyboard",
"price": 79.9,
"sku": "KB-001",
"_id": "665f1c2e8b3a4d0012a10001",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
}The price is 79.9, not 0. A $set key is only one more unknown field to StarkForge, so a client cannot use it to write fields that the group does not list.
Reuse the Create Rules
Writing the same rules twice invites drift between the create group and the update group. This helper returns the same rules with every field optional:
// src/validations/optional-fields.ts
import type { ZodOptional, ZodType } from "zod";
type Optional<Rules extends Record<string, ZodType>> = { [Key in keyof Rules]: ZodOptional<Rules[Key]> };
/** Returns the same rules with every field optional, so an update can send only what it changes. */
export function optionalFields<Rules extends Record<string, ZodType>>(rules: Rules): Optional<Rules> {
const entries = Object.entries(rules).map(([name, rule]) => [name, rule.optional()]);
return Object.fromEntries(entries) as Optional<Rules>;
}The helper is a small piece of your own code, not part of StarkForge. Keep the shared rules in one object, and build both groups from it:
// src/collections/products.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";
import { optionalFields } from "../validations/optional-fields.js";
const productRules = {
name: z.string().trim().min(1).max(120),
price: z.number().min(0),
};
export const products = collection({
type: "crud",
route: "products",
model: "Product",
schema: {
name: fields.requiredString,
price: fields.requiredNumber,
sku: { mongoose: { type: String, unique: true, sparse: true }, zod: z.string().optional() },
},
validations: {
create: { ...productRules, sku: z.string().optional() },
update: optionalFields(productRules),
},
routes: defineRoutes([
{ method: "post", path: "/", handler: "create", auth: "authenticated", roles: ["editor", "admin"], validation: "create" },
{ method: "patch", path: "/update/:id", handler: "update", auth: "authenticated", roles: ["editor", "admin"], validation: "update" },
]),
});A rule change now reaches both routes, and sku is still something that only a create request can set.
Choose Who Can Update
The auth key on the route decides who gets past the first step and, for admin-or-owner, which documents they may change:
auth | Who can update | Which documents |
|---|---|---|
"authenticated" | Any signed-in user, limited by roles when the route lists them | Every document |
"admin-or-owner" | Administrators and the owner of the document | Only the documents the caller owns, unless the caller is an administrator |
"admin" | Administrators | Every document |
A caller who is not an administrator can never write owner or the fields in the protectedFields option, which defaults to role. StarkForge removes them from the body even when the validation group lists them, so a user cannot hand a document to someone else or promote themselves. An administrator can write both.
An owner is the user whose id is stored in the owner field of the document, as Read explains. Authorization covers ownership and protected fields in full.
Encrypted and Hashed Fields
The route settings encryptedFields and hashedFields apply to updates the same way they apply to creates. A listed field is sealed or hashed before it is saved, but only when the body contains it. This route lets the owner of a note change the title, the body or the secret:
// src/collections/notes.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";
export const notes = collection({
type: "crud",
route: "notes",
model: "Note",
schema: {
title: fields.requiredString,
body: fields.optionalString,
secret: fields.encryptedString,
owner: fields.userRef,
},
validations: {
update: {
title: z.string().trim().min(1).optional(),
body: z.string().optional(),
secret: z.string().optional(),
},
},
routes: defineRoutes([
{
method: "patch",
path: "/update/:id",
handler: "update",
auth: "admin-or-owner",
validation: "update",
encryptedFields: ["secret?"],
},
]),
});The owner changes the title and the secret in one request:
PATCH /api/v1/notes/update/665f1c2e8b3a4d0012a10003
Content-Type: application/json
{
"title": "Weekly shopping list",
"secret": "my bank PIN is 4321"
}HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Note updated successfully.",
"data": {
"title": "Weekly shopping list",
"body": "Milk, eggs",
"owner": "665f1c2e8b3a4d0012a10002",
"_id": "665f1c2e8b3a4d0012a10003",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
}The response has no secret, because the route does not list it in decryptedFields. A read route that does list it returns the new value, my bank PIN is 4321. A second request that sends only a title changes the title and leaves the stored secret untouched.
The question mark in "secret?" makes the field optional for the request. Without it, every update must send the field, and a request that leaves it out is refused:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"success": false,
"message": "'secret' is required.",
"code": "FIELD_MISSING",
"hint": "Add 'secret' to the route's validation, or mark it optional as 'secret?'.",
"requestId": "c2665823-679b-46db-9cb1-7e8d6b6e7838"
}Use the question mark on every encrypted or hashed field of an update route, so that a partial body stays valid. Hidden and Encrypted Fields describes how the sealed values are stored and returned.
Update Files
A route with files reads the request as multipart/form-data. When an update stores new files in a field, the field holds only the new files, and StarkForge removes the files it held before from storage. When the update fails, the new files are removed as well. Uploads covers the options of files and the handlers that add or delete a single file.
Errors
| Status | code | When it happens | What to change |
|---|---|---|---|
| 400 | INVALID_OBJECT_ID | The id in the URL is not 24 hexadecimal characters | Send the _id of a document |
| 401 | ACCESS_TOKEN_MISSING | The route needs sign-in and no token or cookie was sent | Sign in first |
| 403 | ROLE_NOT_ALLOWED | The route lists roles and the caller's role is not among them | Sign in with an allowed role |
| 403 | OWNER_REQUIRED | An admin-or-owner route and a document that belongs to someone else | Update your own documents, or sign in as an administrator |
| 404 | RESOURCE_NOT_FOUND | The id is well formed but no document has it | Check the id |
| 409 | DUPLICATE_KEY | The new value breaks a unique field of the schema | Send a value that no other document has |
| 422 | VALIDATION_FAILED | A value breaks the validation group or a schema validator | Send a value the rule accepts |
| 422 | FIELD_MISSING | A field of encryptedFields or hashedFields is missing and has no question mark | Send the field, or write it as "secret?" |
A value that breaks a rule answers with an issues list that names each field:
PATCH /api/v1/products/update/665f1c2e8b3a4d0012a10001
Content-Type: application/json
{
"price": -5
}HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"success": false,
"message": "price: Too small: expected number to be >=0",
"code": "VALIDATION_FAILED",
"issues": [
{
"path": "price",
"message": "Too small: expected number to be >=0",
"code": "too_small"
}
],
"requestId": "7e0b5d64-5b9a-43ae-814d-e99b0c585436"
}Validation explains the issues list and how to write the rules.
Where to Go Next
Hidden and Encrypted Fields
Keep secrets out of responses and encrypt them at rest.
Authorization
Choose who can call a route and which documents they own.
:::