S
Crud

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.

TypeScript
// 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:

Text
PATCH /api/v1/products/update/665f1c2e8b3a4d0012a10001
Content-Type: application/json

{
  "price": 79.9
}
Text
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. 1
    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.
    2. Your before actions run. Any of them can stop the request by throwing an error.
    3. 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.
    4. On an admin-or-owner route, the handler checks that the caller owns the document or is an administrator. Otherwise it answers 403.
    5. 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.
    6. For a caller who is not an administrator, the protected fields and owner are removed from the body.
    7. Uploaded files are stored, and fields listed in encryptedFields and hashedFields are encrypted and hashed.
    8. The document is updated, and the files that the update replaced are removed from storage.
    9. Your after actions run, and the handler answers 200 with the updated document.
Checks Run in Order

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:

Text
PATCH /api/v1/products/update/665f1c2e8b3a4d0012a10001
Content-Type: application/json

{
  "price": 79.9,
  "role": "admin",
  "$set": {
    "price": 0
  }
}
Text
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:

TypeScript
// 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:

TypeScript
// 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:

authWho can updateWhich documents
"authenticated"Any signed-in user, limited by roles when the route lists themEvery document
"admin-or-owner"Administrators and the owner of the documentOnly the documents the caller owns, unless the caller is an administrator
"admin"AdministratorsEvery document
Owner and Role Are Protected

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:

TypeScript
// 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:

Text
PATCH /api/v1/notes/update/665f1c2e8b3a4d0012a10003
Content-Type: application/json

{
  "title": "Weekly shopping list",
  "secret": "my bank PIN is 4321"
}
Text
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:

Text
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

StatuscodeWhen it happensWhat to change
400INVALID_OBJECT_IDThe id in the URL is not 24 hexadecimal charactersSend the _id of a document
401ACCESS_TOKEN_MISSINGThe route needs sign-in and no token or cookie was sentSign in first
403ROLE_NOT_ALLOWEDThe route lists roles and the caller's role is not among themSign in with an allowed role
403OWNER_REQUIREDAn admin-or-owner route and a document that belongs to someone elseUpdate your own documents, or sign in as an administrator
404RESOURCE_NOT_FOUNDThe id is well formed but no document has itCheck the id
409DUPLICATE_KEYThe new value breaks a unique field of the schemaSend a value that no other document has
422VALIDATION_FAILEDA value breaks the validation group or a schema validatorSend a value the rule accepts
422FIELD_MISSINGA field of encryptedFields or hashedFields is missing and has no question markSend the field, or write it as "secret?"

A value that breaks a rule answers with an issues list that names each field:

Text
PATCH /api/v1/products/update/665f1c2e8b3a4d0012a10001
Content-Type: application/json

{
  "price": -5
}
Text
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.

:::