Documentation

Everything you need to build resource-oriented REST APIs with hydro.

Getting started

hydro is a Nuxt module that generates REST endpoints from resource definitions. Define your schema with Zod, implement reads and writes, and get validation, relationships, OpenAPI docs and error handling for free.

terminaltry it
// After adding a resource file, everything is live:GET /api/books?title=Dune&order[title]=asc// 200 OK// X-Total-Count: 1[  { "id": "1", "title": "Dune", "available": true }]// Validation is automatic — no manual checks needed:POST /api/books{ "title": "" }// 422 Unprocessable Entity// Content-Type: application/problem+json{  "type": "https://hydro.dev/errors/validation",  "violations": [{ "propertyPath": "/title", "message": "min 1 character" }]}

Install

terminalinstall
npm install @muffe/hydro zod

Configure

Add hydro to your Nuxt modules and point it at your resources directory. Each file in server/resources/ becomes a set of CRUD routes.

nuxt.config.tsconfig
// nuxt.config.tsexport default defineNuxtConfig({  modules: ['@muffe/hydro'],  hydro: {    prefix: '/api',    resourcesDir: 'server/resources',    openapi: {      enabled: true,      info: { title: 'My API', version: '1.0.0' },    },  },})

Your first resource

Create server/resources/book.ts with a schema, provider (reads) and processor (writes). That single file generates five REST endpoints.

server/resources/book.tsdefineResource
// server/resources/book.tsimport { defineResource } from '#hydro'import { z } from 'zod'const books = [  { id: '1', title: 'Dune', available: true },]export default defineResource({  name: 'Book',  path: 'books',  schema: z.object({    id: z.string().optional(),    title: z.string().min(1).max(200),    available: z.boolean().default(true),  }),  provider: {    async list(ctx) {      const { offset, itemsPerPage } = ctx.query.pagination      return {        items: books.slice(offset, offset + itemsPerPage),        total: books.length,      }    },    async get(ctx) {      return books.find(b => b.id === ctx.params.id) ?? null    },  },  processor: {    async create(ctx) {      const book = { ...ctx.input, id: crypto.randomUUID() }      books.push(book)      return book    },    async update(ctx) {      const i = books.findIndex(b => b.id === ctx.params.id)      const next = { ...books[i], ...ctx.input, id: ctx.params.id }      books[i] = next      return next    },    async delete(ctx) {      const i = books.findIndex(b => b.id === ctx.params.id)      if (i >= 0) books.splice(i, 1)    },  },})

Start your dev server and the following routes are live:

routesauto-generated
// The definition above generates these five endpoints:GET    /api/booksList with filters, pagination, sort, sparse fieldsGET    /api/books/:idFetch a single book by idPOST   /api/booksCreate (body validated against schema)PATCH  /api/books/:idPartial update (only sent fields are validated)DELETE /api/books/:idDelete a book by id

Define a resource

Every resource has three building blocks: a schema that describes the data shape, a provider that handles reads, and a processor that handles writes. Hydro wires them together into validated, documented routes.

skeletonstructure
// A resource definition has three core parts:export default defineResource({  name: 'Book',          // PascalCase name for error messages and OpenAPI  path: 'books',         // kebab-case plural for URL paths  schema: z.object({     // Zod schema — validates input and generates docs    id: z.string().optional(),    title: z.string().min(1),  }),  // Provider — handle reads (list, get)  provider: {    async list(ctx) { /* return { items, total } */ },    async get(ctx)  { /* return item or null */ },  },  // Processor — handle writes (create, update, delete)  processor: {    async create(ctx) { /* return created */ },    async update(ctx) { /* return updated */ },    async delete(ctx) { /* no return needed */ },  },})

Schema

The schema is a Zod object. Hydro uses it to validate request bodies on create (full schema) and update (partial), coerce defaults, and generate the OpenAPI spec. Every field that appears in your API responses should be declared here.

