- * - Json: any
- * - Param: Record
- * - Header: Record
- * - Cookie: Record
- *
- * Schemas can transform to any output type (pipes/transforms allowed).
- */
-export type EndpointDefinitionSchemas = {
- [K in keyof ValidationTargets as Capitalize]?:
- ValidationTargets[K] extends Record
- ? RecordSchemaFor[K]>
- : SchemaFor[K]>
-}
-
-// Endpoint definition contract
-export type EndpointDefinition = {
- Name: string
- Route: string
- Description?: string
- Methods: readonly ('GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH')[]
- Input: z.ZodType
- Output: z.ZodType
- Schemas: EndpointDefinitionSchemas
-}
diff --git a/utils/endpoint/schemas.ts b/utils/endpoint/schemas.ts
deleted file mode 100644
index d73d5ad..0000000
--- a/utils/endpoint/schemas.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-// schemas.ts
-import type { FormValue, ParsedFormValue } from 'hono/types';
-import type { EndpointDefinitionSchemas } from './definitions.ts';
-import { z } from 'zod';
-
-/**
- * Zod "string | string[]" union (mirrors Hono's `query` values).
- * Useful for normalizing query params that may be repeated.
- */
-export const ZStringOrStringArray = z.union([z.string(), z.array(z.string())]);
-
-/**
- * ### Base JSON Schema
- *
- * - Accepts any JSON-like value.
- * - Tighten per-endpoint with `.refine(...)`, `.transform(...)`, or `.pipe(...)`.
- */
-export const BaseJsonSchema = z.any() satisfies NonNullable;
-
-/**
- * ### Base Form Schema
- *
- * - **Input:** `Record`, where `T` defaults to `ParsedFormValue` (`string | File`).
- * - Uses `z.object({}).catchall(...)` so it remains a **ZodObject** (giving you `.shape` and `.extend(...)`).
- * - Extend per endpoint to restrict keys, change accepted types, or add transforms.
- *
- * @example
- * // Restrict to a specific form key and coerce a file name:
- * const UploadForm = makeBaseFormSchema().extend({
- * file: z.custom(),
- * note: z.union([z.string(), z.array(z.string())]).transform(v => Array.isArray(v) ? v[0] ?? '' : v),
- * });
- */
-export function makeBaseFormSchema() {
- const value = z.union([z.custom(), z.array(z.custom())]);
- return z.object({}).catchall(value) satisfies NonNullable['Form']>;
-}
-
-/**
- * @see {@link makeBaseFormSchema}
- */
-export const BaseFormSchema = makeBaseFormSchema();
-
-/**
- * ### Base Query Schema
- *
- * - **Input:** `Record`.
- * - Accepts arbitrary keys with `string | string[]` values.
- * - Extend per endpoint with strongly-typed keys and transforms.
- *
- * @example
- * const Query = BaseQuerySchema.extend({
- * page: z.coerce.number().int().min(1).default(1),
- * sort: ZStringOrStringArray.transform(v => Array.isArray(v) ? v[0] ?? '' : v),
- * });
- */
-export const BaseQuerySchema = z
- .object({})
- .catchall(ZStringOrStringArray) satisfies NonNullable;
-
-/**
- * ### Base Header Schema (extendable; no transforms)
- *
- * - **Input:** `Record`
- * - Kept as a `ZodObject` so you can `.extend(Other.shape)` or spread shapes.
- * - Apply normalization via `makeHeaderSchema(...)` when you’re done composing.
- */
-export const BaseHeaderSchema = z
- .object({})
- .catchall(z.string()) satisfies NonNullable;
-
-/**
- * ### makeHeaderSchema
- *
- * Finalize a header schema by applying the **key-normalization transform** (lowercase keys).
- *
- * - Accepts any **object** schema compatible with the `header` input target
- * (e.g., `BaseHeaderSchema`, or an extended version of it).
- * - Returns a **piped** schema (`ZodPipe`) whose **output** is `Record` with
- * lowercase keys. This is what you should actually **parse** with at the boundary.
- *
- * @example
- * // 1) Compose while the schema is still a ZodObject
- * const StrictHeader = BaseHeaderSchema.extend({
- * authorization: z.string().min(1),
- * });
- *
- * // 2) Only at the end, finalize with the transform
- * const Header = makeHeaderSchema(StrictHeader);
- *
- * // Now parse incoming headers
- * const parsed = Header.parse(reqHeaders);
- *
- * @example
- * // If you don't need to customize headers, just:
- * const Header = makeHeaderSchema(); // uses BaseHeaderSchema by default
- */
-export function makeHeaderSchema<
- // Infer the Input side from whatever object schema you pass in
- S extends typeof BaseHeaderSchema
->(schema?: S) {
- const base = (schema ?? BaseHeaderSchema) as S;
- // Apply the transform at the very end; result is a ZodPipe (no .shape/.extend)
- return base.transform((rec) => {
- type K = (keyof typeof rec);
- const out: Record = Object.create(null);
- for (const k in rec) {
- if (typeof k === "string") {
- out[(k as string).toLowerCase()] = rec[k];
- }
- }
- return out;
- });
-}
-
-/**
- * ### Base Cookie Schema
- *
- * - **Input:** `Record`.
- * - Simple string map; extend per endpoint to constrain or transform specific cookie keys.
- */
-export const BaseCookieSchema = z
- .object({})
- .catchall(z.string()) satisfies NonNullable;
-
-
-/**
- * ### Base Params Schema
- */
-export const BaseParamSchema = z
- .object({})
- .catchall(z.string()) satisfies NonNullable
-
diff --git a/utils/query/fields.ts b/utils/query/fields.ts
deleted file mode 100644
index 623530d..0000000
--- a/utils/query/fields.ts
+++ /dev/null
@@ -1,371 +0,0 @@
-// utils/query/fields.ts
-/**
- * Field selection (sparse fieldsets) with multi-source support
- *
- * Features:
- * - JSON:API syntax: fields[type]=a,b,c
- * - Simple syntax: fields=a,b,c
- * - Field allowlists via registry
- * - De-duplication
- * - Wildcard handling
- * - Query/JSON/FormData source adapters
- */
-
-import { z } from 'zod'
-
-import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '../endpoint/schemas.ts'
-import {
- FieldSelectionNormalizedSchema,
- type FieldsConfig,
- type FieldSelectionNormalized
-} from './schemas.ts'
-
-// ============================================================================
-// WIRE SCHEMAS (raw incoming data)
-// ============================================================================
-
-/**
- * Query parameter wire schema (raw incoming)
- * Supports both:
- * - Simple: ?fields=a,b,c
- * - JSON:API: ?fields[products]=a,b,c&fields[categories]=x,y
- */
-export const FieldsQueryWire = BaseQuerySchema
-
-/**
- * JSON body wire schema (raw incoming)
- * Expects: { fields: { type: 'simple', fields: ['a', 'b'] } }
- */
-export const FieldsJsonWire = BaseJsonSchema.pipe(
- z.object({
- fields: FieldSelectionNormalizedSchema.nullable().default(null)
- })
-)
-
-/**
- * FormData wire schema (raw incoming)
- */
-export const FieldsFormWire = BaseFormSchema
-
-// ============================================================================
-// HELPER FUNCTIONS
-// ============================================================================
-
-/**
- * Extract string value from ZStringOrStringArray
- */
-const getString = (val: unknown): string | undefined => {
- if (Array.isArray(val)) return val[0]
- if (typeof val === 'string') return val
- return undefined
-}
-
-/**
- * Detect JSON:API vs simple syntax from query/form data
- *
- * Detection priority:
- * 1. Check for JSON:API syntax: fields[type]=...
- * 2. Fall back to simple syntax: fields=...
- * 3. Return null if neither found
- *
- * @param data Raw query or form data
- * @returns Normalized field selection or null
- *
- * @example
- * // JSON:API syntax
- * detectFieldSyntax({ 'fields[products]': 'id,name', 'fields[categories]': 'name' })
- * // => { type: 'jsonapi', fields: { products: ['id', 'name'], categories: ['name'] } }
- *
- * // Simple syntax
- * detectFieldSyntax({ fields: 'id,name,price' })
- * // => { type: 'simple', fields: ['id', 'name', 'price'] }
- *
- * // No field selection
- * detectFieldSyntax({ page: '1' })
- * // => null
- */
-function detectFieldSyntax(data: Record): FieldSelectionNormalized | null {
- // Check for JSON:API syntax: fields[type]=...
- const jsonApiFields: Record = {}
-
- for (const [key, value] of Object.entries(data)) {
- const match = key.match(/^fields\[([^\]]+)\]$/)
- if (match) {
- const type = match[1]
-
- // Extract value (handle arrays from ZStringOrStringArray)
- const stringValue = String(getString(value) ?? '')
- const fields = stringValue
- .split(',')
- .map(f => f.trim())
- .filter(f => f)
-
- if (fields.length > 0) {
- jsonApiFields[type] = fields
- }
- }
- }
-
- if (Object.keys(jsonApiFields).length > 0) {
- return { type: 'jsonapi', fields: jsonApiFields }
- }
-
- // Fall back to simple syntax: fields=...
- const fieldsValue = data.fields
- if (fieldsValue) {
- // Extract value (handle arrays from ZStringOrStringArray)
- const stringValue = String(getString(fieldsValue) ?? '')
- const fields = stringValue
- .split(',')
- .map(f => f.trim())
- .filter(f => f)
-
- if (fields.length > 0) {
- return { type: 'simple', fields }
- }
- }
-
- return null
-}
-
-/**
- * Encode field selection back to wire format
- * Used for round-trip serialization
- *
- * @param selection Normalized field selection
- * @returns Wire format object
- *
- * @example
- * encodeFieldSelection({ type: 'simple', fields: ['id', 'name'] })
- * // => { fields: 'id,name' }
- *
- * encodeFieldSelection({
- * type: 'jsonapi',
- * fields: { products: ['id', 'name'], categories: ['name'] }
- * })
- * // => { 'fields[products]': 'id,name', 'fields[categories]': 'name' }
- */
-function encodeFieldSelection(selection: FieldSelectionNormalized | null): Record {
- if (!selection) return {}
-
- if (selection.type === 'simple') {
- return { fields: selection.fields.join(',') }
- }
-
- // JSON:API format
- const result: Record = {}
- for (const [type, fields] of Object.entries(selection.fields)) {
- result[`fields[${type}]`] = fields.join(',')
- }
- return result
-}
-
-// ============================================================================
-// SOURCE ADAPTERS
-// ============================================================================
-
-/**
- * Query parameter adapter (uses z.codec)
- * Supports: ?fields=a,b,c or ?fields[products]=a,b,c
- *
- * @example
- * const adapter = createFieldsQueryAdapter()
- *
- * // Simple syntax
- * const simple = adapter.decode({ fields: 'id,name,price' })
- * // => { type: 'simple', fields: ['id', 'name', 'price'] }
- *
- * // JSON:API syntax
- * const jsonapi = adapter.decode({
- * 'fields[products]': 'id,name',
- * 'fields[categories]': 'name'
- * })
- * // => { type: 'jsonapi', fields: { products: ['id', 'name'], categories: ['name'] } }
- */
-export function createFieldsQueryAdapter() {
- return z.codec(
- FieldsQueryWire, // Input (wire)
- FieldSelectionNormalizedSchema.nullable(), // Output (normalized)
- {
- decode: (raw) => {
- return detectFieldSyntax(raw)
- },
- encode: (normalized) => {
- return encodeFieldSelection(normalized)
- }
- }
- )
-}
-
-/**
- * JSON body adapter (uses z.codec)
- * Expects: { fields: { type: 'simple', fields: ['a', 'b'] } }
- *
- * @example
- * const adapter = createFieldsJsonAdapter()
- * const normalized = adapter.decode({
- * fields: { type: 'simple', fields: ['id', 'name'] }
- * })
- * // => { type: 'simple', fields: ['id', 'name'] }
- */
-export function createFieldsJsonAdapter() {
- return z.codec(
- FieldsJsonWire, // Input (wire)
- FieldSelectionNormalizedSchema.nullable(), // Output (normalized)
- {
- decode: (raw) => {
- return raw.fields as FieldSelectionNormalized | null
- },
- encode: (normalized) => {
- return { fields: normalized }
- }
- }
- )
-}
-
-/**
- * FormData adapter (uses z.codec)
- * Supports: fields=a,b,c or fields[products]=a,b,c
- *
- * @example
- * const adapter = createFieldsFormAdapter()
- * const formData = new FormData()
- * formData.append('fields', 'id,name,price')
- * const normalized = adapter.decode(formData)
- * // => { type: 'simple', fields: ['id', 'name', 'price'] }
- */
-export function createFieldsFormAdapter() {
- return z.codec(
- FieldsFormWire, // Input (wire)
- FieldSelectionNormalizedSchema.nullable(), // Output (normalized)
- {
- decode: (raw): FieldSelectionNormalized | null => {
- // Convert FormValue to plain record for detectFieldSyntax
- const plainObj: Record = {}
-
- for (const [key, value] of Object.entries(raw)) {
- plainObj[key] = String(getString(value) ?? '')
- }
-
- return detectFieldSyntax(plainObj)
- },
- encode: (normalized) => {
- return encodeFieldSelection(normalized)
- }
- }
- )
-}
-
-// ============================================================================
-// SCHEMA COMPOSITION WITH VALIDATION
-// ============================================================================
-
-/**
- * Create endpoint-specific fields schema with validation
- * All validation happens in .superRefine() so middleware handles errors
- *
- * @param config Configuration for field selection validation
- * @param config.source Input source type ('query' | 'json' | 'form')
- * @param config.allowedFields Array of selectable field names (allowlist)
- * @param config.resourceType Resource type for JSON:API validation
- * @param config.defaultFields Default fields when none provided
- *
- * @example
- * const schema = createFieldsSchema({
- * source: 'query',
- * allowedFields: ['id', 'name', 'price', 'created_at'],
- * defaultFields: ['id', 'name']
- * })
- *
- * // Parse and validate
- * const fields = schema.parse({ fields: 'id,name,price' })
- * // => { type: 'simple', fields: ['id', 'name', 'price'] }
- *
- * // With wildcard
- * const allFields = schema.parse({ fields: '*' })
- * // => { type: 'simple', fields: ['id', 'name', 'price', 'created_at'] }
- *
- * // Invalid field rejected
- * schema.parse({ fields: 'id,invalid_field' })
- * // => Throws validation error
- */
-export function createFieldsSchema(config: {
- source: 'query' | 'json' | 'form'
-} & FieldsConfig) {
- // When disabled, always return null
- if (config.disabled) {
- return z.null()
- }
-
- const adapter =
- config.source === 'query' ? createFieldsQueryAdapter() :
- config.source === 'json' ? createFieldsJsonAdapter() :
- createFieldsFormAdapter()
-
- return adapter
- .transform((selection): FieldSelectionNormalized | null => {
- // Apply defaults when no selection provided
- if (!selection && config.defaults && config.defaults.length > 0) {
- return {
- type: 'simple',
- fields: [...config.defaults] // Copy to avoid mutation
- }
- }
- return selection
- })
- .superRefine((selection, ctx) => {
- // No selection - use default or allow null
- if (!selection) { return; }
-
- // Get fields to validate based on selection type
- const fieldsToValidate =
- selection.type === 'simple'
- ? selection.fields
- : config.resourceType
- ? selection.fields[config.resourceType] ?? []
- : Object.values(selection.fields).flat()
-
- // De-duplicate fields
- const uniqueFields = Array.from(new Set(fieldsToValidate))
-
- // Handle wildcard
- const hasWildcard = uniqueFields.includes('*')
- if (hasWildcard) {
- // Wildcard with no restrictions
- if (!config.allowedFields) return
-
- // Replace wildcard with all allowed fields
- if (selection.type === 'simple') {
- selection.fields = [...config.allowedFields]
- } else if (config.resourceType) {
- selection.fields[config.resourceType] = [...config.allowedFields]
- }
-
- return
- }
-
- // Validate against allowlist
- if (config.allowedFields && config.allowedFields.length > 0) {
- const invalidFields = uniqueFields.filter(f => !config.allowedFields!.includes(f))
-
- if (invalidFields.length > 0) {
- ctx.addIssue({
- code: "custom",
- path: selection.type === 'simple' ? ['fields'] : ['fields', config.resourceType ?? '*'],
- message: `Invalid fields: ${invalidFields.join(', ')}. Allowed fields: ${config.allowedFields.join(', ')}`
- })
- return
- }
- }
-
- // De-duplicate fields in the selection
- if (selection.type === 'simple') {
- selection.fields = uniqueFields
- } else {
- // For JSON:API, de-duplicate each resource type
- for (const [type, fields] of Object.entries(selection.fields)) {
- selection.fields[type] = Array.from(new Set(fields))
- }
- }
- })
-}
\ No newline at end of file
diff --git a/utils/query/filtering.ts b/utils/query/filtering.ts
deleted file mode 100644
index 26532d7..0000000
--- a/utils/query/filtering.ts
+++ /dev/null
@@ -1,692 +0,0 @@
-// utils/query/filtering.ts
-
-/**
- * Query Filtering — URL shapes, parsing, validation, and why `arrayOperators` exist.
- *
- * ## The two client-facing representations
- *
- * 1) **Bracket notation (URL-friendly; recommended for GET)**
- * Structure: `filter[][]=`
- *
- * - Operator is optional; missing operator implies `eq`.
- * Example:
- * `/api/posts?filter[status]=published`
- * → `status eq 'published'` (operator defaults to `eq`).
- *
- * - Multiple filters AND together:
- * `/api/products?filter[category]=electronics&filter[price][gte]=50&filter[price][lte]=200`
- * → `category = 'electronics' AND price >= 50 AND price <= 200`.
- *
- * - Null checks use keywords (not booleans):
- * `/api/posts?filter[deleted_at]=null` → `IS NULL`
- * `/api/posts?filter[published_at]=not_null` → `IS NOT NULL`.
- *
- * - Set operators use comma-separated lists in the URL:
- * `/api/issues?filter[status][in]=open,in_review,blocked`
- * `/api/issues?filter[status][nin]=wontfix,duplicate`.
- *
- * Parsing flow:
- * - `parseBracketNotation(...)` walks query keys with `/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/`,
- * extracts `{ field, operator? }`, defaults operator to `eq`, and turns `null` / `not_null`
- * into `is_null` / `is_not_null`. The rest pass through as `{ field, operator, value }`.
- * This produces a `FiltersNormalized[]` array for downstream validation.
- *
- * 2) **Structured JSON filters (body POST/PUT), or URL-encoded JSON as a single param**
- * Shape:
- * ```json
- * { "filters": [
- * { "field": "category", "operator": "eq", "value": "electronics" },
- * { "field": "price", "operator": "gte","value": "50" }
- * ] }
- * ```
- * - Use `createFiltersJsonAdapter()` to parse the request body into `FiltersNormalized`.
- * If you must keep GET-only, you *may* URL-encode the JSON into a single `filters` param
- * and parse it prior to validation, then feed this same adapter.
- *
- * ## Where `arrayOperators` matter
- *
- * - Instead of hardcoding that `'in'`/`'nin'` always expect arrays, each field’s registry entry
- * can *declare* which operators consume arrays via `arrayOperators`. This allows per-field policy
- * (e.g., `status` allows `in`; `title` does not), and future operators like `between`, `overlaps`,
- * or geo/json containment to be opt-in for the fields that support them.
- *
- * - In bracket notation, clients still send comma-separated lists; validation reads the registry
- * to decide whether to split/coerce into arrays. In JSON, clients send actual arrays for `value`.
- * Either way, we normalize to the *same* `FiltersNormalized` shape.
- *
- * ## Validation layer (what this module enforces)
- *
- * - **Allowlist fields & operators per resource**: We require a `FilterRegistry` describing
- * what’s filterable and with which operators, including the expected scalar type (string/number/
- * boolean/date/enum/uuid) and allowed enum values.
- *
- * - **Type-aware coercion**: The validator converts values based on the field type and operator:
- * - numbers for `gt/gte/lt/lte/eq/ne` (rejects NaN),
- * - booleans for `eq/ne`,
- * - dates to ISO strings for `gt/gte/lt/lte/eq/ne` (rejects invalid dates),
- * - enum membership checks,
- * - UUID format checks, etc.
- *
- * - **Null operators** never require a `value` (`is_null`, `is_not_null`).
- *
- * - **Array operators** (as declared per field) require arrays; URL commas split into arrays.
- * (We also recommend adding per-endpoint caps on list lengths via `LimitsConfig` to avoid abuse.)
- *
- * - **DoS protection**: We cap the **number of filters** per request (default 20; configurable)
- * and fail fast if exceeded.
- *
- * ## Security & performance notes
- *
- * - **Security**: Always allowlist fields/operators and type-check values before building queries.
- * Don’t ever string-concatenate SQL; use parameterized calls.
- *
- * - **Performance**: Index columns you filter/sort on; prefer compound indexes that align to
- * common filter+sort patterns. This is especially important for high-cardinality fields and
- * for cursor-based pagination where tiebreakers (e.g., `created_at,id`) must be indexed.
- *
- * ## Worked examples (URL)
- *
- * - Equality (implicit operator):
- * `/api/posts?filter[status]=published` → `{ field:'status', operator:'eq', value:'published' }`
- *
- * - Ranged price:
- * `/api/products?filter[price][gte]=50&filter[price][lte]=200` → two normalized filters; numbers coerced.
- *
- * - Null checks:
- * `/api/posts?filter[deleted_at]=null&filter[published_at]=not_null` → `is_null` / `is_not_null` (no values).
- *
- * - Set membership (array operator via registry):
- * `/api/issues?filter[status][in]=open,in_review,blocked`
- * → value coerced to `['open','in_review','blocked']` when `status` declares `in` under `arrayOperators`.
- *
- * ## Worked examples (JSON)
- *
- * ```json
- * {
- * "filters": [
- * { "field": "category", "operator": "eq", "value": "electronics" },
- * { "field": "price", "operator": "gte", "value": "50" },
- * { "field": "status", "operator": "in", "value": ["open","in_review","blocked"] }
- * ]
- * }
- * ```
- * - Parsed via `createFiltersJsonAdapter()` into the same `FiltersNormalized[]`; array handling
- * and type coercion follow the registry+validation rules above.
- *
- * ---
- *
- * Implementation notes:
- * - The adapters (`createFiltersQueryAdapter`, `createFiltersJsonAdapter`, `createFiltersFormAdapter`)
- * only handle source parsing: they normalize different input formats (query strings, JSON bodies, form data)
- * into a consistent internal shape.
- *
- * - The actual business rules live in `createFiltersSchema(...).superRefine(...)`:
- * • Enforce allowlists (only certain fields/operators allowed)
- * • Match operators to correct value types
- * • Split arrays consistently (e.g. "a,b,c" → ["a","b","c"])
- * • Apply caps/limits (e.g. max filters)
- *
- * - This separation keeps parsing concerns **independent from** validation rules,
- * making the system easier to extend and reason about.
- */
-
-import { z } from 'zod'
-
-import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '../endpoint/schemas.ts'
-import {
- FiltersNormalizedSchema,
- type FilterNormalized,
- type FiltersNormalized,
- type FilterOperator,
- type FilterRegistry,
- type FiltersConfig,
-} from './schemas.ts'
-
-// ============================================================================
-// WIRE SCHEMAS (raw incoming data)
-// ============================================================================
-
-/**
- * Query parameter wire schema (raw incoming)
- * Supports: ?filter[category][eq]=electronics&filter[price][gte]=50
- */
-export const FiltersQueryWire = BaseQuerySchema
-
-/**
- * JSON body wire schema (raw incoming)
- * Expects: { filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] }
- */
-export const FiltersJsonWire = BaseJsonSchema.pipe(
- z.object({
- filters: FiltersNormalizedSchema.default([])
- })
-)
-
-/**
- * FormData wire schema (raw incoming)
- */
-export const FiltersFormWire = BaseFormSchema
-
-// ============================================================================
-// HELPER FUNCTIONS
-// ============================================================================
-
-/**
- * Parse bracket notation from query parameters or form data
- * Syntax: filter[field][operator]=value
- *
- * @param data Raw query or form data
- * @returns Array of normalized filters
- *
- * @example
- * parseBracketNotation({
- * 'filter[status]': 'published',
- * 'filter[price][gte]': '50'
- * })
- * // => [
- * // { field: 'status', operator: 'eq', value: 'published' },
- * // { field: 'price', operator: 'gte', value: '50' }
- * // ]
- */
-function parseBracketNotation(data: Record): FiltersNormalized {
- const filters: FilterNormalized[] = []
-
- for (const [key, rawValue] of Object.entries(data)) {
- const match = key.match(/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/)
- if (!match) continue
-
- const field = match[1]
- const operator = (match[2] || 'eq') as FilterOperator
-
- // Extract value (handle arrays from ZStringOrStringArray)
- let value: string
- if (Array.isArray(rawValue)) {
- value = String(rawValue[0] ?? '')
- } else {
- value = String(rawValue ?? '')
- }
-
- // Handle null keywords
- if (value === 'null') {
- filters.push({ field, operator: 'is_null' })
- } else if (value === 'not_null') {
- filters.push({ field, operator: 'is_not_null' })
- } else {
- filters.push({ field, operator, value })
- }
- }
-
- return filters
-}
-
-/**
- * Encode filters back to bracket notation
- *
- * @param filters Normalized filters array
- * @returns Wire format object
- *
- * @example
- * encodeBracketNotation([
- * { field: 'status', operator: 'eq', value: 'published' },
- * { field: 'price', operator: 'gte', value: 50 }
- * ])
- * // => {
- * // 'filter[status]': 'published',
- * // 'filter[price][gte]': '50'
- * // }
- */
-function encodeBracketNotation(filters: FiltersNormalized): Record {
- const result: Record = {}
-
- for (const filter of filters) {
- // Handle null operators (no value)
- if (filter.operator === 'is_null') {
- result[`filter[${filter.field}]`] = 'null'
- continue
- }
-
- if (filter.operator === 'is_not_null') {
- result[`filter[${filter.field}]`] = 'not_null'
- continue
- }
-
- // Handle value-based operators
- const value = filter.value
- const stringValue = Array.isArray(value)
- ? value.join(',')
- : String(value ?? '')
-
- // Use bracket notation for non-eq operators
- const key = filter.operator === 'eq'
- ? `filter[${filter.field}]`
- : `filter[${filter.field}][${filter.operator}]`
-
- result[key] = stringValue
- }
-
- return result
-}
-
-// ============================================================================
-// SOURCE ADAPTERS
-// ============================================================================
-
-/**
- * Query parameter adapter (uses z.codec)
- * Supports: ?filter[category][eq]=electronics&filter[price][gte]=50
- *
- * @example
- * const adapter = createFiltersQueryAdapter()
- * const normalized = adapter.decode({
- * 'filter[status]': 'published',
- * 'filter[price][gte]': '50'
- * })
- * // => [
- * // { field: 'status', operator: 'eq', value: 'published' },
- * // { field: 'price', operator: 'gte', value: '50' }
- * // ]
- */
-export function createFiltersQueryAdapter() {
- return z.codec(
- FiltersQueryWire, // Input (wire)
- FiltersNormalizedSchema, // Output (normalized)
- {
- decode: (raw) => {
- return parseBracketNotation(raw)
- },
- encode: (normalized) => {
- return encodeBracketNotation(normalized)
- }
- }
- )
-}
-
-/**
- * JSON body adapter (uses z.codec)
- * Expects: { filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] }
- *
- * @example
- * const adapter = createFiltersJsonAdapter()
- * const normalized = adapter.decode({
- * filters: [{ field: 'category', operator: 'eq', value: 'electronics' }]
- * })
- */
-export function createFiltersJsonAdapter() {
- return z.codec(
- FiltersJsonWire, // Input (wire)
- FiltersNormalizedSchema, // Output (normalized)
- {
- decode: (raw) => {
- return raw.filters as FiltersNormalized
- },
- encode: (normalized) => {
- return { filters: normalized }
- }
- }
- )
-}
-
-/**
- * FormData adapter (uses z.codec)
- * Supports same bracket notation as query adapter
- *
- * @example
- * const adapter = createFiltersFormAdapter()
- * const formData = new FormData()
- * formData.append('filter[status]', 'published')
- * const normalized = adapter.decode(formData)
- */
-export function createFiltersFormAdapter() {
- return z.codec(
- FiltersFormWire, // Input (wire)
- FiltersNormalizedSchema, // Output (normalized)
- {
- decode: (raw): FiltersNormalized => {
- // Convert FormValue to plain record for parseBracketNotation
- return parseBracketNotation(raw)
- },
- encode: (normalized) => {
- return encodeBracketNotation(normalized)
- }
- }
- )
-}
-
-// ============================================================================
-// VALIDATION HELPERS
-// ============================================================================
-
-/**
- * Validate and coerce operator value based on field type
- * Mutates the filter object with coerced value
- *
- * @param filter Filter to validate and coerce
- * @param fieldDef Field definition from registry
- * @param ctx Zod refinement context for error reporting
- * @param path Path for error reporting
- */
-function validateAndCoerceOperatorValue(
- filter: FilterNormalized,
- fieldDef: FilterRegistry[string],
- ctx: z.RefinementCtx,
- path: (string | number)[]
-) {
- const { operator, value, field } = filter
-
- // Null operators don't need values
- if (operator === 'is_null' || operator === 'is_not_null') {
- return
- }
-
- // Between operator needs exactly 2 values
- if (operator === 'between') {
- let arrayValue: unknown[]
- if (Array.isArray(value)) {
- arrayValue = value
- } else if (typeof value === 'string') {
- arrayValue = value.split(',').map(v => v.trim()).filter(Boolean)
- } else {
- arrayValue = [value]
- }
-
- if (arrayValue.length !== 2) {
- ctx.addIssue({
- code: 'custom',
- input: ctx.value,
- path: [...path, 'value'],
- message: `Operator 'between' on field '${field}' requires exactly 2 values (min,max). Got ${arrayValue.length}.`
- })
- return
- }
-
- // Coerce based on field type
- if (fieldDef.type === 'number') {
- const [min, max] = arrayValue.map(Number)
- if (isNaN(min) || isNaN(max)) {
- ctx.addIssue({
- code: 'custom',
- input: ctx.value,
- path: [...path, 'value'],
- message: `Field '${field}' requires numeric values for 'between' operator`
- })
- return
- }
-
- if (min > max) {
- ctx.addIssue({
- code: 'custom',
- input: ctx.value,
- path: [...path, 'value'],
- message: `Field '${field}' 'between' operator requires min <= max. Got min=${min}, max=${max}.`
- })
- return
- }
- filter.value = [min, max]
- } else if (fieldDef.type === 'date') {
- const [minDate, maxDate] = arrayValue.map(v => new Date(String(v)))
- if (isNaN(minDate.getTime()) || isNaN(maxDate.getTime())) {
- ctx.addIssue({
- code: 'custom',
- input: ctx.value,
- path: [...path, 'value'],
- message: `Field '${field}' requires valid dates for 'between' operator`
- })
- return
- }
-
- if (minDate > maxDate) {
- ctx.addIssue({
- code: 'custom',
- input: ctx.value,
- path: [...path, 'value'],
- message: `Field '${field}' 'between' operator requires start date <= end date.`
- })
- return
- }
-
- filter.value = [minDate.toISOString(), maxDate.toISOString()]
- } else {
- ctx.addIssue({
- code: 'custom',
- input: ctx.value,
- path: [...path, 'operator'],
- message: `Operator 'between' not supported for field type '${fieldDef.type}' on field '${field}'`
- })
- }
- return
- }
-
- // Array operators need special handling
- const isArrayOperator = fieldDef.arrayOperators?.includes(operator) ?? false
- if (isArrayOperator) {
- // Ensure value is an array
- let arrayValue: unknown[]
- if (Array.isArray(value)) {
- arrayValue = value
- } else if (typeof value === 'string') {
- arrayValue = value.split(',').map(v => v.trim()).filter(Boolean)
- } else {
- arrayValue = [value]
- }
-
- if (arrayValue.length === 0) {
- ctx.addIssue({
- code: 'too_small', // v4 literal (not the enum)
- minimum: 1,
- origin: 'array',
- path: [...path, 'value'],
- message: `Operator '${operator}' on field '${field}' requires an array or comma-separated value with at least 1 value.`
- });
- return; // stop further processing for this filter
- }
-
- filter.value = arrayValue
- return
- }
-
- // Non-array operators - coerce based on field type
- switch (fieldDef.type) {
- case 'number':
- if (['gt', 'gte', 'lt', 'lte', 'eq', 'ne'].includes(operator)) {
- const numValue = Number(value)
- if (isNaN(numValue)) {
- ctx.addIssue({
- code: "custom",
- input: ctx.value,
- path: [...path, 'value'],
- message: `Field '${field}' requires numeric value for operator '${operator}'`
- })
- return
- }
-
- filter.value = numValue
- }
- break
-
- case 'boolean':
- if (operator === 'eq' || operator === 'ne') {
- const boolValue = value === 'true' || value === '1' || value === 1
- filter.value = boolValue
- }
- break
-
- case 'date':
- if (['gt', 'gte', 'lt', 'lte', 'eq', 'ne'].includes(operator)) {
- const dateValue = new Date(String(value))
- if (isNaN(dateValue.getTime())) {
- ctx.addIssue({
- code: "custom",
- input: ctx.value,
- path: [...path, 'value'],
- message: `Field '${field}' requires valid date for operator '${operator}'`
- })
- return
- }
-
- filter.value = dateValue.toISOString()
- }
- break
-
- case 'enum':
- if (operator === 'eq' || operator === 'ne') {
- const strValue = String(value)
- if (fieldDef.values && !fieldDef.values.includes(strValue)) {
- ctx.addIssue({
- code: "custom",
- input: ctx.value,
- path: [...path, 'value'],
- message: `Invalid value '${strValue}' for enum field '${field}'. Allowed values: ${fieldDef.values.join(', ')}`
- })
-
- return
- }
- }
- break
-
- case 'uuid':
- if (operator === 'eq' || operator === 'ne') {
- const uuidCheck = z.uuid().safeParse(String(value))
- if (!uuidCheck.success) {
- const uuidIssue = uuidCheck.error.issues[0];
- ctx.addIssue({
- ...uuidIssue,
- path: [...path, 'value'],
- message: `Field '${field}' requires valid UUID for operator '${operator}'. ${uuidIssue.message}`
- })
- return
- }
- }
- break
-
- case 'string':
- // String operators accept any value, coerce to string
- filter.value = String(value)
- break
- }
-}
-
-// ============================================================================
-// SCHEMA COMPOSITION WITH VALIDATION
-// ============================================================================
-
-/**
- * Create endpoint-specific filters schema with validation
- * All validation happens in .superRefine() so middleware handles errors
- *
- * @param config Configuration for filter validation
- * @param config.source Input source type ('query' | 'json' | 'form')
- * @param config.registry Filter registry with field definitions
- * @param config.limits Optional limits configuration (maxFilters)
- *
- * @example
- * const schema = createFiltersSchema({
- * source: 'query',
- * registry: {
- * price: {
- * operators: ['gte', 'lte'],
- * type: 'number'
- * },
- * status: {
- * operators: ['eq', 'in'],
- * type: 'enum',
- * values: ['draft', 'published'],
- * arrayOperators: ['in']
- * }
- * },
- * limits: { maxFilters: 10 }
- * })
- *
- * // Parse and validate
- * const filters = schema.parse({
- * 'filter[price][gte]': '50',
- * 'filter[status][in]': 'draft,published'
- * })
- */
-export function createFiltersSchema(config: {
- source: 'query' | 'json' | 'form'
-} & FiltersConfig) {
- // When disabled, always return null
- if (config.disabled) {
- return z.null()
- }
-
- // Select appropriate adapter based on source
- const adapter =
- config.source === 'query' ? createFiltersQueryAdapter() :
- config.source === 'json' ? createFiltersJsonAdapter() :
- createFiltersFormAdapter()
-
- const maxFilters = config.limits?.maxFilters ?? 20
- const mergeDefaults = config.mergeDefaults ?? true
-
- const registry = config.registry ?? {}
- const allowedFields = Object.keys(registry)
-
- return adapter
- .transform((filters): FiltersNormalized => {
- // Apply default filters
- if (config.defaults && config.defaults.length > 0) {
- if (filters.length === 0) {
- // No user filters - use defaults
- return [...config.defaults]
- } else if (mergeDefaults) {
- // Merge defaults with user filters
- // Defaults come first (applied before user filters)
- return [...config.defaults, ...filters]
- }
- }
-
- return filters
- })
- .superRefine((filters, ctx) => {
- // Check filter count (DoS protection)
- if (filters.length > maxFilters) {
- ctx.addIssue({
- code: "too_big",
- maximum: maxFilters,
- origin: 'array',
- path: [],
- message: `Too many filters: maximum ${maxFilters} allowed, got ${filters.length}`
- })
- return // Don't continue validating individual filters
- }
-
- // No registry = skip field/operator validation
- if (!config.registry) {
- return
- }
-
- // Validate each filter
- filters.forEach((filter, idx) => {
- const { field, operator = 'eq', value } = filter
-
- // Check if field is filterable
- const fieldDef = registry[field]
- if (!fieldDef && allowedFields.length > 0) {
- ctx.addIssue({
- code: "custom",
- input: ctx.value,
- path: [idx, 'field'],
- message: `Field '${field}' is not filterable. Allowed fields: ${allowedFields.join(', ')}`
- })
- return // Skip further validation for this filter
- }
-
- // If field is in registry, validate operator
- if (fieldDef) {
- if (!fieldDef.operators.includes(operator)) {
- ctx.addIssue({
- code: "custom",
- input: ctx.value,
- path: [idx, 'operator'],
- message: `Operator '${operator}' not allowed for field '${field}'. Allowed operators: ${fieldDef.operators.join(', ')}`
- })
- return
- }
-
- // Validate operator-value type compatibility and coerce
- validateAndCoerceOperatorValue(filter, fieldDef, ctx, [idx])
- }
- })
- })
-}
\ No newline at end of file
diff --git a/utils/query/index.ts b/utils/query/index.ts
deleted file mode 100644
index c728107..0000000
--- a/utils/query/index.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-// utils/query/index.ts
-/**
- * Public surface for query utilities.
- * - Adapters and schema factories
- * - Cursor helpers and executor
- */
-
-export * from './schemas.ts'
-export * from './filtering.ts'
-export * from './sorting.ts'
-export * from './fields.ts'
-export * from './pagination.ts'
-export * from './query.ts'
-
-/**
- * Example (Hono-like) handler usage
- *
- * import { createEndpoindQuerySpec } from '@platform/backend/query/schemas.ts'
- * import { createValidator } from '#shared/middleware/validation.ts' // your existing middleware factory
- *
- * import { executeListQuery } from '#shared/query/execution/supabase.ts'
- * import { paginate, gone, badRequest } from '@platform/backend/response/index.ts'
- *
- * const QueryValidator = createValidator('query', createEndpoindQuerySpec({
- * filters: { registry: { age: { operators: ['gte', 'lte'], valueType: 'number' } } },
- * sorts: { allowedFields: ['created_at','title'] },
- * fields: { allowlist: ['*','id','title','created_at','posts.*'] },
- * pagination: { cursorSecret: Deno.env.get('CURSOR_SECRET') ?? '' },
- * }))
- *
- * app.get('/items', QueryValidator, async (c) => {
- * const spec = c.req.valid('query') // -> QuerySpec
- *
- * const result = await executeListQuery(
- * { secret: Deno.env.get('CURSOR_SECRET') ?? undefined, ttlSec: 3600 },
- * spec,
- * async ({ filters, sorts, fields, pagination }) => {
- * // TODO: translate filters/sorts/fields/pagination → your DB query
- * return { rows: [], count: 0 }
- * }
- * )
- *
- * if (result.errorType === 'cursor_expired') {
- * return c.json(...gone(c.req.path, 'Cursor expired'))
- * }
- * if (result.errorType === 'cursor_invalid') {
- * return c.json(...badRequest(c.req.path, 'Invalid cursor'))
- * }
- *
- * // Use your existing paginate() response helper (data + meta + headers in tuple)
- * // Example expects you to compute pagination meta (next/prev cursors) alongside rows.
- * return c.json(...paginate(c.req.url, result.data ?? [], { count: result.count ?? 0 }))
- * })
- */
diff --git a/utils/query/pagination.ts b/utils/query/pagination.ts
deleted file mode 100644
index 1cf07db..0000000
--- a/utils/query/pagination.ts
+++ /dev/null
@@ -1,607 +0,0 @@
-// utils/query/pagination.ts
-/**
- * Cursor-based and offset-based pagination with multi-source support
- *
- * Features:
- * - Auto-detects cursor vs offset from any source
- * - HMAC-signed cursors with expiration
- * - Configurable limits per endpoint
- * - 410 Gone for expired cursors
- * - Query/JSON/FormData source adapters
- */
-
-import type { CursorPaginationNormalized, OffsetPaginationNormalized, PaginationConfig, SortDirection } from './schemas.ts'
-import type { Pagination, PaginationMetadata } from '../response/schemas.ts'
-import type { QuerySpec } from './schemas.ts'
-
-import { z } from 'zod'
-import { createHmac } from 'node:crypto'
-import { Buffer } from 'node:buffer'
-
-import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '../endpoint/schemas.ts'
-import {
- PaginationNormalizedSchema,
- CursorDataSchema,
- type CursorData,
-} from './schemas.ts'
-import { badRequest, ok, gone } from '../response/index.ts'
-import { isSuccessResponse } from '../response/success.ts'
-
-// ============================================================================
-// CURSOR ENCODING/DECODING (existing, unchanged)
-// ============================================================================
-
-const EncodedCursorSchema = z.object({
- data: CursorDataSchema,
- signature: z.hex().length(64)
-}).strict()
-
-/**
- * @based on `@mofax/sorted-stringify` https://jsr.io/@mofax/sorted-stringify/0.0.4/index.ts
- * Recursively sorts the keys of an object or elements of an array.
- *
- * - If the input is not an object or array, the value is returned as is.
- * - If the input is an array, it recursively sorts each element in the array.
- * - If the input is an object, it sorts the object by its keys and recursively sorts the values.
- *
- * @param obj - The input object, array, or any other value to be sorted.
- * @returns The sorted object, array, or the original value if it's not an object or array.
- *
- * @example
- * ```typescript
- * const obj = {
- * z: 1,
- * a: { c: 3, b: 2 },
- * array: [ { b: 2, a: 1 }, 3 ]
- * };
- * const sortedObj = sortObj(obj);
- * console.log(sortedObj);
- * // Output:
- * // {
- * // a: { b: 2, c: 3 },
- * // array: [ { a: 1, b: 2 }, 3 ],
- * // z: 1
- * // }
- * ```
- */
-export function sortObject(obj: T): T {
- if (obj instanceof Date) return obj.toISOString() as T; // normalize precision
- if (obj == null || typeof obj !== "object") return obj;
- if (ArrayBuffer.isView(obj) || obj instanceof ArrayBuffer) return obj as T;
- if (Array.isArray(obj)) return obj.map(sortObject) as T;
-
- return Object.entries(obj)
- .sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
- .reduce((sortedObj: Record, [key, value]) => {
- sortedObj[key] = sortObject(value);
- return sortedObj;
- }, {}) as T;
-}
-
-/**
- * Codec: string (base64url JSON) <-> { data, signature }
- * - decode(): wire -> structured
- * - encode(): structured -> wire
- *
- * Note: defaults/prefaults apply in forward (decode) direction,
- * not in backward (encode) direction. Keep that in mind.
- */
-export const Base64UrlJsonCursorCodec = z.codec(
- z.string(), // wire type (Input)
- EncodedCursorSchema, // domain type (Output)
- {
- decode: (token) => {
- // base64url decode
- const decoded = Buffer.from(token, 'base64url').toString('utf-8')
- const parsed = JSON.parse(decoded)
- return parsed;
- },
- encode: (obj) => {
- const json = JSON.stringify(obj)
- return Buffer.from(json).toString('base64url');
- },
- }
-);
-
-export function hmacSha256Hex(secret: string, payload: unknown): string {
- // Important: canonicalize to avoid key-order signature drift.
- // If you don't have a stable stringify util, ensure 'data' is serialized consistently.
- // The order of the objects keys affects how the object is stringified
- const data = sortObject(payload);
- const json = JSON.stringify(data);
- return createHmac("sha256", secret)
- .update(json)
- .digest("hex");
-}
-
-/**
- * Decode + verify a cursor token. Return domain result or throw mapped HTTP errors.
- * - 400 if format/signature invalid
- * - 410 if expired
- */
-export function decodeAndVerifyCursor(
- token: string,
- secret: string,
- ttlSeconds = 86_400, // 24h
-) {
- // 1) Decode token -> { data, signature }
- const decoded = Base64UrlJsonCursorCodec.decode(token);
- const parsed = EncodedCursorSchema.safeParse(decoded);
-
- if (!parsed.success) {
- // Map Zod issues to your error envelope at the HTTP boundary:
- return badRequest(token, `Invalid cursor: ${parsed.error.issues?.[0]?.message}`)
- }
-
- const { data, signature } = parsed.data;
-
- // 2) Verify HMAC
- const expected = hmacSha256Hex(secret, data);
- if (signature !== expected) {
- return badRequest(token, "Cursor signature mismatch")
- }
-
- // 3) Verify TTL (use epoch seconds; avoid Date#getDate bug)
- const nowSec = Math.floor(Date.now() / 1000);
- const createdSec = Math.floor(data.createdAt.getTime() / 1000);
- const age = nowSec - createdSec;
-
- if (age > ttlSeconds) {
- return gone(String(age), `Cursor has expired (${age - ttlSeconds})`, {
- deltaSeconds: age - ttlSeconds,
- });
- }
-
- // 4) Success: return normalized CursorData
- return ok(data, 200);
-}
-
-/**
- * Create a new signed cursor token from CursorData.
- */
-export function encodeCursor(data: CursorData, secret: string): string {
- const envelope = Object.assign({}, {
- data,
- signature: hmacSha256Hex(secret, data),
- });
-
- return Base64UrlJsonCursorCodec.encode(envelope);
-}
-
-// ============================================================================
-// SOURCE ADAPTERS
-// ============================================================================
-
-// Query wire schema (raw incoming)
-export const PaginationQueryWire = BaseQuerySchema.extend({
- offset: ZStringOrStringArray.optional(),
- limit: ZStringOrStringArray.optional(),
- page: ZStringOrStringArray.optional(),
- per_page: ZStringOrStringArray.optional(),
- cursor: ZStringOrStringArray.optional()
-})
-
-// JSON wire schema (raw incoming)
-export const PaginationJsonWire = BaseJsonSchema; // you already have this
-
-// Form wire schema (raw incoming)
-export const PaginationFormWire = BaseFormSchema;
-
-// Extract first value if array
-const getString = (val: unknown): string | undefined => {
- if (Array.isArray(val)) return val[0]
- if (typeof val === 'string') return val
- return undefined
-}
-
-// Extract first value of a specific key if array of string
-const pickForm = (raw: Record, key: string): string | undefined => {
- const val = getString(raw[key])
- if (val) return String(val)
- return val
-}
-
-
-/**
- * Query parameter adapter (extends BaseQuerySchema)
- * Supports: ?offset=0&limit=20, ?page=1&per_page=20, ?cursor=abc&limit=20
- */
-export function createPaginationQueryAdapter(defaultLimit: number = 20) {
- return z.codec(
- PaginationQueryWire, // Input (wire)
- PaginationNormalizedSchema, // Output (normalized)
- {
- decode: (raw) => {
- const cursor = getString(raw.cursor)
- const offset = getString(raw.offset)
- const page = getString(raw.page)
- const limit = getString(raw.limit)
- const perPage = getString(raw.per_page)
-
- // Cursor-based
- if (cursor !== undefined) {
- return {
- type: 'cursor',
- cursor: cursor || undefined,
- limit: parseInt(limit ?? String(defaultLimit), 10)
- } as CursorPaginationNormalized
- }
-
- // Page-based
- if (page !== undefined) {
- const pageNum = parseInt(page, 10)
- const limitNum = parseInt(perPage ?? limit ?? String(defaultLimit), 10)
- return {
- type: 'offset',
- offset: (pageNum - 1) * limitNum,
- limit: limitNum
- } as OffsetPaginationNormalized
- }
-
- // Offset-based
- return {
- type: 'offset',
- offset: parseInt(offset ?? '0', 10),
- limit: parseInt(limit ?? String(defaultLimit), 10)
- } as OffsetPaginationNormalized
- },
- // optional if you want to emit back out to wire shape:
- encode: (norm) => {
- if (norm.type === "cursor") {
- return {
- cursor: norm.cursor,
- limit: String(norm.limit),
- } as z.input;
- }
- return {
- offset: String(norm.offset),
- limit: String(norm.limit),
- } as z.input;
- },
- })
-}
-
-/**
- * JSON body adapter (uses BaseJsonSchema)
- * Expects: { pagination: { type: 'cursor', cursor: '...', limit: 20 } }
- */
-export function createPaginationJsonAdapter(defaultLimit: number = 20) {
- const JsonEnvelope = z.object({
- pagination: PaginationNormalizedSchema.optional().default({
- type: 'offset',
- offset: 0,
- limit: defaultLimit
- })
- })
-
- return z.codec(
- BaseJsonSchema, // Input wire (your BaseJsonSchema)
- PaginationNormalizedSchema, // Output normalized
- {
- decode: (raw) => JsonEnvelope.parse(raw).pagination,
- encode: (norm) => ({ pagination: norm }) as z.input,
- }
- );
-}
-
-/**
- * FormData adapter (uses makeBaseFormSchema)
- * Supports same params as query adapter
- */
-export function createPaginationFormAdapter(defaultLimit: number = 20) {
- return z.codec(
- PaginationFormWire, // Input wire
- PaginationNormalizedSchema, // Output normalized
- {
- decode: (raw) => {
- const cursor = pickForm(raw, 'cursor')
- const offset = pickForm(raw, 'offset')
- const page = pickForm(raw, 'page')
- const limit = pickForm(raw, 'limit')
- const perPage = pickForm(raw, 'per_page')
-
- // Cursor-based
- if (cursor !== undefined) {
- return {
- type: 'cursor',
- cursor: cursor || undefined,
- limit: parseInt(limit ?? String(defaultLimit), 10)
- } as CursorPaginationNormalized
- }
-
- // Page-based
- if (page !== undefined) {
- const pageNum = parseInt(page, 10)
- const limitNum = parseInt(perPage ?? limit ?? String(defaultLimit), 10)
- return {
- type: 'offset',
- offset: (pageNum - 1) * limitNum,
- limit: limitNum
- } as OffsetPaginationNormalized
- }
-
- // Offset-based
- return {
- type: 'offset',
- offset: parseInt(offset ?? '0', 10),
- limit: parseInt(limit ?? String(defaultLimit), 10)
- } as OffsetPaginationNormalized
- },
- encode: (norm) => {
- if (norm.type === "cursor")
- return {
- cursor: norm.cursor,
- limit: String(norm.limit)
- } as z.infer;
-
- return {
- offset: String(norm.offset),
- limit: String(norm.limit)
- } as z.infer;
- },
- })
-}
-
-// ============================================================================
-// SCHEMA COMPOSITION WITH VALIDATION
-// ============================================================================
-
-/**
- * Create endpoint-specific pagination schema with validation
- * All validation happens in .superRefine() so middleware handles errors
- */
-export function createPaginationSchema(config: {
- source: 'query' | 'json' | 'form'
-} & PaginationConfig) {
- const limits = config.limits ?? {} as NonNullable
- const defaultLimit = config.limits?.defaultLimit ?? 20
-
- const adapter =
- config.source === 'query' ? createPaginationQueryAdapter(defaultLimit) :
- config.source === 'json' ? createPaginationJsonAdapter(defaultLimit) :
- createPaginationFormAdapter(defaultLimit)
-
- return adapter.superRefine((pagination, ctx) => {
- // Validate limits
- if (pagination.limit < limits.minLimit) {
- ctx.addIssue({
- code: "too_small",
- minimum: limits.minLimit,
- origin: "number",
- path: ['limit'],
- message: `Limit must be between ${limits.minLimit} and ${limits.maxLimit} (exclusive), got ${pagination.limit}`,
- input: ctx.value
- })
- }
-
- if (pagination.limit > limits.maxLimit) {
- ctx.addIssue({
- code: "too_big",
- maximum: limits.maxLimit,
- origin: "number",
- path: ['limit'],
- message: `Limit must be between ${limits.minLimit} and ${limits.maxLimit} (exclusive), got ${pagination.limit}`,
- input: ctx.value
- })
- }
-
- // Validate offset for DoS protection
- if (pagination.type === 'offset' && pagination.offset > limits.maxOffset) {
- ctx.addIssue({
- code: "too_big",
- maximum: limits.maxOffset,
- origin: "number",
- path: ['offset'],
- message: `Offset cannot exceed ${limits.maxOffset} (DoS protection), got ${pagination.offset}`,
- input: ctx.value
- })
- }
-
- // Decode cursor if present (execution-time error, not validation)
- if (pagination.type === 'cursor' && pagination.cursor && config.cursorSecret) {
- const result = decodeAndVerifyCursor(pagination.cursor, config.cursorSecret, limits.cursorTTL);
- if (isSuccessResponse(result)) {
- // Mutation is safe here - Zod creates new object per parse
- const [decoded] = result
- pagination.decodedCursor = decoded.data
- } else {
- const [error] = result
- ctx.addIssue({
- code: "custom",
- path: ['decodedCursor'],
- message: error.detail || 'Invalid or expired cursor',
- input: ctx.value
- })
- }
- }
- })
-}
-
-// ============================================================================
-// RESPONSE GENERATION (existing, mostly unchanged)
-// ============================================================================
-
-/**
- * Create CursorData from a DB row.
- * Keep this close to the query code so it's obvious which fields drive the cursor.
- *
- * @example
- * const head = rows[0], tail = rows[rows.length - 1]
- * const nextData = cursorFromRow(tail, { sortField: "created_at", tiebreaker: "id", direction: "asc" })
- * const prevData = cursorFromRow(head, { sortField: "created_at", tiebreaker: "id", direction: "desc" })
- */
-export function cursorFromRow>(row: Row, cfg: {
- sortField: string;
- tiebreaker: string;
- direction: SortDirection;
-}): CursorData {
- const sortValue = row[cfg.sortField];
- const tieValue = row[cfg.tiebreaker];
-
- // Normalize known primitives; let CursorDataSchema enforce the rest.
- const data = Object.assign({}, {
- sortField: cfg.sortField,
- sortValue: sortValue as unknown,
- tiebreaker: cfg.tiebreaker,
- tiebreakerValue: tieValue as string | number,
- direction: cfg.direction,
- createdAt: new Date(), // mint time the cursor was issued
- });
-
- // Validate to keep types honest
- return CursorDataSchema.parse(data);
-}
-
-/**
- * Compute next/prev tokens for keyset pages.
- * - `hasMoreForward`: whether there are more rows after the last one you returned.
- * - `hasMoreBackward`: whether there are rows before the first one (optional; set if you probe backward).
- */
-export function makeCursorTokens>(args: {
- items: Row[];
- limit: number;
- sortField: string;
- tiebreaker: string;
- secret: string;
- direction: "asc" | "desc";
- hasMoreForward: boolean;
- hasMoreBackward?: boolean;
-}) {
- const count = args.items.length;
- if (count === 0) {
- return Object.assign({}, {
- next: undefined as string | undefined,
- prev: undefined as string | undefined
- });
- }
-
- const head = args.items[0];
- const tail = args.items[count - 1];
-
- // For "next" we point past the last item we actually returned.
- const nextData: CursorData | undefined = args.hasMoreForward
- ? cursorFromRow(tail, { sortField: args.sortField, tiebreaker: args.tiebreaker, direction: args.direction })
- : undefined;
-
- // For "prev" we point before the first item; invert direction to walk back.
- const prevData: CursorData | undefined = args.hasMoreBackward
- ? cursorFromRow(head, { sortField: args.sortField, tiebreaker: args.tiebreaker, direction: args.direction === "asc" ? "desc" : "asc" })
- : undefined;
-
- return Object.assign({}, {
- next: nextData ? encodeCursor(nextData, args.secret) : undefined,
- prev: prevData ? encodeCursor(prevData, args.secret) : undefined,
- });
-}
-
-/**
- * Computes a page expiry timestamp (ISO) from:
- * - The cursor TTL policy (if cursor pagination)
- * - An optional global page TTL (offset pagination or policy override)
- *
- * Semantics:
- * - If you minted a "next" cursor, its expiry governs page expiry.
- * - If both "next" and "prev" exist, choose the earliest.
- * - If neither cursor exists (e.g., empty result or offset mode), use a global TTL (optional).
- */
-export function computeExpiresAt(opts: {
- now?: Date; // for testability
- ttlSecs?: number; // same units you pass to decode/verify
- hasCursor?: boolean;
-}): Date | undefined {
- const now = opts.now ?? new Date()
- if (opts.hasCursor && typeof opts.ttlSecs === 'number') {
- const expiry = new Date(now.getTime() + opts.ttlSecs * 1000)
- return expiry
- }
- return undefined
-}
-
-/**
- * Comprehensive pagination bridge.
- * - Trims rows via limit+1 strategy
- * - Computes hasMore
- * - Generates next/prev cursor tokens (cursor mode)
- * - Computes next/prev offsets (offset mode)
- * - Optionally computes expiresAt from TTL
- */
-export function buildPaginationMeta>(args: {
- rows: Row[];
- query: QuerySpec;
- sortField?: string;
- tiebreaker?: string;
- direction?: "asc" | "desc";
- secret?: string;
- ttlSec?: number; // optional TTL for expiresAt
- total?: number;
- approxTotal?: number;
-}): { items: Row[]; } & PaginationMetadata {
- const { rows, query } = args;
- const limit = query.pagination.limit;
-
- // finalizePage logic
- const hasMore = rows.length > limit;
- const items = hasMore ? rows.slice(0, limit) : rows;
-
- // common base
- const base: Pagination = {
- hasMore,
- limit,
- count: items.length,
- total: args.total,
- approxTotal: args.approxTotal,
- };
-
- if (query.pagination.type === "cursor") {
- const { sortField = "id", tiebreaker = "id", direction = "asc", secret = "" } = args;
-
- const { next, prev } = makeCursorTokens({
- items,
- limit,
- sortField,
- tiebreaker,
- secret,
- direction,
- hasMoreForward: hasMore,
- // hasMoreBackward could be probed separately if needed
- });
-
- // optional expiry
- if (args.ttlSec && args.ttlSec > 0) {
- const expiresAt = computeExpiresAt({
- ttlSecs: args.ttlSec,
- hasCursor: query.pagination.type === "cursor"
- })
- base.expiresAt = expiresAt;
- }
-
- return {
- items,
- pagination: {
- ...base,
- nextCursor: next,
- prevCursor: prev,
- },
- query
- };
- }
-
- if (query.pagination.type === "offset") {
- const nextOffset = hasMore ? query.pagination.offset + limit : undefined;
- const prevOffset = query.pagination.offset > 0 ? Math.max(0, query.pagination.offset - limit) : undefined;
-
- return {
- items,
- pagination: {
- ...base,
- offset: query.pagination.offset,
- ...(nextOffset !== undefined ? { nextOffset } : {}),
- ...(prevOffset !== undefined ? { prevOffset } : {}),
- },
- query
- };
- }
-
- // exhaustive guard
- return { items, pagination: base };
-}
diff --git a/utils/query/query.ts b/utils/query/query.ts
deleted file mode 100644
index 4c42c77..0000000
--- a/utils/query/query.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-// utils/query/query.ts
-/**
- * Composite schema factory for endpoint-specific query specs
- *
- * CHANGES:
- * - createSingleSourceQuerySpec now respects disable flags
- * - Disabled components return null instead of creating schemas
- * - applyQuerySpec skips null components
- *
- * Features:
- * - Source precedence (JSON > Query > Form)
- * - Conflict detection
- * - Complete QuerySpec type
- * - Declarative endpoint configuration
- * - Per-component enable/disable control
- */
-
-import {
- EndpointQueryConfigSchema,
- type EndpointQueryConfig,
- type QuerySpec
-} from './schemas.ts'
-
-import { createPaginationSchema } from './pagination.ts'
-import { createFiltersSchema } from './filtering.ts'
-import { createSortsSchema } from './sorting.ts'
-import { createFieldsSchema } from './fields.ts'
-
-import { BaseFormSchema, BaseJsonSchema, BaseQuerySchema } from '../endpoint/schemas.ts'
-
-// Re-export QuerySpec type
-export { QuerySpecSchema, type QuerySpec } from './schemas.ts'
-
-// ============================================================================
-// SINGLE-SOURCE QUERY SPEC FACTORY (UPDATED)
-// ============================================================================
-
-/**
- * Build a single-source schema (query | json | form) applying business rules
- *
- * NEW: Respects disable flags in config:
- * - disableFiltering: Always returns null for filters
- * - disableSorting: Always returns null for sorts
- * - disableFields: Always returns null for fields
- * - Pagination always enabled (required for collections)
- *
- * @param source Input source type
- * @param configInput Endpoint configuration
- * @returns Zod schema that parses and validates complete query spec
- *
- * @example
- * // Endpoint with fixed fields and sorting
- * const schema = createSingleSourceQuerySpec('query', {
- * fieldRegistry: {
- * filterable: ['status'],
- * sortable: ['created_at', 'id'],
- * selectable: ['id', 'title']
- * },
- * disableFields: true, // Ignore field selection requests
- * disableSorting: true, // Ignore sort requests
- * defaultSort: [{ field: 'created_at', direction: 'desc' }]
- * })
- *
- * @example
- * // Read-only endpoint (no filtering)
- * const schema = createSingleSourceQuerySpec('query', {
- * fieldRegistry: {
- * filterable: [],
- * sortable: ['created_at'],
- * selectable: ['id', 'title', 'content']
- * },
- * disableFiltering: true
- * })
- */
-export function createQuerySpec(
- source: 'query' | 'json' | 'form',
- configInput?: EndpointQueryConfig
-) {
- const config = EndpointQueryConfigSchema.parse(configInput)
-
- console.log({
- config
- })
-
- // Pagination is always enabled (required for collection endpoints)
- const paginationSchema = createPaginationSchema({
- source,
- ...config.pagination
- })
-
- const filtersSchema = !config?.filters.disabled ? createFiltersSchema({
- source,
- ...config.filters
- }) : null
-
- const sortsSchema = !config?.sorts.disabled ? createSortsSchema({
- source,
- ...config.sorts
- }) : null
-
- const fieldsSchema = !config?.fields.disabled ? createFieldsSchema({
- source,
- ...config.fields
- }) : null
-
- const BaseInputSchema =
- source === 'query' ? BaseQuerySchema :
- source === 'json' ? BaseJsonSchema :
- BaseFormSchema
-
- return BaseInputSchema.transform((raw): QuerySpec => {
- // Parse each component (disabled ones return null)
- const pagination = paginationSchema.parse(raw)
- const filters = filtersSchema ? filtersSchema.parse(raw) : null
- const sorts = sortsSchema ? sortsSchema.parse(raw) : null
- const fields = fieldsSchema ? fieldsSchema.parse(raw) : null
-
- return {
- pagination,
- filters,
- sorts,
- fields,
- }
- })
-}
-
-// ============================================================================
-// HELPER FACTORY
-// ============================================================================
-
-/**
- * Simplified factory for common case (query source only)
- *
- * Most endpoints only need to parse query parameters, so this provides
- * a convenient shorthand.
- *
- * @param config Endpoint configuration
- * @returns Schema for query-based input
- *
- * @example
- * // Standard endpoint with all features
- * const QuerySchema = createEndpointQuerySchema({
- * fieldRegistry: {
- * filterable: ['status'],
- * sortable: ['created_at', 'id'],
- * selectable: ['id', 'title', 'status']
- * },
- * tiebreaker: 'id'
- * })
- *
- * @example
- * // Fixed-output endpoint (disable field selection)
- * const QuerySchema = createEndpointQuerySchema({
- * fieldRegistry: {
- * filterable: ['status'],
- * sortable: ['created_at', 'id'],
- * selectable: [] // Not used when disabled
- * },
- * disableFields: true,
- * tiebreaker: 'id'
- * })
- */
-export function createEndpointQuerySchema(config: EndpointQueryConfig) {
- return createQuerySpec('query', config)
-}
diff --git a/utils/query/schemas.ts b/utils/query/schemas.ts
deleted file mode 100644
index e5d67d4..0000000
--- a/utils/query/schemas.ts
+++ /dev/null
@@ -1,380 +0,0 @@
-// utils/query/types.ts
-/**
- * Core types and schemas for query processing
- *
- * Provides:
- * - Registry schemas for allowlists and validation
- * - Normalized intermediary schemas
- * - Zod schemas for all query features
- * - QuerySpecSchema now has nullable filters and sorts for disable functionality
- * - EndpointQueryConfig gains disableFiltering, disableSorting, disableFields flags
- */
-
-import { z } from 'zod'
-import type { ValidationErrorDetail } from '../response/schemas.ts'
-
-// Re-export for convenience
-export type { ValidationErrorDetail }
-
-// ---------------------------------------------
-// Shared primitives
-// ---------------------------------------------
-
-export const NonEmptyStringSchema = z.string().trim().min(1, 'Value cannot be empty')
-
-// ============================================================================
-// FILTER SCHEMAS
-// ============================================================================
-
-/**
- * Available filter operators
- */
-export const FilterOperatorSchema = z.enum([
- 'eq', 'ne',
- 'gt', 'gte',
- 'lt', 'lte',
- 'between', // NEW
- 'in', 'nin',
- 'contains', 'icontains',
- 'startswith', 'endswith',
- 'is_null', 'is_not_null'
-])
-
-export type FilterOperator = z.infer
-
-/**
- * Normalized filter shape (output from adapters)
- */
-export const FilterNormalizedSchema = z.object({
- field: NonEmptyStringSchema,
- operator: FilterOperatorSchema,
- value: z.unknown().optional()
-})
-
-export type FilterNormalized = z.infer
-
-/**
- * Array of normalized filters
- */
-export const FiltersNormalizedSchema = z.array(FilterNormalizedSchema)
-
-export type FiltersNormalized = z.infer
-
-/**
- * Normalized filter shape (output from adapters)
- */
-export const BaseFilterNormalizedSchema = z.object({
- field: NonEmptyStringSchema,
- operator: FilterOperatorSchema.optional(),
- value: z.unknown().optional()
-})
-
-export type BaseFilterNormalized = z.infer
-
-/**
- * Array of normalized filters
- */
-export const BaseFiltersNormalizedSchema = z.array(BaseFilterNormalizedSchema)
-
-export type BaseFiltersNormalized = z.infer
-
-/**
- * `OperatorDefinitionSchema`
- *
- * Describes *per-field* filtering rules:
- * - `operators`: which operators are allowed for this field (allowlist).
- * - `type`: the scalar type used for value coercion (`string|number|boolean|date|enum|uuid`).
- * - `values` (optional): enum allowlist for `type: 'enum'`.
- * - `arrayOperators` (optional): which operators for this field *expect arrays*.
- *
- * Why `arrayOperators`?
- * - Avoids hardcoding that `'in'`/`'nin'` (or future operators like `between`, `overlaps`, geo/json operators)
- * always consume lists. Instead, each field opts into multi-value semantics explicitly, enabling
- * field-specific policy, schema-driven validation, and accurate auto-docs/UI hints.
- *
- * How it interacts with URLs:
- * - Bracket notation clients send comma-separated values (e.g., `filter[tag][in]=a,b,c`), which the validator
- * converts to arrays *only if* the field’s `arrayOperators` includes that operator. JSON clients send arrays
- * directly for `value`. Either way, normalization ends at the same `FiltersNormalized` shape.
- */
-export const OperatorDefinitionSchema = z.object({
- operators: z.array(FilterOperatorSchema).min(1),
- type: z.enum(['string', 'number', 'boolean', 'date', 'enum', 'uuid']),
- values: z.array(z.string()).optional(), // For enum types
- arrayOperators: z.array(FilterOperatorSchema).optional() // Operators that require arrays
-})
-
-/**
- * See {@link OperatorDefinitionSchema} for full schema details.
- */
-export type OperatorDefinition = z.infer
-
-/**
- * Filter registry for a resource
- */
-export const FilterRegistrySchema = z.record(
- z.string(),
- OperatorDefinitionSchema
-)
-
-export type FilterRegistry = z.infer
-
-// ============================================================================
-// SORT SCHEMAS
-// ============================================================================
-
-/**
- * Sort direction
- */
-export const SortDirectionSchema = z.enum(['asc', 'desc'])
-
-export type SortDirection = z.infer
-
-/**
- * Normalized sort shape (output from adapters)
- */
-export const SortNormalizedSchema = z.object({
- field: NonEmptyStringSchema,
- direction: SortDirectionSchema,
- tiebreaker: z.boolean().optional().default(false)
-})
-
-export type SortNormalized = z.infer
-
-/**
- * Array of normalized sorts
- */
-export const SortsNormalizedSchema = z.array(SortNormalizedSchema)
-
-export type SortsNormalized = z.infer
-
-// ============================================================================
-// FIELD SELECTION SCHEMAS
-// ============================================================================
-
-/**
- * Simple field selection (fields=a,b,c)
- */
-export const SimpleFieldSelectionSchema = z.object({
- type: z.literal('simple'),
- fields: z.array(NonEmptyStringSchema).min(1)
-})
-
-/**
- * JSON:API field selection (fields[type]=a,b,c)
- */
-export const JsonApiFieldSelectionSchema = z.object({
- type: z.literal('jsonapi'),
- fields: z.record(z.string(), z.array(NonEmptyStringSchema))
-})
-
-/**
- * Normalized field selection (output from adapters)
- */
-export const FieldSelectionNormalizedSchema = z.discriminatedUnion('type', [
- SimpleFieldSelectionSchema,
- JsonApiFieldSelectionSchema
-])
-
-export type FieldSelectionNormalized = z.infer
-
-// ============================================================================
-// PAGINATION SCHEMAS
-// ============================================================================
-
-/**
- * Cursor data structure (internal representation)
- */
-export const CursorDataSchema = z.object({
- sortField: NonEmptyStringSchema,
- sortValue: z.union([
- z.string(),
- z.number(),
- z.coerce.date()
- ]),
- tiebreaker: z.string(),
- tiebreakerValue: z.union([z.string(), z.number()]),
- direction: SortDirectionSchema,
- createdAt: z.coerce.date()
-})
-
-export type CursorData = z.infer
-
-/**
- * Offset pagination (normalized)
- */
-export const OffsetPaginationNormalizedSchema = z.object({
- type: z.literal('offset'),
- limit: z.number().int().positive(),
- offset: z.number().int().min(0),
-})
-
-export type OffsetPaginationNormalized = z.infer
-
-/**
- * Cursor pagination (normalized)
- */
-export const CursorPaginationNormalizedSchema = z.object({
- type: z.literal('cursor'),
- limit: z.number().int().positive(),
- cursor: NonEmptyStringSchema.optional(),
- decodedCursor: CursorDataSchema
-})
-
-export type CursorPaginationNormalized = z.infer
-
-/**
- * Normalized pagination (union)
- */
-export const PaginationNormalizedSchema = z.discriminatedUnion('type', [
- OffsetPaginationNormalizedSchema,
- CursorPaginationNormalizedSchema
-])
-
-export type PaginationNormalized = z.infer
-
-// ============================================================================
-// QUERY SPEC SCHEMA
-// ============================================================================
-
-/**
- * Pagination configuration
- *
- * All options for pagination in one place
- */
-export const PaginationConfigSchema = z.object({
- limits: z.object({
- minLimit: z.number().int().positive().optional().default(1),
- maxLimit: z.number().int().positive().optional().default(100),
- defaultLimit: z.number().int().positive().optional().default(20),
- maxOffset: z.number().int().positive().optional().default(1_000_000),
- cursorTTL: z.number().int().positive().optional().default(86400), // 24 hours
- }).optional().default({
- minLimit: 1,
- maxLimit: 100,
- defaultLimit: 20,
- maxOffset: 1_000_000,
- cursorTTL: 86400
- }),
- cursorSecret: z.string().optional(),
-})
-
-export type PaginationConfig = z.infer
-
-/**
- * Filters configuration
- *
- * All options for filtering in one place
- */
-export const FiltersConfigSchema = z.object({
- registry: FilterRegistrySchema.optional(),
- defaults: FiltersNormalizedSchema.optional(),
- mergeDefaults: z.boolean().optional().default(true),
- disabled: z.boolean().optional().default(false),
- limits: z.object({
- maxFilters: z.number().int().positive().optional().default(20),
- }).optional().default({ maxFilters: 20 }),
-})
-
-export type FiltersConfig = z.input
-
-/**
- * Sorts configuration
- *
- * All options for sorting in one place
- */
-export const SortsConfigSchema = z.object({
- tiebreaker: z.string().default('id'),
- allowedFields: z.array(z.string()).optional(),
- mergeDefaults: z.boolean().optional().default(true),
- defaults: SortsNormalizedSchema.optional(),
- disabled: z.boolean().optional().default(false),
- limits: z.object({
- maxSorts: z.number().int().positive().optional().default(5),
- }).optional().default({ maxSorts: 5 }),
-})
-
-export type SortsConfig = z.input
-
-/**
- * Fields configuration
- *
- * All options for field selection in one place
- */
-export const FieldsConfigSchema = z.object({
- allowedFields: z.array(z.string()).optional(),
- defaults: z.array(z.string()).optional(),
- disabled: z.boolean().default(false),
- resourceType: z.string().optional(), // For JSON:API format
-}).default({ disabled: false })
-
-export type FieldsConfig = z.input
-
-/**
- * Complete query specification (output from composite schema)
- *
- * This is the normalized, validated output that contains all query parameters
- * ready to be applied to a database query.
- */
-export const QuerySpecSchema = z.object({
- pagination: PaginationNormalizedSchema,
- filters: FiltersNormalizedSchema.nullable(),
- sorts: SortsNormalizedSchema.nullable(),
- fields: FieldSelectionNormalizedSchema.nullable(),
-})
-
-export type QuerySpec = z.infer
-
-// ============================================================================
-// ENDPOINT CONFIGURATION (UPDATED)
-// ============================================================================
-
-/**
- * Endpoint query configuration
- *
- * FIXED: All component configs are optional and have sensible defaults
- * TypeScript types are clean (no | undefined noise)
- *
- * @example
- * ```typescript
- * // Minimal config
- * const config = {
- * tiebreaker: 'id'
- * }
- *
- * // Full config
- * const config = {
- * tiebreaker: 'id',
- * pagination: {
- * limits: { defaultLimit: 50, maxLimit: 100 },
- * cursorSecret: CURSOR_SECRET
- * },
- * filters: {
- * registry: { ... },
- * defaults: [ ... ],
- * mergeDefaults: true,
- * disabled: false,
- * limits: { maxFilters: 10 }
- * },
- * sorts: {
- * allowedFields: ['created_at', 'id'],
- * defaults: [ ... ],
- * disabled: false,
- * limits: { maxSorts: 3 }
- * },
- * fields: {
- * allowedFields: ['id', 'title'],
- * defaults: ['id'],
- * disabled: false
- * }
- * }
- * ```
- */
-export const EndpointQueryConfigSchema = z.object({
- pagination: PaginationConfigSchema,
- filters: FiltersConfigSchema,
- sorts: SortsConfigSchema,
- fields: FieldsConfigSchema,
-})
-
-export type EndpointQueryConfig = z.input
\ No newline at end of file
diff --git a/utils/query/sorting.ts b/utils/query/sorting.ts
deleted file mode 100644
index 9aa7b63..0000000
--- a/utils/query/sorting.ts
+++ /dev/null
@@ -1,275 +0,0 @@
-// utils/query/sorting.ts
-/**
- * Query sorting with multi-source support and validation
- *
- * Features:
- * - Explicit direction: sort=field:direction,field:direction
- * - Field allowlists via registry
- * - Max sort count (DoS protection)
- * - Always adds tiebreaker for deterministic ordering
- * - Query/JSON/FormData source adapters
- */
-
-import { z } from 'zod'
-
-import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '../endpoint/schemas.ts'
-import {
- SortsNormalizedSchema,
- type SortNormalized,
- type SortsNormalized,
- type SortDirection,
- type SortsConfig,
-} from './schemas.ts'
-
-// ============================================================================
-// WIRE SCHEMAS (raw incoming data)
-// ============================================================================
-
-/**
- * Query parameter wire schema (raw incoming)
- * Supports: ?sort=created_at:desc,id:asc
- */
-export const SortsQueryWire = BaseQuerySchema.extend({
- sort: ZStringOrStringArray.optional()
-})
-
-/**
- * JSON body wire schema (raw incoming)
- * Expects: { sorts: [{ field: 'created_at', direction: 'desc' }] }
- */
-export const SortsJsonWire = BaseJsonSchema.pipe(
- z.object({ sorts: SortsNormalizedSchema.default([]) })
-)
-
-/**
- * FormData wire schema (raw incoming)
- */
-export const SortsFormWire = BaseFormSchema
-
-// ============================================================================
-// HELPER FUNCTIONS
-// ============================================================================
-
-/**
- * Parse colon syntax from string
- * Syntax: created_at:desc,id:asc
- */
-function parseColonSyntax(data: string | undefined): SortsNormalized {
- if (typeof data !== 'string' || !data?.trim()) return []
-
- const sorts: SortNormalized[] = []
- const segments = data.split(',').map(s => s.trim()).filter(Boolean)
-
- for (const segment of segments) {
- const [fieldRaw, dirRaw] = segment.split(':')
- const field = (fieldRaw ?? '').trim()
- const direction = (dirRaw ?? 'asc').trim().toLowerCase()
-
- if (!field) continue;
- if (direction !== 'asc' && direction !== 'desc') continue;
-
- sorts.push({
- field: field,
- direction: direction as SortDirection,
- tiebreaker: false,
- })
- }
-
- return sorts
-}
-
-// ============================================================================
-// SOURCE ADAPTERS
-// ============================================================================
-
-
-/**
- * Query parameter adapter (uses z.codec)
- * Supports: ?sort=created_at:desc,id:asc
- *
- * @example
- * const adapter = createSortsQueryAdapter()
- * const normalized = adapter.decode({ sort: 'created_at:desc,id:asc' })
- * // => [{ field: 'created_at', direction: 'desc' }, { field: 'id', direction: 'asc' }]
- */
-export function createSortsQueryAdapter() {
- return z.codec(
- SortsQueryWire, // Input (wire)
- SortsNormalizedSchema, // Output (normalized)
- {
- decode: (query) => {
- // Extract sort value (handle ZStringOrStringArray)
- const raw = Array.isArray(query.sort) ? query.sort[0] : query.sort
- return parseColonSyntax(raw)
- },
- encode: (normalized) => {
- // Reverse transform: SortsNormalized -> query string
- if (normalized.length === 0) return {} as z.infer
- const sortString = normalized
- .map(s => `${s.field}:${s.direction}`)
- .join(',')
- return { sort: sortString } as z.infer
- }
- }
- )
-}
-
-/**
- * JSON body adapter (uses z.codec)
- * Expects: { sorts: [{ field: 'created_at', direction: 'desc' }] }
- *
- * @example
- * const adapter = createSortsJsonAdapter()
- * const normalized = adapter.decode({
- * sorts: [{ field: 'created_at', direction: 'desc' }]
- * })
- */
-export function createSortsJsonAdapter() {
- return z.codec(
- SortsJsonWire, // Input (wire)
- SortsNormalizedSchema, // Output (normalized)
- {
- decode: (raw) => {
- return raw.sorts as SortsNormalized
- },
- encode: (normalized) => {
- return { sorts: normalized.map(s => Object.assign(s, { tiebreaker: false })) }
- }
- }
- )
-}
-
-/**
- * FormData adapter (uses z.codec)
- * Supports: sort=created_at:desc,id:asc
- *
- * @example
- * const adapter = createSortsFormAdapter()
- * const formData = new FormData()
- * formData.append('sort', 'created_at:desc,id:asc')
- * const normalized = adapter.decode(formData)
- */
-export function createSortsFormAdapter() {
- return z.codec(
- SortsFormWire, // Input (wire)
- SortsNormalizedSchema, // Output (normalized)
- {
- decode: (raw): SortsNormalized => {
- const sortValue = raw.sort
- if (!sortValue) return []
-
- const sortStr = Array.isArray(sortValue)
- ? String(sortValue[0])
- : String(sortValue)
-
- return parseColonSyntax(sortStr)
- },
- encode: (normalized) => {
- if (normalized.length === 0) return {}
- const sortString = normalized
- .map(s => `${s.field}:${s.direction}`)
- .join(',')
- return { sort: sortString } as z.infer
- }
- }
- )
-}
-
-// ============================================================================
-// SCHEMA COMPOSITION WITH VALIDATION
-// ============================================================================
-
-/**
- * Create endpoint-specific sorts schema with validation
- * All validation happens in .superRefine() so middleware handles errors
- *
- * @param config Configuration for sorts validation
- * @param config.source Input source type ('query' | 'json' | 'form')
- * @param config.allowedFields Array of sortable field names (allowlist)
- * @param config.limits Optional limits configuration (maxSorts)
- * @param config.tiebreaker Field to use as tiebreaker (default: 'id')
- * @param config.defaultSort Default sort when none provided
- *
- * @example
- * const schema = createSortsSchema({
- * source: 'query',
- * allowedFields: ['created_at', 'title', 'id'],
- * limits: { maxSorts: 3 },
- * tiebreaker: 'id',
- * defaultSort: [{ field: 'created_at', direction: 'desc' }]
- * })
- *
- * // Parse and validate
- * const sorts = schema.parse({ sort: 'created_at:desc' })
- * // => [{ field: 'created_at', direction: 'desc' }, { field: 'id', direction: 'asc' }]
- */
-export function createSortsSchema(config: {
- source: 'query' | 'json' | 'form'
-} & SortsConfig) {
- // When disabled, always return null
- if (config.disabled) {
- return z.null()
- }
-
- const adapter =
- config.source === 'query' ? createSortsQueryAdapter() :
- config.source === 'json' ? createSortsJsonAdapter() :
- createSortsFormAdapter()
-
- const allowSet = new Set(config.allowedFields)
- const maxSorts = config.limits?.maxSorts ?? 5
- const tiebreaker = config.tiebreaker ?? 'id'
-
- return adapter
- .transform(sorts => {
- // Use default if no sorts provided
- if (sorts.length === 0 && config.defaults) {
- return config.defaults
- }
-
- // Merge defaults if enabled
- if (config.mergeDefaults && config.defaults) {
- const defaultSorts = config.defaults.filter(def => !sorts.some(s => s.field === def.field))
- return [...defaultSorts, ...sorts]
- }
-
- return sorts
- })
- .superRefine((sorts, ctx) => {
- // Check sort count before adding tiebreaker (DoS protection)
- if (sorts.length > maxSorts) {
- ctx.addIssue({
- code: "too_big",
- maximum: maxSorts,
- origin: 'array',
- path: [],
- message: `Too many sorts: maximum ${maxSorts} allowed, got ${sorts.length}`,
- })
- return // Don't continue
- }
-
- // Validate allowed fields
- if (allowSet.size > 0) {
- sorts.forEach((sort, idx) => {
- if (!allowSet!.has(sort.field)) {
- ctx.addIssue({
- code: "custom",
- path: [idx, 'field'],
- message: `Field '${sort.field}' is not sortable. Allowed fields: ${config.allowedFields!.join(', ')}`,
- })
- }
- })
- }
- })
- .transform((sorts) => {
- // Always add tiebreaker if not already present
- const _sorts = sorts.map(s => Object.assign(s, { tiebreaker: s.field === tiebreaker }));
- const hasTiebreaker = _sorts.some(s => s.tiebreaker)
- if (!hasTiebreaker) {
- _sorts.push({ field: tiebreaker, direction: 'asc' as SortDirection, tiebreaker: true })
- return _sorts
- }
-
- return _sorts
- })
-}
\ No newline at end of file
diff --git a/utils/response/errors.ts b/utils/response/errors.ts
deleted file mode 100644
index 943255c..0000000
--- a/utils/response/errors.ts
+++ /dev/null
@@ -1,636 +0,0 @@
-import type { ContentfulStatusCode } from 'hono/utils/http-status'
-import type { ErrorResponse, ErrorResult, ErrorsResult, ProblemDetails, ProblemDetailsWithErrors, ResponseResult, ValidationErrorDetail } from './schemas.ts'
-import { HTTPException } from 'hono/http-exception'
-
-export const BASE_ERROR_URL = 'https://backend.okikio.dev/error'
-export const BASE_DOCS_URL = 'https://docs.okikio.dev/errors'
-
-/** Canonical problem type URIs. */
-export const ERROR_TYPES = {
- BAD_REQUEST: `${BASE_ERROR_URL}/bad-request`,
- UNAUTHORIZED: `${BASE_ERROR_URL}/unauthorized`,
- FORBIDDEN: `${BASE_ERROR_URL}/forbidden`,
- NOT_FOUND: `${BASE_ERROR_URL}/not-found`,
- METHOD_NOT_ALLOWED: `${BASE_ERROR_URL}/method-not-allowed`,
- NOT_ACCEPTABLE: `${BASE_ERROR_URL}/not-acceptable`,
- REQUEST_TIMEOUT: `${BASE_ERROR_URL}/request-timeout`,
- CONFLICT: `${BASE_ERROR_URL}/conflict`,
- GONE: `${BASE_ERROR_URL}/gone`,
- PRECONDITION_FAILED: `${BASE_ERROR_URL}/precondition-failed`,
- PAYLOAD_TOO_LARGE: `${BASE_ERROR_URL}/payload-too-large`,
- URI_TOO_LONG: `${BASE_ERROR_URL}/uri-too-long`,
- UNSUPPORTED_MEDIA_TYPE: `${BASE_ERROR_URL}/unsupported-media-type`,
- RANGE_NOT_SATISFIABLE: `${BASE_ERROR_URL}/range-not-satisfiable`,
- UNPROCESSABLE_ENTITY: `${BASE_ERROR_URL}/unprocessable-entity`,
- PRECONDITION_REQUIRED: `${BASE_ERROR_URL}/precondition-required`,
- REQUEST_HEADER_FIELDS_TOO_LARGE: `${BASE_ERROR_URL}/request-header-fields-too-large`,
- UNAVAILABLE_FOR_LEGAL_REASONS: `${BASE_ERROR_URL}/unavailable-for-legal-reasons`,
- VALIDATION_ERROR: `${BASE_ERROR_URL}/validation-error`,
- RATE_LIMIT_EXCEEDED: `${BASE_ERROR_URL}/rate-limit-exceeded`,
- INTERNAL_SERVER_ERROR: `${BASE_ERROR_URL}/internal-server-error`,
- NOT_IMPLEMENTED: `${BASE_ERROR_URL}/not-implemented`,
- BAD_GATEWAY: `${BASE_ERROR_URL}/bad-gateway`,
- SERVICE_UNAVAILABLE: `${BASE_ERROR_URL}/service-unavailable`,
- GATEWAY_TIMEOUT: `${BASE_ERROR_URL}/gateway-timeout`,
-} as const
-
-/** Canonical docs links for each problem type. */
-export const ERROR_DOCS = {
- BAD_REQUEST: `${BASE_DOCS_URL}/bad-request`,
- UNAUTHORIZED: `${BASE_DOCS_URL}/unauthorized`,
- FORBIDDEN: `${BASE_DOCS_URL}/forbidden`,
- NOT_FOUND: `${BASE_DOCS_URL}/not-found`,
- METHOD_NOT_ALLOWED: `${BASE_DOCS_URL}/method-not-allowed`,
- NOT_ACCEPTABLE: `${BASE_DOCS_URL}/not-acceptable`,
- REQUEST_TIMEOUT: `${BASE_DOCS_URL}/request-timeout`,
- CONFLICT: `${BASE_DOCS_URL}/conflict`,
- GONE: `${BASE_DOCS_URL}/gone`,
- PRECONDITION_FAILED: `${BASE_DOCS_URL}/precondition-failed`,
- PAYLOAD_TOO_LARGE: `${BASE_DOCS_URL}/payload-too-large`,
- URI_TOO_LONG: `${BASE_DOCS_URL}/uri-too-long`,
- UNSUPPORTED_MEDIA_TYPE: `${BASE_DOCS_URL}/unsupported-media-type`,
- RANGE_NOT_SATISFIABLE: `${BASE_DOCS_URL}/range-not-satisfiable`,
- UNPROCESSABLE_ENTITY: `${BASE_DOCS_URL}/unprocessable-entity`,
- PRECONDITION_REQUIRED: `${BASE_DOCS_URL}/precondition-required`,
- REQUEST_HEADER_FIELDS_TOO_LARGE: `${BASE_DOCS_URL}/request-header-fields-too-large`,
- UNAVAILABLE_FOR_LEGAL_REASONS: `${BASE_DOCS_URL}/unavailable-for-legal-reasons`,
- VALIDATION_ERROR: `${BASE_DOCS_URL}/validation-error`,
- RATE_LIMIT_EXCEEDED: `${BASE_DOCS_URL}/rate-limit-exceeded`,
- INTERNAL_SERVER_ERROR: `${BASE_DOCS_URL}/internal-server-error`,
- NOT_IMPLEMENTED: `${BASE_DOCS_URL}/not-implemented`,
- BAD_GATEWAY: `${BASE_DOCS_URL}/bad-gateway`,
- SERVICE_UNAVAILABLE: `${BASE_DOCS_URL}/service-unavailable`,
- GATEWAY_TIMEOUT: `${BASE_DOCS_URL}/gateway-timeout`,
-} as const
-
-/** Canonical human-readable titles by status. */
-export const STATUS_TITLES: Readonly> = {
- 200: 'OK',
- 201: 'Created',
- 202: 'Accepted',
- 204: 'No Content',
- 400: 'Bad Request',
- 401: 'Unauthorized',
- 403: 'Forbidden',
- 404: 'Not Found',
- 405: 'Method Not Allowed',
- 406: 'Not Acceptable',
- 408: 'Request Timeout',
- 409: 'Conflict',
- 410: 'Gone',
- 412: 'Precondition Failed',
- 413: 'Payload Too Large',
- 414: 'URI Too Long',
- 415: 'Unsupported Media Type',
- 416: 'Range Not Satisfiable',
- 422: 'Unprocessable Entity',
- 428: 'Precondition Required',
- 429: 'Too Many Requests',
- 431: 'Request Header Fields Too Large',
- 451: 'Unavailable For Legal Reasons',
- 500: 'Internal Server Error',
- 501: 'Not Implemented',
- 502: 'Bad Gateway',
- 503: 'Service Unavailable',
- 504: 'Gateway Timeout',
-}
-
-export function titleFor(status: number): string {
- return STATUS_TITLES[status] ?? 'Error'
-}
-
-// ============================================================================
-// Status → convenience factory name map (symbolic)
-// ============================================================================
-
-export const STATUS_TO_FUNCTION_MAP = {
- 400: 'badRequest',
- 401: 'unauthorized',
- 403: 'forbidden',
- 404: 'notFound',
- 405: 'methodNotAllowed',
- 406: 'notAcceptable',
- 408: 'requestTimeout',
- 409: 'conflict',
- 410: 'gone',
- 412: 'preconditionFailed',
- 413: 'payloadTooLarge',
- 414: 'uriTooLong',
- 415: 'unsupportedMediaType',
- 416: 'rangeNotSatisfiable',
- 422: 'unprocessableEntity',
- 428: 'preconditionRequired',
- 429: 'rateLimitExceeded',
- 431: 'requestHeaderFieldsTooLarge',
- 451: 'unavailableForLegalReasons',
- 500: 'internalServerError',
- 501: 'notImplemented',
- 502: 'badGateway',
- 503: 'serviceUnavailable',
- 504: 'gatewayTimeout',
-} as const
-
-export type KnownErrorStatus = keyof typeof STATUS_TO_FUNCTION_MAP
-
-// ============================================================================
-// Extensions (intent-forward extra fields)
-// ============================================================================
-
-export interface MethodNotAllowedExtension { allowed: string[] }
-export interface UnsupportedMediaTypeExtension { supported?: string[] }
-export interface PayloadTooLargeExtension { limitBytes?: number }
-export interface RateLimitExtension { retryAfter: number }
-export interface ServiceUnavailableExtension { service: string }
-
-// ============================================================================
-// Private base builder for problems (always uses STATUS_TITLES)
-// ============================================================================
-
-export function baseProblem(
- status: ContentfulStatusCode,
- type: string,
- instance: string,
- detail: string,
- docs?: string,
- extensions?: Record
-): ErrorResult {
- return [
- Object.assign(
- {
- type,
- title: titleFor(status),
- status,
- detail,
- instance,
- timestamp: new Date(),
- },
- docs ? { docs } : {},
- extensions ? extensions : {},
- ) as ProblemDetails,
- status,
- { 'Content-Type': 'application/problem+json' },
- ] as const
-}
-
-// ============================================================================
-// Multi-error responses
-// ============================================================================
-
-/**
- * Build RFC7807 with an `errors` array for multi-violations.
- *
- * @example
- * const errors = [{ field: 'email', message: 'Invalid email' }]
- * return c.json(...errs(ERROR_TYPES.UNPROCESSABLE_ENTITY, titleFor(422), 422, c.req.path, errors))
- */
-export function errs(
- type: string,
- title: string,
- status: ContentfulStatusCode,
- instance: string,
- errors: ValidationErrorDetail[],
- detail?: string,
- docs?: string
-): ErrorsResult {
- const errorCount = errors.length
- const defaultDetail = errorCount === 1 ? '1 error occurred' : `${errorCount} errors occurred`
- return [
- Object.assign(
- {
- type,
- title,
- status,
- detail: detail || defaultDetail,
- instance,
- timestamp: new Date(),
- errors,
- },
- docs ? { docs } : {},
- ) as ProblemDetailsWithErrors,
- status,
- { 'Content-Type': 'application/problem+json' },
- ] as const
-}
-
-/**
- * Canonical 422 validation failure shape with field-level details.
- *
- * @example
- * return c.json(...validationFailed(c.req.path, toErrs(zodError.issues)))
- */
-export function validationFailed(
- instance: string,
- errors: ValidationErrorDetail[],
- detail?: string
-): ErrorsResult {
- const status = 422 as const
- const errorCount = errors.length
- const defaultDetail =
- errorCount === 1
- ? 'Request validation failed on 1 field'
- : `Request validation failed on ${errorCount} fields`
-
- return errs(
- ERROR_TYPES.VALIDATION_ERROR,
- titleFor(status),
- status,
- instance,
- errors,
- detail || defaultDetail,
- ERROR_DOCS.VALIDATION_ERROR
- )
-}
-
-
-// ============================================================================
-// Convenience error factories (titles always from STATUS_TITLES)
-// ============================================================================
-
-export function badRequest(
- instance: string,
- detail: string,
- extensions?: Record
-): ErrorResult {
- return baseProblem(400, ERROR_TYPES.BAD_REQUEST, instance, detail, ERROR_DOCS.BAD_REQUEST, extensions)
-}
-
-export function unauthorized(
- instance: string,
- detail: string = 'Authentication required',
- extensions?: Record
-): ErrorResult {
- return baseProblem(401, ERROR_TYPES.UNAUTHORIZED, instance, detail, ERROR_DOCS.UNAUTHORIZED, extensions)
-}
-
-export function forbidden(
- instance: string,
- detail: string = 'Insufficient permissions',
- extensions?: Record
-): ErrorResult {
- return baseProblem(403, ERROR_TYPES.FORBIDDEN, instance, detail, ERROR_DOCS.FORBIDDEN, extensions)
-}
-
-export function notFound(
- instance: string,
- detail: string = 'Resource not found',
- extensions?: Record
-): ErrorResult {
- return baseProblem(404, ERROR_TYPES.NOT_FOUND, instance, detail, ERROR_DOCS.NOT_FOUND, extensions)
-}
-
-export function methodNotAllowed(
- instance: string,
- allowed: MethodNotAllowedExtension['allowed'],
- detail: string = 'Method not allowed',
- extensions?: Record
-): ErrorResult {
- return baseProblem(
- 405,
- ERROR_TYPES.METHOD_NOT_ALLOWED,
- instance,
- detail,
- ERROR_DOCS.METHOD_NOT_ALLOWED,
- Object.assign({ allowed }, extensions ? extensions : {}),
- )
-}
-
-export function notAcceptable(
- instance: string,
- detail: string = 'Not acceptable',
- extensions?: Record
-): ErrorResult {
- return baseProblem(406, ERROR_TYPES.NOT_ACCEPTABLE, instance, detail, ERROR_DOCS.NOT_ACCEPTABLE, extensions)
-}
-
-export function requestTimeout(
- instance: string,
- detail: string = 'Request timed out',
- extensions?: Record
-): ErrorResult {
- return baseProblem(408, ERROR_TYPES.REQUEST_TIMEOUT, instance, detail, ERROR_DOCS.REQUEST_TIMEOUT, extensions)
-}
-
-export function conflict(
- instance: string,
- detail: string = 'Resource already exists',
- extensions?: Record
-): ErrorResult {
- return baseProblem(409, ERROR_TYPES.CONFLICT, instance, detail, ERROR_DOCS.CONFLICT, extensions)
-}
-
-export function gone(
- instance: string,
- detail: string = 'Resource is gone',
- extensions?: Record
-): ErrorResult {
- return baseProblem(410, ERROR_TYPES.GONE, instance, detail, ERROR_DOCS.GONE, extensions)
-}
-
-export function preconditionFailed(
- instance: string,
- detail: string = 'Precondition failed',
- extensions?: Record
-): ErrorResult {
- return baseProblem(412, ERROR_TYPES.PRECONDITION_FAILED, instance, detail, ERROR_DOCS.PRECONDITION_FAILED, extensions)
-}
-
-export function payloadTooLarge(
- instance: string,
- limitBytes?: PayloadTooLargeExtension['limitBytes'],
- detail: string = 'Payload too large',
- extensions?: Record
-): ErrorResult {
- return baseProblem(
- 413,
- ERROR_TYPES.PAYLOAD_TOO_LARGE,
- instance,
- detail,
- ERROR_DOCS.PAYLOAD_TOO_LARGE,
- Object.assign(limitBytes ? { limitBytes } : {}, extensions ? extensions : {}),
- )
-}
-
-export function uriTooLong(
- instance: string,
- detail: string = 'URI too long',
- extensions?: Record
-): ErrorResult {
- return baseProblem(414, ERROR_TYPES.URI_TOO_LONG, instance, detail, ERROR_DOCS.URI_TOO_LONG, extensions)
-}
-
-export function unsupportedMediaType(
- instance: string,
- supported?: UnsupportedMediaTypeExtension['supported'],
- detail: string = 'Unsupported media type',
- extensions?: Record
-): ErrorResult {
- return baseProblem(
- 415,
- ERROR_TYPES.UNSUPPORTED_MEDIA_TYPE,
- instance,
- detail,
- ERROR_DOCS.UNSUPPORTED_MEDIA_TYPE,
- Object.assign(supported ? { supported } : {}, extensions ? extensions : {}),
- )
-}
-
-export function rangeNotSatisfiable(
- instance: string,
- detail: string = 'Range not satisfiable',
- extensions?: Record
-): ErrorResult {
- return baseProblem(416, ERROR_TYPES.RANGE_NOT_SATISFIABLE, instance, detail, ERROR_DOCS.RANGE_NOT_SATISFIABLE, extensions)
-}
-
-export function unprocessableEntity(
- instance: string,
- detail: string = 'Request contains semantic errors',
- extensions?: Record
-): ErrorResult {
- return baseProblem(422, ERROR_TYPES.UNPROCESSABLE_ENTITY, instance, detail, ERROR_DOCS.UNPROCESSABLE_ENTITY, extensions)
-}
-
-export function preconditionRequired(
- instance: string,
- detail: string = 'Precondition required',
- extensions?: Record
-): ErrorResult {
- return baseProblem(428, ERROR_TYPES.PRECONDITION_REQUIRED, instance, detail, ERROR_DOCS.PRECONDITION_REQUIRED, extensions)
-}
-
-export function rateLimitExceeded(
- instance: string,
- retryAfter: RateLimitExtension['retryAfter'],
- extensions?: Record
-): ErrorResult {
- return baseProblem(
- 429,
- ERROR_TYPES.RATE_LIMIT_EXCEEDED,
- instance,
- `Rate limit exceeded. Retry after ${retryAfter} seconds.`,
- ERROR_DOCS.RATE_LIMIT_EXCEEDED,
- Object.assign({ retryAfter }, extensions ? extensions : {}),
- )
-}
-
-export function requestHeaderFieldsTooLarge(
- instance: string,
- detail: string = 'Request header fields too large',
- extensions?: Record
-): ErrorResult {
- return baseProblem(
- 431,
- ERROR_TYPES.REQUEST_HEADER_FIELDS_TOO_LARGE,
- instance,
- detail,
- ERROR_DOCS.REQUEST_HEADER_FIELDS_TOO_LARGE,
- extensions
- )
-}
-
-export function unavailableForLegalReasons(
- instance: string,
- detail: string = 'Unavailable for legal reasons',
- extensions?: Record
-): ErrorResult {
- return baseProblem(
- 451,
- ERROR_TYPES.UNAVAILABLE_FOR_LEGAL_REASONS,
- instance,
- detail,
- ERROR_DOCS.UNAVAILABLE_FOR_LEGAL_REASONS,
- extensions
- )
-}
-
-export function internalServerError(
- instance: string,
- detail: string = 'Internal server error',
- extensions?: Record
-): ErrorResult {
- return baseProblem(500, ERROR_TYPES.INTERNAL_SERVER_ERROR, instance, detail, ERROR_DOCS.INTERNAL_SERVER_ERROR, extensions)
-}
-
-export function notImplemented(
- instance: string,
- detail: string = 'Not implemented',
- extensions?: Record
-): ErrorResult {
- return baseProblem(501, ERROR_TYPES.NOT_IMPLEMENTED, instance, detail, ERROR_DOCS.NOT_IMPLEMENTED, extensions)
-}
-
-export function badGateway(
- instance: string,
- detail: string = 'Bad gateway',
- extensions?: Record
-): ErrorResult {
- return baseProblem(502, ERROR_TYPES.BAD_GATEWAY, instance, detail, ERROR_DOCS.BAD_GATEWAY, extensions)
-}
-
-export function serviceUnavailable(
- instance: string,
- service: ServiceUnavailableExtension['service'],
- detail?: string,
- extensions?: Record
-): ErrorResult {
- return baseProblem(
- 503,
- ERROR_TYPES.SERVICE_UNAVAILABLE,
- instance,
- detail || `Upstream service '${service}' unavailable`,
- ERROR_DOCS.SERVICE_UNAVAILABLE,
- Object.assign({ service }, extensions ? extensions : {}),
- )
-}
-
-export function gatewayTimeout(
- instance: string,
- detail: string = 'Gateway timeout',
- extensions?: Record
-): ErrorResult {
- return baseProblem(504, ERROR_TYPES.GATEWAY_TIMEOUT, instance, detail, ERROR_DOCS.GATEWAY_TIMEOUT, extensions)
-}
-
-// ============================================================================
-// err(): status-agnostic entrypoint with smart delegation (no `any` casting)
-// ============================================================================
-
-/**
- * Build a Problem Details error for any status.
- * - Delegates to the matching convenience factory (with required extras).
- * - Falls back to a generic RFC7807 if status is not mapped.
- */
-export function err(
- status: ContentfulStatusCode,
- instance: string,
- detail: string,
- extensions?: Record
-): ErrorResult {
- switch (status as number) {
- case 400: return badRequest(instance, detail, extensions)
- case 401: return unauthorized(instance, detail, extensions)
- case 403: return forbidden(instance, detail, extensions)
- case 404: return notFound(instance, detail, extensions)
- case 405: {
- // If caller has `allowed`, they should call methodNotAllowed directly.
- return methodNotAllowed(instance, [], detail, extensions)
- }
- case 406: return notAcceptable(instance, detail, extensions)
- case 408: return requestTimeout(instance, detail, extensions)
- case 409: return conflict(instance, detail, extensions)
- case 410: return gone(instance, detail, extensions)
- case 412: return preconditionFailed(instance, detail, extensions)
- case 413: return payloadTooLarge(instance, undefined, detail, extensions)
- case 414: return uriTooLong(instance, detail, extensions)
- case 415: return unsupportedMediaType(instance, undefined, detail, extensions)
- case 416: return rangeNotSatisfiable(instance, detail, extensions)
- case 422: return unprocessableEntity(instance, detail, extensions)
- case 428: return preconditionRequired(instance, detail, extensions)
- case 429: {
- const retryAfter =
- extensions && typeof (extensions as { retryAfter?: unknown }).retryAfter === 'number'
- ? (extensions as { retryAfter: number }).retryAfter
- : 60
- return rateLimitExceeded(instance, retryAfter, extensions)
- }
- case 431: return requestHeaderFieldsTooLarge(instance, detail, extensions)
- case 451: return unavailableForLegalReasons(instance, detail, extensions)
- case 500: return internalServerError(instance, detail, extensions)
- case 501: return notImplemented(instance, detail, extensions)
- case 502: return badGateway(instance, detail, extensions)
- case 503: {
- const service =
- extensions && typeof (extensions as { service?: unknown }).service === 'string'
- ? (extensions as { service: string }).service
- : 'unknown'
- return serviceUnavailable(instance, service, detail, extensions)
- }
- case 504: return gatewayTimeout(instance, detail, extensions)
- default:
- // Generic fallback uses canonical title & inferred type.
- return baseProblem(
- status,
- `${BASE_ERROR_URL}/${String(status)}`,
- instance,
- detail,
- undefined,
- extensions
- )
- }
-}
-
-// ============================================================================
-// Header helpers (typed)
-// ============================================================================
-
-/**
- * Compute extra HTTP headers that should accompany a Problem response.
- * - 429/503: set `Retry-After` from `extensions.retryAfter` (seconds) when present.
- */
-export function extraProblemHeaders(
- status: ContentfulStatusCode,
- extensions?: Record
-): Record {
- const out: Record = {}
- const retryAfter =
- extensions && typeof (extensions as { retryAfter?: unknown }).retryAfter === 'number'
- ? (extensions as { retryAfter: number }).retryAfter
- : undefined
-
- if ((status === 429 || status === 503) && typeof retryAfter === 'number') {
- out['Retry-After'] = String(retryAfter)
- }
- return out
-}
-
-/**
- * Throws an RFC 7807–compliant HTTP problem response.
- *
- * This utility wraps an `ErrorResult` tuple into a Hono `HTTPException`,
- * ensuring that validation errors or other structured error payloads
- * are returned in a standardized JSON format.
- *
- * The response body follows the [RFC 7807 Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc7807)
- * specification, allowing clients to consume consistent error information.
- *
- * @param err - A tuple containing:
- * - `error`: The problem details object (e.g. validation errors).
- * - `status`: The HTTP status code to return.
- * - `headers`: Optional headers to include in the response.
- *
- * @throws {HTTPException} Always throws a Hono `HTTPException` with the given
- * problem details, status code, and headers.
- *
- * @example
- * ```ts
- * // Example usage inside a Hono route:
- * if (!isValid(input)) {
- * exception([{ title: "Invalid input", detail: "Field X is required" }, 400, { "Content-Type": "application/problem+json" }]);
- * }
- * ```
- */
-export function exception(err: ErrorResponse) {
- const [error, status, headers] = err;
-
- // Return RFC 7807 validation error array
- return new HTTPException(status, {
- res: new Response(JSON.stringify(error), { status, headers: headers }),
- });
-}
-
-/**
- * Check if response is an error by inspecting Content-Type
- * Don't destructure before checking - this maintains type narrowing
- *
- * @example
- * const result = await someApiCall()
- * if (isErrorResponse(result)) {
- * const [error, status, headers] = result // ✅ result is now ErrorResponse
- * return c.json(error, status, headers)
- * }
- * // ✅ result is now SuccessResponse
- * const [data, status, headers] = result
- */
-export function isErrorResponse(response: ResponseResult): response is ErrorResponse {
- return response[2]['Content-Type'] === 'application/problem+json'
-}
\ No newline at end of file
diff --git a/utils/response/index.ts b/utils/response/index.ts
deleted file mode 100644
index 91a7480..0000000
--- a/utils/response/index.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * RFC 7807 + Success Envelope Utilities for Hono
- * ------------------------------------------------
- * - Canonical status titles & problem type/docs registries
- * - Strict tuples for success & error responses (stable 3-tuple shape)
- * - Convenience error factories for all common HTTP errors
- * - Success helpers: ok, created (201 + Location), accepted (202), noContent (204), paginate
- * - errs() + validationFailed() for multi-error payloads
- * - Header utilities: extraProblemHeaders() and withHeaders() with precise typing
- *
- * Notes
- * - Uses Object.assign for meta/header building to match your style preferences.
- * - Avoids `any` in public types; no unsafe casts in user-facing APIs.
- * - ESM-friendly, Deno v2 / TS strict, tree-shakeable.
- */
-
-export type * from "./schemas.ts";
-export * from "./errors.ts";
-export * from "./success.ts";
-export * from "./status-codes.ts"
-
-// ============================================================================
-// Quick usage examples (copy/paste into handlers)
-// ============================================================================
-//
-// // OK
-// return c.json(...ok({ id: 'like-1' }))
-//
-// // Created + Location
-// const res = created({ id }, `/api/things/${id}`)
-// return c.json(...withHeaders(res, { Location: `/api/things/${id}` }))
-//
-// // Rate limited with Retry-After header
-// const err429 = rateLimitExceeded(c.req.path, 120)
-// return c.json(...withHeaders(err429, extraProblemHeaders(429, { retryAfter: 120 })))
-//
-// // Validation
-// return c.json(...validationFailed(c.req.path, [{ field: 'email', message: 'Invalid' }]))
-//
-// // Status-agnostic
-// return c.json(...err(404, c.req.path, 'Thing not found'))
diff --git a/utils/response/schemas.ts b/utils/response/schemas.ts
deleted file mode 100644
index 3fa6552..0000000
--- a/utils/response/schemas.ts
+++ /dev/null
@@ -1,487 +0,0 @@
-// utils/response/schemas.ts
-/**
- * Response types and schemas following RFC 7807 (Problem Details) and success envelopes
- *
- * Architecture:
- * - Everything is a Zod schema first
- * - Types are inferred from schemas via z.infer
- * - Factory functions create generic schemas (like in fields.ts, filtering.ts)
- * - Follows same pattern as query processing utilities
- * - Aligns with Hono's ContentfulStatusCode and ContentlessStatusCode types
- */
-
-import type { ContentfulStatusCode, ContentlessStatusCode } from 'hono/utils/http-status'
-import type { RequestHeader } from 'hono/utils/headers'
-
-import { ContentfulStatusCodeSchema } from './status-codes.ts'
-import { QuerySpecSchema } from '../query/schemas.ts'
-import { z } from 'zod'
-
-// ============================================================================
-// BASE COMPONENT SCHEMAS
-// ============================================================================
-
-/**
- * Validation error detail schema
- * Individual field-level or rule-level validation error
- */
-export const ValidationErrorDetailSchema = z.object({
- field: z.string(),
- message: z.string()
-})
-
-export type ValidationErrorDetail = z.infer
-
-/**
- * RFC 7807 Problem Details base schema
- */
-export const ProblemDetailsSchema = z.object({
- type: z.string(),
- title: z.string(),
- status: ContentfulStatusCodeSchema,
- detail: z.string(),
- instance: z.string(),
- timestamp: z.coerce.date(),
- docs: z.url().optional()
-})
-
-export type ProblemDetails = z.infer
-
-/**
- * RFC 7807 Problem Details with validation errors array
- */
-export const ProblemDetailsWithErrorsSchema = ProblemDetailsSchema.extend({
- errors: z.array(ValidationErrorDetailSchema)
-})
-
-export type ProblemDetailsWithErrors = z.infer
-
-/**
- * Metadata describing the pagination state of a result set.
- *
- * This interface is designed to support both cursor-based and offset-based
- * pagination strategies. It captures the current slice of data and provides
- * hints for navigating forward/backward.
- *
- * Supports cursor-based and offset-based pagination, plus
- * optional approximate totals and expiry timestamps.
- */
-export const PaginationSchema = z.object({
- hasMore: z.boolean(),
- limit: z.number().int().positive(),
- count: z.number().int().min(0),
- nextCursor: z.string().optional(),
- prevCursor: z.string().optional(),
- offset: z.number().int().min(0).optional(),
- total: z.number().int().min(0).optional(),
- approxTotal: z.number().int().min(0).optional(),
- expiresAt: z.coerce.date().optional()
-})
-
-export type Pagination = z.infer
-
-/**
- * Base metadata schema - accepts arbitrary additional fields
- */
-export const DataMetadataSchema = z.object().catchall(z.unknown())
-
-export type DataMetadata = z.infer
-
-/**
- * LinkMap
- *
- * Normalized representation of RFC 8288-style links for a single response.
- *
- * - Keys are **link relation types** (`rel`): "self", "next", "prev", "first", "last", etc.
- * - Values are URI references (often relative paths like `/articles?page=2`).
- * - `self` is **required** and must be non-empty.
- * - Standard pagination rels (`first`, `last`, `next`, `prev`) are optional.
- * - Any additional rel (e.g. "describedby", "up", custom rels) is allowed via catchall.
- *
- * @example
- * const links = LinkMapSchema.parse({
- * self: "/articles?offset=40&limit=20",
- * first: "/articles?offset=0&limit=20",
- * prev: "/articles?offset=20&limit=20",
- * next: "/articles?offset=60&limit=20",
- * last: "/articles?offset=120&limit=20",
- * })
- *
- * @example
- * // With custom rels
- * const links = LinkMapSchema.parse({
- * self: "/articles?offset=0&limit=20",
- * describedby: "https://api.example.com/schemas/articles",
- * profile: "https://api.example.com/profiles/pagination",
- * })
- */
-export const LinkMapSchema = z
- .object({
- self: z.string().min(1, "self link must be a non-empty URI reference"),
-
- // Common pagination link relations (all optional)
- first: z.string().min(1).optional(),
- last: z.string().min(1).optional(),
- next: z.string().min(1).optional(),
- prev: z.string().min(1).optional(),
- })
- // Allow additional rels like "describedby", "up", "profile", etc.
- .catchall(z.string().min(1));
-
-export type LinkMap = z.infer;
-
-// ============================================================================
-// HEADER SCHEMAS
-// ============================================================================
-
-/** Standard Headers - properties are optional but must be strings when present */
-export const StandardHeadersSchema = z.object().catchall(z.string());
-
-/**
- * Standard JSON response headers
- */
-export const JsonHeadersSchema = StandardHeadersSchema.extend({
- 'Content-Type': z.union([z.literal('application/json'), z.string()])
-})
-
-export type JsonHeaders = z.infer
-
-/**
- * Pagination response headers (includes RFC 8288 Link header)
- */
-export const JsonHeadersWithLinksSchema = JsonHeadersSchema.extend({
- 'Link': z.string(),
- 'X-Total-Count': z.string().optional(),
- 'X-Per-Page': z.string().optional(),
- 'X-Page': z.string().optional(),
- 'X-Total-Pages': z.string().optional(),
- 'Preference-Applied': z.string().optional(),
- 'Range-Unit': z.string().optional(),
- 'Content-Range': z.string().optional()
-})
-
-export type JsonHeadersWithLinks = z.infer
-
-/**
- * RFC 7807 problem+json headers
- */
-export const ProblemHeadersSchema = StandardHeadersSchema.extend({
- 'Content-Type': z.literal('application/problem+json')
-})
-
-export type ProblemHeaders = z.infer
-
-/**
- * Generic standard headers type (for helper function signatures)
- */
-export type StandardHeaders = {
- [K in RequestHeader]?: string
-} & z.infer
-
-// ============================================================================
-// ENVELOPE SCHEMA FACTORIES (for generic data types)
-// ============================================================================
-
-/**
- * Create success envelope schema
- *
- * Structure: { data: T, meta: { timestamp: string, ...custom } }
- *
- * @example
- * const UserSchema = z.object({ id: z.string(), name: z.string() })
- * const EnvelopeSchema = makeSuccessEnvelopeSchema(UserSchema)
- * type Envelope = z.infer
- * // => { data: User, meta: { timestamp: string } }
- */
-export function makeSuccessEnvelopeSchema<
- TData extends z.ZodType,
- TMeta extends z.ZodObject = z.ZodObject<{}>
->(
- dataSchema: TData,
- metaSchema?: TMeta
-) {
- const baseMeta = z.object({
- timestamp: z.coerce.date(),
- })
-
- const meta = metaSchema
- ? z.intersection(baseMeta, metaSchema)
- : baseMeta
-
- return z.object({
- data: dataSchema,
- meta: meta
- })
-}
-
-/**
- * Generic success envelope type (for helpers like ok(), created())
- * For actual validation, use makeSuccessEnvelopeSchema()
- */
-export interface SuccessEnvelope {
- data: T
- meta: M & { timestamp: string }
-}
-
-
-/**
- * Create pagination envelope schema
- *
- * Structure: { data: T[], meta: { timestamp: string, pagination: Pagination } }
- *
- * @example
- * const UserSchema = z.object({ id: z.string(), name: z.string() })
- * const EnvelopeSchema = makePaginationEnvelopeSchema(z.array(UserSchema))
- * type Envelope = z.infer
- */
-export function makePaginationEnvelopeSchema(
- dataSchema: TData
-) {
- return makeSuccessEnvelopeSchema(dataSchema, PaginationMetadataSchema)
-}
-
-/**
- * Generic pagination envelope type (for helpers like paginate())
- * For actual validation, use makePaginationEnvelopeSchema()
- */
-export type PaginationEnvelope = SuccessEnvelope
-
-/**
- * Pagination metadata schema (pre-built)
- */
-export const PaginationMetadataSchema = DataMetadataSchema.extend({
- pagination: PaginationSchema,
- query: QuerySpecSchema.optional(),
-})
-
-export type PaginationMetadata = z.infer
-
-/**
- * Create contentless result tuple schema
- * For responses with no body (204, 101, 205, 304)
- *
- * @example
- * const ResultSchema = makeContentlessResultSchema(204)
- * type Result = z.infer
- * // => readonly [undefined, 204, JsonHeaders]
- */
-export const ContentlessResultSchema = z.tuple([
- z.undefined(),
- ContentfulStatusCodeSchema,
- JsonHeadersSchema
-])
-
-/**
- * Generic contentless result tuple type
- * For actual validation, use makeContentlessResultSchema()
- */
-export type ContentlessResult = readonly [
- undefined,
- ContentlessStatusCode,
- JsonHeaders
-]
-
-// ============================================================================
-// RESULT TUPLE SCHEMA FACTORIES
-// ============================================================================
-
-/**
- * Create success result tuple schema
- * For responses with data (200, 201, etc.)
- *
- * @example
- * const UserSchema = z.object({ id: z.string() })
- * const ResultSchema = makeSuccessResultSchema(UserSchema, 200)
- * type Result = z.infer
- * // => readonly [{ data: User, meta: { timestamp: string } }, 200, JsonHeaders]
- */
-export function makeSuccessResultSchema<
- TData extends z.ZodType
->(
- dataSchema: TData,
- metaSchema?: z.ZodObject
-) {
- return z.tuple([
- makeSuccessEnvelopeSchema(dataSchema, metaSchema),
- ContentfulStatusCodeSchema,
- JsonHeadersSchema
- ])
-}
-
-/**
- * Generic success result tuple type
- * For actual validation, use makeSuccessResultSchema()
- */
-export type GenericSuccessResult = readonly [
- E,
- ContentfulStatusCode,
- H
-]
-
-/**
- * Success Result
- */
-export type SuccessResult = GenericSuccessResult>
-
-/**
- * Create pagination result tuple schema
- * Always returns 200 with pagination metadata
- *
- * @example
- * const UserSchema = z.object({ id: z.string() })
- * const ResultSchema = makePaginationResultSchema(z.array(UserSchema))
- * type Result = z.infer
- * // => readonly [{ data: User[], meta: { timestamp: string, pagination: Pagination } }, 200, JsonHeadersWithLinks]
- */
-export function makePaginationResultSchema(
- dataSchema: TData
-) {
- return z.tuple([
- makePaginationEnvelopeSchema(dataSchema),
- ContentfulStatusCodeSchema,
- JsonHeadersWithLinksSchema
- ])
-}
-
-/**
- * Generic pagination result tuple type
- * For actual validation, use makePaginationResultSchema()
- */
-export type PaginationResult = GenericSuccessResult, JsonHeadersWithLinks>
-
-/**
- * Create error result tuple schema
- * For RFC 7807 error responses
- *
- * @example
- * const ResultSchema = ErrorResultSchema
- * type Result = z.infer
- * // => readonly [ProblemDetails, 404, ProblemHeaders]
- */
-export const ErrorResultSchema = z.tuple([
- ProblemDetailsSchema,
- ContentfulStatusCodeSchema,
- ProblemHeadersSchema
-])
-
-/**
- * Generic error result tuple type
- * For actual validation, use makeErrorResultSchema()
- */
-export type ErrorResult = readonly [
- ProblemDetails,
- ContentfulStatusCode,
- ProblemHeaders
-]
-
-/**
- * Create validation errors result tuple schema
- * For 422 responses with multiple field errors
- *
- * @example
- * const ResultSchema = makeErrorsResultSchema()
- * type Result = z.infer
- * // => readonly [ProblemDetailsWithErrors, 422, ProblemHeaders]
- */
-export const ErrorsResultSchema = z.tuple([
- ProblemDetailsWithErrorsSchema,
- ContentfulStatusCodeSchema,
- ProblemHeadersSchema
-])
-
-/**
- * Generic errors result tuple type (with validation errors array)
- * For actual validation, use makeErrorsResultSchema()
- */
-export type ErrorsResult = readonly [
- ProblemDetailsWithErrors,
- ContentfulStatusCode,
- ProblemHeaders
-]
-
-// ============================================================================
-// RESPONSE UNION SCHEMAS (for comprehensive validation)
-// ============================================================================
-
-/**
- * Create success response union schema
- * Includes contentless (204), success, and pagination results
- *
- * @example
- * const UserSchema = z.object({ id: z.string() })
- * const ResponseSchema = makeSuccessResponseSchema(UserSchema)
- *
- * // Can validate any of:
- * // - [{ data: User, meta }, 200, JsonHeaders]
- * // - [{ data: User[], meta: { pagination } }, 200, JsonHeadersWithLinks]
- * // - [undefined, 204, JsonHeaders]
- */
-export function makeSuccessResponseSchema(
- dataSchema: TData
-) {
- return z.union([
- makeSuccessResultSchema(dataSchema),
- makePaginationResultSchema(dataSchema),
- ContentlessResultSchema
- ])
-}
-
-/**
- * Union of success response types
- */
-export type SuccessResponse =
- | SuccessResult
- | PaginationResult
- | ContentlessResult
-
-/**
- * Create error response union schema
- * Includes common error status codes
- *
- * @example
- * const ResponseSchema = ErrorResponseSchema
- *
- * // Can validate any of:
- * // - [ProblemDetails, 400, ProblemHeaders]
- * // - [ProblemDetails, 404, ProblemHeaders]
- * // - [ProblemDetailsWithErrors, 422, ProblemHeaders]
- * // - [ProblemDetails, 500, ProblemHeaders]
- */
-export const ErrorResponseSchema = z.union([
- ErrorResultSchema,
- ErrorsResultSchema
-])
-
-/**
- * Union of error response types
- */
-export type ErrorResponse =
- | ErrorResult
- | ErrorsResult
-
-
-/**
- * Create complete response union schema
- * Includes all success and error variants
- *
- * @example
- * const UserSchema = z.object({ id: z.string() })
- * const ResponseSchema = makeResponseResultSchema(UserSchema)
- *
- * // Can validate any response tuple from your API
- */
-export function makeResponseResultSchema(
- dataSchema: TData
-) {
- return z.union([
- makeSuccessResponseSchema(dataSchema),
- ErrorResponseSchema,
- ])
-}
-
-/**
- * Union of all response tuple types
- */
-export type ResponseResult =
- | SuccessResponse
- | ErrorResponse
\ No newline at end of file
diff --git a/utils/response/status-codes.ts b/utils/response/status-codes.ts
deleted file mode 100644
index 25aafd6..0000000
--- a/utils/response/status-codes.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-// utils/response/status-codes.ts
-/**
- * HTTP status-code schemas and types.
- *
- * Architecture & Conventions 🗺️
- * ────────────────────────────
- * – Every status-code *category* is expressed first as a **Zod schema**
- * (literal-union of numeric codes).
- * – The **TypeScript type** is inferred via `z.infer<…>` immediately
- * after the schema.
- * – Section headers mirror the layout used in **schemas.ts** to keep the
- * codebase consistent and easy to scan. :contentReference[oaicite:0]{index=0}
- */
-
-import { z } from 'zod'
-
-/* ============================================================================
- * 1×× INFORMATIONAL STATUS CODES
- * ============================================================================
- */
-
-/**
- * Informational (1××) status codes.
- */
-export const InfoStatusCodeSchema = z.union([
- z.literal(100),
- z.literal(101),
- z.literal(102),
- z.literal(103),
-])
-export type InfoStatusCode = z.infer
-
-/* ============================================================================
- * 2×× SUCCESS STATUS CODES
- * ============================================================================
- */
-
-/**
- * Successful (2××) status codes.
- */
-export const SuccessStatusCodeSchema = z.union([
- z.literal(200),
- z.literal(201),
- z.literal(202),
- z.literal(203),
- z.literal(204),
- z.literal(205),
- z.literal(206),
- z.literal(207),
- z.literal(208),
- z.literal(226),
-])
-export type SuccessStatusCode = z.infer
-
-/* ============================================================================
- * 3×× REDIRECTION STATUS CODES
- * (includes the historical 305 / 306 for completeness)
- * ============================================================================
- */
-
-/**
- * Deprecated redirection status codes (305 & 306).
- */
-export const DeprecatedStatusCodeSchema = z.union([
- z.literal(305),
- z.literal(306),
-])
-export type DeprecatedStatusCode = z.infer
-
-/**
- * Redirection (3××) status codes.
- */
-export const RedirectStatusCodeSchema = z.union([
- z.literal(300),
- z.literal(301),
- z.literal(302),
- z.literal(303),
- z.literal(304),
- ...DeprecatedStatusCodeSchema.options,
- z.literal(307),
- z.literal(308),
-])
-export type RedirectStatusCode = z.infer
-
-/* ============================================================================
- * 4×× CLIENT-ERROR STATUS CODES
- * ============================================================================
- */
-
-/**
- * Client-error (4××) status codes.
- */
-export const ClientErrorStatusCodeSchema = z.union([
- z.literal(400),
- z.literal(401),
- z.literal(402),
- z.literal(403),
- z.literal(404),
- z.literal(405),
- z.literal(406),
- z.literal(407),
- z.literal(408),
- z.literal(409),
- z.literal(410),
- z.literal(411),
- z.literal(412),
- z.literal(413),
- z.literal(414),
- z.literal(415),
- z.literal(416),
- z.literal(417),
- z.literal(418),
- z.literal(421),
- z.literal(422),
- z.literal(423),
- z.literal(424),
- z.literal(425),
- z.literal(426),
- z.literal(428),
- z.literal(429),
- z.literal(431),
- z.literal(451),
-])
-export type ClientErrorStatusCode = z.infer
-
-/* ============================================================================
- * 5×× SERVER-ERROR STATUS CODES
- * ============================================================================
- */
-
-/**
- * Server-error (5××) status codes.
- */
-export const ServerErrorStatusCodeSchema = z.union([
- z.literal(500),
- z.literal(501),
- z.literal(502),
- z.literal(503),
- z.literal(504),
- z.literal(505),
- z.literal(506),
- z.literal(507),
- z.literal(508),
- z.literal(510),
- z.literal(511),
-])
-export type ServerErrorStatusCode = z.infer
-
-/* ============================================================================
- * SPECIAL / UNOFFICIAL CODES
- * ============================================================================
- */
-
-/**
- * Unofficial / “unknown” status code (-1).
- *
- * @example
- * ```ts
- * return c.text("Unknown Error", -1 as UnofficialStatusCode)
- * ```
- */
-export const UnofficialStatusCodeSchema = z.literal(-1)
-export type UnofficialStatusCode = z.infer
-
-/**
- * @deprecated — Use `UnofficialStatusCode` instead.
- */
-export type UnOfficalStatusCode = UnofficialStatusCode
-
-/* ============================================================================
- * AGGREGATE STATUS-CODE SCHEMAS
- * ============================================================================
- */
-
-/**
- * Any legal HTTP status code (including unofficial).
- */
-export const StatusCodeSchema = z.union([
- InfoStatusCodeSchema,
- SuccessStatusCodeSchema,
- RedirectStatusCodeSchema,
- ClientErrorStatusCodeSchema,
- ServerErrorStatusCodeSchema,
- UnofficialStatusCodeSchema,
-])
-export type StatusCode = z.infer
-
-/**
- * Status codes that MUST NOT include a body in the response.
- * (101 Switching Protocols · 204 No Content · 205 Reset Content · 304 Not Modified)
- */
-export const ContentlessStatusCodeSchema = z.union([
- z.literal(101),
- z.literal(204),
- z.literal(205),
- z.literal(304),
-])
-export type ContentlessStatusCode = z.infer
-
-/**
- * Status codes that MAY include a body in the response.
- * Defined as *all* legal codes minus the four content-less ones.
- */
-export const ContentfulStatusCodeSchema = StatusCodeSchema.refine(
- (code): code is Exclude =>
- !ContentlessStatusCodeSchema.safeParse(code).success,
- { error: 'Contentful status code expected' },
- )
-export type ContentfulStatusCode = z.infer
-
diff --git a/utils/response/success.ts b/utils/response/success.ts
deleted file mode 100644
index b33fe97..0000000
--- a/utils/response/success.ts
+++ /dev/null
@@ -1,404 +0,0 @@
-import type { ContentlessStatusCode, ContentfulStatusCode, StatusCode } from 'hono/utils/http-status'
-import type { ContentlessResult, Pagination, SuccessResult, PaginationResult, DataMetadata, JsonHeadersWithLinks, StandardHeaders, ResponseResult, SuccessResponse, SuccessEnvelope, GenericSuccessResult, PaginationMetadata, LinkMap } from './schemas.ts'
-
-/**
- * 200/201/202/204 OK-style success envelope.
- *
- * @example
- * return c.json(...ok({ id: 'like-1' })) // 200
- * return c.json(...ok(resource, 201)) // 201 Created (use `created` if you have a Location)
- * return c.json(...ok(null, 204)) // 204 No Content
- */
-export function ok(
- data: T,
- statusCode: ContentlessStatusCode,
-): ContentlessResult;
-
-export function ok(
- data: T,
- statusCode: ContentfulStatusCode,
- meta?: M
-): SuccessResult;
-
-export function ok(
- data: T,
- statusCode: StatusCode = 200,
- meta = {} as M
-): SuccessResult | ContentlessResult {
- switch (statusCode) {
- case 101:
- case 204:
- case 205:
- case 304:
- return [undefined, statusCode as ContentlessStatusCode, { 'Content-Type': 'application/json' }] as const
- }
-
- return [
- {
- data,
- meta: Object.assign({ timestamp: new Date().toISOString() }, (meta ?? {})),
- },
- statusCode as ContentfulStatusCode,
- { 'Content-Type': 'application/json' },
- ] as const
-}
-
-/**
- * 201 Created — includes optional Location header.
- *
- * @example
- * const res = created({ id }, `/api/orders/${id}`)
- * return c.json(...withHeaders(res, { Location: `/api/orders/${id}` }))
- */
-export function created(
- data: T,
- location?: string,
- meta?: M
-): SuccessResult {
- const result = ok(data, 201, meta)
- return location ? withHeaders(result, { Location: location }) : result
-}
-
-/**
- * 202 Accepted — server accepted the request for processing.
- *
- * Provide tracking metadata to help clients poll or subscribe.
- *
- * @example
- * return c.json(...accepted({ taskId }, { tracking: { taskId, status: 'queued' } }))
- */
-export function accepted(
- data: T,
- meta?: M
-): SuccessResult {
- return ok(data, 202, meta)
-}
-
-/**
- * 204 No Content — success with no payload. `data` is always null.
- *
- * @example
- * return c.json(...noContent())
- */
-export function noContent(): ContentlessResult {
- return ok(null, 204)
-}
-
-/**
- * Paginated success (cursor or offset) with standard + de-facto headers.
- *
- * Headers emitted:
- * - Always:
- * - Link: rel="next"/"prev" for cursor; rel="first"/"next"/"prev"/"last" for offset
- * - Offset mode (when available):
- * - X-Total-Count:
- * - X-Per-Page:
- * - X-Page: <1-based page index>
- * - X-Total-Pages: // only when exact total known
- * - Range-Unit: items // standards-aligned
- * - Content-Range: start-end/total // only when exact total known
- * - Preference-Applied: count=exact|estimated
- *
- * Notes:
- * - We never guess "first/last" for cursor paging (that’s typically unstable). For offset we include both.
- * - When only approxTotal is available, we still emit X-Total-Count (as best-effort) and mark
- * Preference-Applied: count=estimated, but we avoid Content-Range and X-Total-Pages (those imply exactness).
- *
- * @example
- * return c.json(...paginate(c.req.url, items, { hasMore: true, nextCursor: 'abc', limit: 20 }))
- */
-export function paginate(
- url = "/",
- data: T,
- pagination: Pagination
-): PaginationResult {
- // --- headers & links -------------------------------------------------------
- const headers: StandardHeaders = {}
- const linkMap: LinkMap = { self: url };
- const linkHeaderParts: string[] = []
-
- // Self link (current page)
- if (pagination.offset !== undefined && pagination.limit) {
- // Canonical offset-based URL for this page
- const selfUrl = buildOffsetUrl(url, pagination.offset, pagination.limit);
- linkHeaderParts.push(`<${selfUrl}>; rel="self"`)
- linkMap.self = selfUrl
- } else {
- // Fallback: treat `path` as already representing the current URL (path + query)
- linkHeaderParts.push(`<${url}>; rel="self"`)
- linkMap.self = url
- }
-
- // Cursor links
- if (pagination.nextCursor) {
- const nextUrl = buildCursorUrl(url, pagination.nextCursor, pagination.limit)
- linkHeaderParts.push(`<${nextUrl}>; rel="next"`)
- linkMap.next = nextUrl
- }
- if (pagination.prevCursor) {
- const prevUrl = buildCursorUrl(url, pagination.prevCursor, pagination.limit)
- linkHeaderParts.push(`<${prevUrl}>; rel="prev"`)
- linkMap.next = prevUrl
- }
-
- // Offset links + extra headers
- if (pagination.offset !== undefined && pagination.limit) {
- const firstOffset = 0
- const nextOffset = pagination.offset + pagination.limit
- const prevOffset = Math.max(pagination.offset - pagination.limit, 0)
-
- const firstUrl = buildOffsetUrl(url, firstOffset, pagination.limit);
- const nextUrl = buildOffsetUrl(url, nextOffset, pagination.limit);
- const prevUrl = buildOffsetUrl(url, prevOffset, pagination.limit);
-
- linkMap.first = firstUrl;
- linkMap.next ??= nextUrl;
- linkMap.prev ??= prevUrl;
-
- linkHeaderParts.push(`<${firstUrl}>; rel="first"`);
- linkHeaderParts.push(`<${nextUrl}>; rel="next"`);
- linkHeaderParts.push(`<${prevUrl}>; rel="prev"`);
-
- const total = pagination.total
- const approx = pagination.approxTotal
-
- // De-facto admin-friendly counters
- // Prefer exact `total`; fall back to `approxTotal` if present.
- if (typeof total === "number" || typeof approx === "number") {
- const totalCount = typeof total === "number" ? total : (approx as number)
- headers["X-Total-Count"] = String(totalCount)
- headers["X-Per-Page"] = String(pagination.limit)
- headers["X-Page"] = String(Math.floor((pagination.offset ?? 0) / pagination.limit) + 1)
- // Only compute total pages when exact total is known
- if (typeof total === "number" && pagination.limit > 0) {
- headers["X-Total-Pages"] = String(Math.max(Math.ceil(total / pagination.limit), 1))
- }
- // Signal whether the server used exact or estimated counts
- headers["Preference-Applied"] = typeof total === "number" ? "count=exact" : "count=estimated"
- }
-
- // Standards-aligned range headers (only when exact total is known)
- // - Range-Unit: items
- // - Content-Range: start-end/total (end is inclusive)
- if (typeof total === "number") {
- const start = Math.max(pagination.offset, 0)
- const end = Math.max(Math.min(start + pagination.limit - 1, Math.max(total - 1, 0)), 0)
- headers["Range-Unit"] = "items"
- headers["Content-Range"] = `${start}-${end}/${total}`
- }
-
- // Last link only when exact total is known (so it's meaningful)
- if (typeof total === "number" && total >= 0) {
- const lastOffset = Math.max(total - pagination.limit, 0)
- const lastUrl = buildOffsetUrl(url, lastOffset, pagination.limit);
- linkHeaderParts.push(`<${lastUrl}>; rel="last"`);
- linkMap.last = lastUrl;
- }
- }
-
- if (linkHeaderParts.length > 0) {
- headers["Link"] = Array.from(new Set(linkHeaderParts)).join(", ").trim()
- }
-
- // --- meta envelope ---------------------------------------------------------
- const paginationMeta = Object.assign(
- {
- hasMore: pagination.hasMore,
- limit: pagination.limit,
- count: pagination.count,
- },
- pagination.nextCursor !== undefined ? { nextCursor: pagination.nextCursor } : {},
- pagination.prevCursor !== undefined ? { prevCursor: pagination.prevCursor } : {},
- pagination.offset !== undefined ? { offset: pagination.offset } : {},
- pagination.total !== undefined ? { total: pagination.total } : {},
- (pagination.approxTotal !== undefined || pagination.total !== undefined)
- ? { approxTotal: pagination.total ?? pagination.approxTotal }
- : {},
- pagination.expiresAt !== undefined ? { expiresAt: pagination.expiresAt } : {},
- ) as Pagination;
-
-
- const meta: PaginationMetadata = { pagination: paginationMeta, links: linkMap };
-
- // Return typed pagination tuple with headers
- return withHeaders(ok(data, 200, meta), headers as JsonHeadersWithLinks) satisfies PaginationResult
-}
-
-/**
- * Allowed values for query parameters.
- * `null` and `undefined` are treated as "do not include".
- */
-type QueryValue = string | number | boolean | null | undefined;
-
-/**
- * Safely add or replace query params on a path or full URL.
- *
- * - Preserves existing query params
- * - Works with relative paths ("/search?q=x") and full URLs ("https://example.com/search?q=x")
- * - Preserves `#hash` fragments
- *
- * @example
- * buildUrlWithParams("/search?q=superman", { cursor: "abc", limit: 20 });
- * // "/search?q=superman&cursor=abc&limit=20"
- *
- * @example
- * buildUrlWithParams("https://example.com/list?offset=10", { offset: 30 });
- * // "https://example.com/list?offset=30"
- */
-export function buildUrlWithParams(
- path: string,
- paramsToSet: Record,
-): string {
- // Split off hash fragment (if any) so we can re-attach it later
- const [beforeHash, hash = ""] = path.split("#", 2);
- // Split path and existing query string (if any)
- const [base, existingQuery = ""] = beforeHash.split("?", 2);
-
- const searchParams = new URLSearchParams(existingQuery);
-
- // Apply new/updated params
- for (const [key, value] of Object.entries(paramsToSet)) {
- if (value === null || value === undefined) {
- // Skip nullish values (could also choose to delete instead)
- continue;
- }
-
- let stringValue: string;
-
- if (typeof value === "boolean") {
- stringValue = value ? "true" : "false";
- } else {
- stringValue = String(value);
- }
-
- searchParams.set(key, stringValue);
- }
-
- const queryString = searchParams.toString();
- const hashPart = hash ? `#${hash}` : "";
-
- if (queryString.length === 0) {
- return `${base}${hashPart}`;
- }
-
- return `${base}?${queryString}${hashPart}`;
-}
-
-/**
- * Helper to build cursor-based pagination URLs.
- *
- * - Safely adds/replaces `cursor` and `limit`
- * - Preserves any existing query params
- *
- * @example
- * buildCursorUrl("/search?q=superman", "abc123", 20);
- * // "/search?q=superman&cursor=abc123&limit=20"
- */
-export function buildCursorUrl(
- path = "/",
- cursor: string,
- limit: number,
-): string {
- return buildUrlWithParams(path, {
- cursor,
- limit,
- });
-}
-
-/**
- * Helper to build offset-based pagination URLs.
- *
- * - Safely adds/replaces `offset` and `limit`
- * - Preserves any existing query params
- *
- * @example
- * buildOffsetUrl("/list?sort=desc", 40, 20);
- * // "/list?sort=desc&offset=40&limit=20"
- */
-export function buildOffsetUrl(
- path: string,
- offset: number,
- limit: number,
-): string {
- return buildUrlWithParams(path, {
- offset,
- limit,
- });
-}
-
-/**
- * Merge extra headers into any 3-tuple response.
- *
- * Preserves exact body and status types, merges headers via intersection.
- * Return type remains structurally compatible with ResponseResult members.
- */
-export function withHeaders<
- const T extends readonly [any, StatusCode, StandardHeaders],
- const E extends StandardHeaders
->(
- result: T,
- extra: E
-): readonly [T[0], T[1], T[2] & E] {
- const [body, status, headers] = result
- return [
- body,
- status,
- { ...headers, ...extra } as T[2] & E,
- ] as const
-}
-
-/**
- * Merge extra metadata into the response envelope’s `meta` field.
- *
- * - Preserves `data` and status as-is
- * - Deep-merges `meta` at the top level (shallow per key)
- * - Keeps the `timestamp` from `ok()` intact
- *
- * @example
- * const res = paginate(url, items, pagination)
- * const enriched = withMeta(res, {
- * query: {
- * durationMs,
- * filters,
- * sorts,
- * fields,
- * source: { backend: 'supabase', adapter: 'query' },
- * },
- * })
- * return c.json(...enriched)
- */
-export function withMeta<
- const T extends GenericSuccessResult,
- const M extends DataMetadata
->(
- result: T,
- extra: M
-): readonly [
- SuccessEnvelope,
- T[1],
- T[2]
-] {
- const [body, status, headers] = result
-
- const mergedMeta = Object.assign(
- {},
- body.meta ?? {},
- extra ?? {},
- )
-
- return [
- Object.assign(body, {
- data: body.data,
- meta: mergedMeta as T[0]['meta'] & M,
- }),
- status,
- headers,
- ] as const
-}
-
-/**
- * Check if response is success
- */
-export function isSuccessResponse(response: ResponseResult): response is SuccessResponse {
- return response[2]['Content-Type'] !== 'application/problem+json'
-}