Skip to content

Response Envelopes

Overview

Every API endpoint returns the same JSON envelope shape — whether it's a single entity, a paginated list, or an error. This consistency means the frontend can unwrap any response the same way, and errors are always structured (never raw strings or stack traces).

The envelope types are defined in @mg/shared-types (shared between API and frontend). The API uses factory functions to produce properly serialized Response objects.

These shapes are a global contract — uniform across all routes, changed only by a coordinated all-consumers cutover, never by a per-route variant. New routes match these shapes; they do not introduce "better" ones. Rationale and the decision to keep this envelope (rather than naked-wire) live in ADR-019.

Shapes

Success — single entity or action result:

{ "success": true, "data": { ... }, "message": "optional" }

Error — structured failure. error is the ErrorType value (lowercase snake_case, e.g. not_found, validation_error); details is optional (see [[api/error-handling|Error Handling]]):

{ "success": false, "error": "not_found", "message": "Resource not found", "status": 404 }

Paginated list — items with pagination metadata:

{
  "success": true,
  "data": {
    "items": [ ... ],
    "pagination": {
      "page": 1, "limit": 10, "total": 42,
      "totalPages": 5, "hasNextPage": true, "hasPreviousPage": false
    }
  }
}

Factory Functions

Helper Purpose
createSuccessResponse(data, opts?) Single entity, default 200
createErrorResponse(message, { type, status?, details? }) Error with ErrorType-derived status
createListSuccessResponse(items, { page, limit, total }) Paginated list

All three live at src/lib/api/response/factory.ts. They serialize with serializeJSON (converts Date to ISO string) and set Content-Type: application/json + Cache-Control: no-store.

The underlying formatters (formatSuccessResponse, formatErrorResponse) live in @mg/shared-types at packages/shared-types/src/api/envelopes.ts, so both the API and frontend share the same type definitions.

Pagination

Offset-based (page + limit), not cursor — admin tables need page numbers, jump-to-page, and total counts, and our dataset sizes stay well under offset's deep-page cost. Rationale in ADR-019.

Standard conventions:

  • Request: ?page=1&limit=10 (defaults if omitted)
  • Limit max: 100
  • parseQueryParams pre-calculates skip as (page - 1) * limit
  • total is the default but per-endpoint opt-out-able — the count is a second full scan, so a genuinely large table (e.g. audit logs) may omit it without changing the contract.

DB query pattern:

const records = await tx.model.findMany({ where, orderBy, skip, take: limit })
const total = await tx.model.count({ where })
return createListSuccessResponse(records, { page, limit, total })

Always go through createListSuccessResponse so every list response is extractable as data.items + data.pagination. A few legacy endpoints bypass it and are conformed at the next seam: auth/users (hand-rolled pagination), staff/search (returns { results }), products (raw service output).

Frontend Consumption

@mg/api-client uses a custom fetch wrapper that automatically unwraps the envelope — generated functions return the inner data directly, not the full { success, data } wrapper. See [[shared-packages]] for details.

Code Map

src/lib/api/response/factory.ts                   All factory functions
packages/shared-types/src/api/envelopes.ts         Envelope types, ErrorType, formatters, guards
packages/shared-types/src/common/pagination.ts     PaginationMeta, PaginatedResponse, createListResponse

Parent mgIds in entity payloads

When a response exposes an entity's mgId, it must also expose the mgId of every ancestor in that entity's parent chain, as raw fields on the nested parent objects. No consumer should ever receive an mgId it cannot locate.

  • Flat / global mgIds need parent fields. Reservation (RSV592), Transaction, Asset, Credit, Job, WaitlistEntry use bare global sequences (MgIdService.allocateMgId / buildAssetMgId). A reservation payload must include resource.mgId (which already carries the location). Then a consumer can render MG11-SMO33 RSV592.
  • Self-qualifying mgIds need nothing extra. Resource (MG11-SMO33), ResourceGroup, AccessGate, AssetPlacement embed their parent chain in the string already (buildResourceMgId et al.), so exposing that mgId already satisfies the rule.
  • Raw fields, not a joined string. Return resource.mgId + reservation.mgId as discrete values. Joining into a display ref is the consumer's job — use formatReservationRef from @mg/shared-utils. The API never invents a ref field.

Review checklist for new endpoints: if a response object emits a flat entity's mgId, confirm the nested parent (e.g. resource / location) selects and returns its mgId too.

Full rationale and the per-entity parent table: [[features/api-include-parent-mgids-in-response/spec|api-include-parent-mgids-in-response]].

See Also

  • [[api/overview|API]] — The request pipeline and how responses fit in
  • [[api/error-handling]] — How errors use the envelope
  • [[shared-packages]] — Where types and formatters are defined