schemazod
schema: z.object({  id: z.string().optional(),      // usually optional — generated on create  title: z.string().min(1).max(200), // validated on every request  authorId: z.string().optional(),   // FK for relationships  available: z.boolean().default(true), // default applied when field is missing}),

Provider (reads)

The provider implements list and get. Hydro parses the query string before calling your provider, so you receive structured data on ctx.query — filters, sort rules, pagination and sparse fields are all ready to use.

providerreads
// Provider handles reads — list and get.// Hydro parses the query string before calling your provider,// so you receive structured data on ctx.query.provider: {  async list(ctx) {    let items = [...books]    // Apply filters from ?title=Dune&available=true    if (ctx.query.filters.title) {      items = items.filter(b =>        b.title.includes(String(ctx.query.filters.title))      )    }    if (ctx.query.filters.available !== undefined) {      items = items.filter(b =>        String(b.available) === String(ctx.query.filters.available)      )    }    // Apply sorting from ?order[title]=asc    for (const sort of [...ctx.query.sort].reverse()) {      items = items.sort((a, b) => {        const result = String(a[sort.field]).localeCompare(String(b[sort.field]))        return sort.direction === 'asc' ? result : -result      })    }    // Apply pagination from ?page=2&itemsPerPage=10    const { offset, itemsPerPage } = ctx.query.pagination    const page = items.slice(offset, offset + itemsPerPage)    // Apply sparse fields from ?fields=id,title    return {      items: page.map(item => selectFields(item, ctx.query.fields)),      total: items.length,    }  },  async get(ctx) {    // ctx.params.id comes from the route /api/books/:id    return books.find(b => b.id === ctx.params.id) ?? null  },}

Processor (writes)

The processor implements create, update and delete. The request body has already been validated and coerced by the time your processor runs. On update, only the fields the client actually sent are present (partial).

processorwrites
// Processor handles writes — create, update, delete.// ctx.input is already validated and coerced by the schema.processor: {  async create(ctx) {    // ctx.input contains the validated request body    const book = {      available: true,       // defaults from schema are applied      ...ctx.input,      id: crypto.randomUUID(),    }    books.push(book)    return book               // returned value is sent as JSON response  },  async update(ctx) {    // ctx.input is a partial — only fields the client sent    // missing fields keep their current value    const i = books.findIndex(b => b.id === ctx.params.id)    const next = { ...books[i], ...ctx.input, id: ctx.params.id }    books[i] = next    return next  },  async delete(ctx) {    // No return value needed — Hydro responds with 204    const i = books.findIndex(b => b.id === ctx.params.id)    if (i >= 0) books.splice(i, 1)  },}

Context object

Every provider and processor function receives a typed context with the request data you need:

provider + processorctx usage
// Your provider and processor use ctx to access everythingprovider: {  async list(ctx) {    const { filters, sort, pagination, fields } = ctx.query    // Use pagination directly — values are already clamped    const items = await db      .query('SELECT * FROM books LIMIT $1 OFFSET $2', [        pagination.itemsPerPage,        pagination.offset,      ])    return { items, total: count }  },},processor: {  async create(ctx) {    // ctx.input is validated and coerced — safe to insert    const result = await db.query(      'INSERT INTO books (title, available) VALUES ($1, $2) RETURNING *',      [ctx.input.title, ctx.input.available],    )    return result.rows[0]  },},

Here's what's available on the context object:

ctx.paramsRoute parameters. On item routes, contains { id } from /books/:id.
ctx.query.filtersParsed filter values. Only fields listed in the resource filters array.
ctx.query.pagination{ page, itemsPerPage, offset } — clamped by resource pagination config.
ctx.query.sort[{ field, direction }] — parsed from order[field]=asc query params.
ctx.query.fieldsstring[] of requested sparse fields, or undefined if not set.
ctx.inputValidated request body. Full schema for create, partial for update.
ctx.authAuth info extracted by the configured auth provider, if enabled.
ctx.requestRaw H3 request event with method, url, headers.

Query parameters

Hydro auto-parses the query string into structured data on ctx.query. Your provider receives pre-parsed filters, sort rules, pagination offsets and sparse field selections — no manual query string parsing needed.

Declaring filters

Only fields listed in the resource's filters array are parsed from the query string. Unknown parameters are silently ignored.

resourcefilters
// Only fields listed in the filters array are parsed from the query string.// Unknown filter parameters are silently ignored.export default defineResource({  name: 'Book',  path: 'books',  schema: z.object({    id: z.string().optional(),    title: z.string().min(1),    authorId: z.string().optional(),    available: z.boolean().default(true),  }),  filters: ['title', 'authorId', 'available'],  // ?title=Dune     → parsed, available in ctx.query.filters.title  // ?authorId=a1    → parsed, available in ctx.query.filters.authorId  // ?unknown=foo    → ignored, not in the filters array})

Query parameters reference

?title=fooFilter by field. Only fields listed in the resource filters array are accepted.
?order[title]=ascSort by field. Use asc or desc. Can be repeated for multi-field sort.
?page=2&itemsPerPage=25Paginate results. Values are clamped by the resource pagination config.
?fields=title,availableSparse fieldset. Only return the listed fields — your provider decides how to apply this.

Parsed query shape

Hydro parses the raw query string into a structured object on ctx.query. Here's what the parsed data looks like for a typical request:

ctx.queryparsed
// Given this request:// GET /api/books?title=Dune&order[title]=asc&page=2&itemsPerPage=10&fields=id,title// ctx.query contains:ctx.query = {  filters: {    title: 'Dune',          // string value from the query string  },  sort: [    { field: 'title', direction: 'asc' },  // parsed from order[title]=asc  ],  pagination: {    page: 2,               // current page number    itemsPerPage: 10,      // clamped by resource pagination config    offset: 10,            // pre-computed: (page - 1) * itemsPerPage  },  fields: ['id', 'title'],  // sparse fieldset}

Usage examples

example.httpquery
// All query parameters can be combined freely// Filter by fields (must be listed in resource `filters` array)GET /api/books?title=DuneGET /api/books?available=true// Sort by fieldGET /api/books?order[title]=ascGET /api/books?order[title]=desc// PaginateGET /api/books?page=2&itemsPerPage=25// Sparse fieldset — only return listed fieldsGET /api/books?fields=id,title// Combine everythingGET /api/books?title=Dune&order[title]=asc&page=1&itemsPerPage=10&fields=id,title// Response headers include pagination metadata:// X-Total-Count: 3// X-Has-More: true

Pagination config

Set per-resource limits to prevent clients from requesting too many records. When a client sends ?itemsPerPage=999, Hydro clamps it to your max.

resourcepagination
// Per-resource pagination limits (optional)// Clamps client values so ?itemsPerPage=999 respects your capexport default defineResource({  // ...  pagination: { default: 10, max: 50 },})

Relationships

Hydro resolves relationships between resources. When a client sends related data, Hydro validates references, creates nested records, and writes foreign keys — all before your processor runs. You declare the relationship once, and Hydro handles the wiring.

server/resources/book.tsrelationships
// Relationships live inside the resource definition,// next to schema, filters, provider and processor.export default defineResource({  name: 'Book',  path: 'books',  schema: z.object({    id: z.string().optional(),    title: z.string().min(1),    authorId: z.string().optional(),  }),  filters: ['title', 'authorId'],  relationships: {              // ← declare relationships here    author: {      kind: 'belongsTo',      target: 'Author',      foreignKey: 'authorId',      writable: { create: 'reference', update: 'reference' },    },  },  provider:  { /* ... */ },  processor: { /* ... */ },})

belongsTo (many-to-one)

Use belongsTo on the side that holds the foreign key. When a client sends a reference like "author": "/authors/a1", Hydro looks up the target resource, verifies it exists, and replaces the reference with the foreign key value in ctx.input. Your processor never sees the relationship field — it just receives the flat FK.

server/resources/book.tsbelongsTo
// Book has one Author — declare belongsTo on the "many" side// server/resources/book.tsexport default defineResource({  name: 'Book',  path: 'books',  schema: z.object({    id: z.string().optional(),    title: z.string().min(1).max(200),    authorId: z.string().optional(),  }),  relationships: {    author: {      kind: 'belongsTo',      target: 'Author',         // references the Author resource by name      foreignKey: 'authorId',   // field that stores the FK on Book      writable: {        create: 'reference',    // accept "/authors/a1" or plain "a1" during POST        update: 'reference',    // same for PATCH      },    },  },  // ...provider and processor})
request flowinternals
// Client sends a reference — Hydro resolves it to authorIdPOST /api/booksContent-Type: application/json{  "title": "The Fifth Season",  "author": "/authors/a5"}// What happens internally:// 1. Hydro sees "author" matches a relationship field// 2. It strips the path prefix and looks up Author with id "a5"// 3. It calls Author.provider.get to verify the author exists// 4. It replaces "author" with authorId: "a5" in ctx.input// 5. Your processor receives { title: "The Fifth Season", authorId: "a5" }

hasMany (one-to-many)

Use hasMany on the side that owns the relationship. When a client sends nested objects, Hydro validates each one against the target resource's schema, then calls the target's processor.create after your processor creates the parent. Hydro attaches the parent's id as the foreign key on each child.

server/resources/author.tshasMany
// Author has many Books — declare hasMany on the "one" side// server/resources/author.tsexport default defineResource({  name: 'Author',  path: 'authors',  schema: z.object({    id: z.string().optional(),    name: z.string().min(1).max(120),    country: z.string().length(2),    books: z.array(z.object({   // accept inline book objects      title: z.string().min(1),      available: z.boolean().default(true),    })).optional(),  }),  relationships: {    books: {      kind: 'hasMany',      target: 'Book',           // references the Book resource by name      foreignKey: 'authorId',   // field on Book that points back to Author      writable: {        create: 'nested',       // accept inline objects during POST        update: false,          // books can't be changed through author PATCH      },    },  },  // ...provider and processor})
request flowinternals
// Client sends nested objects — Hydro creates them after the parentPOST /api/authorsContent-Type: application/json{  "name": "Martha Wells",  "country": "US",  "books": [    { "title": "All Systems Red" },    { "title": "Artificial Condition" }  ]}// What happens internally:// 1. Hydro validates the author fields against the schema// 2. Your processor.create runs — the author gets an id (e.g. "a6")// 3. Hydro validates each nested book against Book's schema// 4. For each book, it calls Book.processor.create with://    { title: "All Systems Red", authorId: "a6" }// 5. The response includes the author (not the nested books)// Response:201 Created{  "id": "a6",  "name": "Martha Wells",  "country": "US"}

Writable modes

Each relationship configures how it can be written during create and update. The mode determines what clients can send and what Hydro does with it:

writable modesreference
// Each relationship configures writability per operationrelationships: {  author: {    kind: 'belongsTo',    target: 'Author',    foreignKey: 'authorId',    writable: {      create: 'reference',   // what happens on POST /books      update: 'reference',   // what happens on PATCH /books/:id    },  },}// The writable mode controls what the client can send:// 'reference' → accept a string (FK id or "/authors/a1" path)//   { "author": "/authors/a1" }//   Hydro resolves the reference and sets authorId in ctx.input// 'nested' → accept inline objects, creates them after the parent//   { "books": [{ "title": "Dune" }] }//   Hydro validates each against Book's schema, then calls Book.processor.create// 'either' → accept both references and nested objects//   { "author": "/authors/a1" }     ← reference//   { "author": { "name": "..." } } ← nested (Hydro creates it)// false → block writes through this relationship entirely//   Sending the field returns a 422 validation error

Custom operations

Not everything fits into CRUD. Custom operations let you add named actions to a resource — like checking out a book or featuring an author. They get their own route, validate input and output against schemas, and appear in the generated OpenAPI docs.

server/resources/book.tscustom operations
// Custom operations become real routes with full validation.// They appear alongside CRUD routes in the OpenAPI spec.export default defineResource({  name: 'Book',  path: 'books',  // ...schema, provider, processor  customOperations: {    checkout: {              // the key becomes the path segment      method: 'POST',      scope: 'item',      path: 'checkout',      input: z.object({ userId: z.string() }),      output: z.object({ available: z.boolean() }),      async handler(ctx) { /* ... */ },    },  },})// Generates: POST /api/books/:id/checkout// Input body validated against the input schema// Response validated against the output schema

Item-scoped operations

Item operations bind to a specific resource instance. The URL includes the resource id, and the handler receives ctx.params.id. Use them for actions that modify or query a single record.

server/resources/book.tsitem scope
// Custom operations handle anything beyond plain CRUD// Declare them inside the resource definitionexport default defineResource({  name: 'Book',  path: 'books',  // ...schema, provider, processor  customOperations: {    // An item-scoped operation — acts on a single book    checkout: {      name: 'checkout',      method: 'POST',      scope: 'item',              // binds to /api/books/:id/checkout      path: 'checkout',      input: z.object({        userId: z.string().min(1),      }),      output: z.object({        id: z.string().optional(),        available: z.boolean(),      }),      async handler(ctx) {        // ctx.input — validated against input schema        // ctx.params.id — the book id from the URL        const book = books.find(b => b.id === ctx.params.id)!        book.available = false        return { id: book.id, available: book.available }      },    },  },})

Collection-scoped operations

Collection operations bind to the resource as a whole. There's no id in the URL. Use them for queries or batch actions that span multiple records.

server/resources/author.tscollection scope
// Collection-scoped operations — no :id in the URLcustomOperations: {  spotlight: {    name: 'spotlight',    method: 'POST',    scope: 'collection',       // binds to /api/authors/spotlight    path: 'spotlight',    input: z.object({      country: z.string().length(2).optional(),    }).optional(),             // input can be optional entirely    output: z.array(z.object({      id: z.string().optional(),      name: z.string(),      bookCount: z.number(),    })),    async handler(ctx) {      // ctx.input may be undefined if body was omitted      const country = ctx.input?.country?.toUpperCase()      return authors        .filter(a => a.featured && (!country || a.country === country))        .map(a => ({ id: a.id, name: a.name, bookCount: bookCount(a.id) }))    },  },},

Calling custom operations

example.httprequests
// Calling the checkout operationPOST /api/books/1/checkoutContent-Type: application/json{ "userId": "demo-user" }// Response:200 OK{ "id": "1", "available": false }// Calling the spotlight operationPOST /api/authors/spotlightContent-Type: application/json{ "country": "US" }// Response:200 OK[  { "id": "a1", "name": "Frank Herbert", "bookCount": 1 },  { "id": "a3", "name": "Ursula K. Le Guin", "bookCount": 2 },  { "id": "a4", "name": "Octavia E. Butler", "bookCount": 2 }]

Loading the entity automatically

For item-scoped operations, you can set loadEntity: true to have Hydro call provider.get before your handler runs. The loaded entity is available on ctx.entity. If the entity doesn't exist, Hydro returns 404 automatically.

book.tsloadEntity
// For item-scoped ops, you can ask Hydro to load the entity firstcheckout: {  name: 'checkout',  method: 'POST',  scope: 'item',  path: 'checkout',  loadEntity: true,            // Hydro calls provider.get for you  input: z.object({ userId: z.string() }),  output: z.object({ id: z.string().optional(), available: z.boolean() }),  async handler(ctx) {    // ctx.entity is now available — the loaded book    // Still null-safe: returns 404 if provider.get returns null    ctx.entity.available = false    return { id: ctx.entity.id, available: false }  },},
Item scopescope: 'item'Creates /api/books/:id/checkout. Handler receives ctx.params.id.
Collection scopescope: 'collection'Creates /api/authors/spotlight. No id in URL or params.

Error handling

Hydro uses RFC 7807 Problem Details for all error responses. Validation errors are generated automatically from your schema — every violation includes a JSON pointer to the failing field. The format is stable and machine-readable, making it easy to map errors to form inputs on the client side.

terminalautomatic
// Hydro catches validation errors automatically —// no try/catch needed in your handlers:POST /api/books{ "title": "", "available": "not-a-bool" }422 Unprocessable EntityContent-Type: application/problem+json{  "type": "https://hydro.dev/errors/validation",  "title": "Validation Failed",  "status": 422,  "violations": [    { "propertyPath": "/title", "message": "min 1 character" },    { "propertyPath": "/available", "message": "Expected boolean" }  ]}

Validation errors

When a request body fails schema validation, Hydro responds with a 422 and a list of violations. Each violation has a propertyPath (a JSON pointer like /name) and a human-readable message.

responseRFC 7807
// All validation errors follow RFC 7807 Problem Details// Content-Type: application/problem+json// Example: missing required fieldsPOST /api/authors{ "name": "", "country": "USA" }422 Unprocessable Entity{  "type": "https://hydro.dev/errors/validation",  "title": "Validation Failed",  "status": 422,  "detail": "One or more fields failed validation",  "violations": [    { "propertyPath": "/name", "message": "Required" },    { "propertyPath": "/country", "message": "Expected 2 characters" }  ]}

Error types

Hydro provides typed error classes you can throw from any provider, processor, or custom operation handler. They all produce Problem Details responses. Hydro throws ValidationError and NotFoundError automatically, but you can use the full set for your own logic.

errors.tsthrow
import { HydroError, ValidationError, NotFoundError,         ConflictError, ForbiddenError, UnauthorizedError }from '@muffe/hydro/core'// Hydro throws these automatically, but you can throw them// from your own provider/processor handlers too:// Validation — 422// Thrown when schema validation fails.// Hydro generates violations from the schema automatically.throw new ValidationError([  { propertyPath: '/email', message: 'Already taken' },])// Not Found — 404// Thrown when provider.get returns null, or resource doesn't exist.// Hydro throws this automatically for missing items.throw new NotFoundError('Book not found')// Conflict — 409// Use for duplicate checks or optimistic locking failures.throw new ConflictError('Email already registered')// Forbidden — 403// Use when the user is authenticated but lacks permission.throw new ForbiddenError('Not the resource owner')// Unauthorized — 401// Use when no authentication is provided.throw new UnauthorizedError('Login required')// Custom errors — any statusthrow new HydroError(429, 'Too Many Requests', 'Try again in 60 seconds')

Response shape

Every error response follows the same structure. The violations array is only present on validation errors (422). Other errors include a detail string instead.

typesinterface
// Every error response follows this shape:interface ProblemDetails {  type: string           // Machine-readable error identifier  title: string          // Human-readable summary  status: number         // HTTP status code  detail?: string        // Specific explanation  instance?: string      // The request URL that caused the error  violations?: Array<{   // Present for validation errors (422)    propertyPath: string // JSON pointer to the field (e.g. "/name")    message: string      // Human-readable error message  }>}// Response headers:// Content-Type: application/problem+json

OpenAPI & generated docs

Hydro generates a full OpenAPI 3.1 spec from your resource definitions at runtime. Your schemas become request/response bodies, your filters become query parameters, your custom operations become dedicated paths. No extra config files or annotations needed. The spec feeds an interactive Scalar docs UI so consumers can explore your API immediately.

What gets generated

nuxt.config.tsopenapi
// Enable OpenAPI in nuxt.config.tshydro: {  prefix: '/api',  openapi: {    enabled: true,    info: { title: 'Library API', version: '1.0.0' },  },}// Hydro generates the spec from your resource definitions at runtime:// 1. Schema → request/response bodies with types and constraints// 2. Filters → query parameters// 3. Pagination → page and itemsPerPage parameters// 4. Relationships → referenced schemas// 5. Custom operations → dedicated paths with input/output schemas// 6. Auth rules → security requirements on endpoints

Generated spec example

The output is a standard OpenAPI 3.1 document you can feed to any tooling. Here's a simplified look at what Hydro produces:

_openapi.jsonoutput
// GET /api/_openapi.json produces something like:{  "openapi": "3.1.0",  "info": { "title": "Library API", "version": "1.0.0" },  "paths": {    "/api/books": {      "get": {        "summary": "List Books",        "parameters": [          { "name": "title", "in": "query", "schema": { "type": "string" } },          { "name": "order[title]", "in": "query", "schema": { "type": "string" } },          { "name": "page", "in": "query", "schema": { "type": "integer" } },          { "name": "itemsPerPage", "in": "query", "schema": { "type": "integer" } },          { "name": "fields", "in": "query", "schema": { "type": "string" } }        ],        "responses": {          "200": {            "description": "List of books",            "content": { "application/json": { /* item schema */ } }          }        }      },      "post": {        "summary": "Create Book",        "requestBody": { /* schema from zod */ },        "responses": { "201": { /* ... */ } }      }    },    "/api/books/{id}": { "get": {}, "patch": {}, "delete": {} },    "/api/books/{id}/checkout": {      "post": { "summary": "Checkout", /* input/output schemas */ }    }  }}

Endpoints

routesauto-generated
// Generated routesGET /api/_openapi.jsonFull OpenAPI 3.1 JSON specGET /api/_docsScalar interactive docs UI// Try it live in this playground:// https://hydro-playground.vercel.app/api/_docs// Disable the docs UI but keep the spec:openapi: { docsPath: undefined }// Disable everything:openapi: { enabled: false }

AI context

hydro ships a compact AI context pack for coding tools. Use it when you want an AI agent to add resources, relationships or custom operations in a different Nuxt codebase without guessing the module conventions.

Copy-paste prompt

prompt.mdAI
I am working in a Nuxt codebase that uses hydro.Use this context as the source of truth:https://hydro-playground.vercel.app/llms-full.txtBefore coding:- Read nuxt.config.ts and confirm hydro.prefix and hydro.resourcesDir.- Inspect existing files in the resources directory and match their style.- Add or edit one resource per file.- Import defineResource from #hydro.- Use Zod schemas.- Keep reads in provider and writes in processor.- Use ctx.query for filters, sort, pagination and sparse fields.- Use ctx.input for validated request bodies.- Use relationships and customOperations when appropriate.- Verify with typecheck and relevant tests.