/** * True when the error is a unique-constraint violation, across SQLite, * MySQL, and Postgres: * * - SQLite: `SQLITE_CONSTRAINT_UNIQUE` / `SQLITE_CONSTRAINT` * - MySQL: `errno: 1062` (ER_DUP_ENTRY) * - Postgres: `code: '23505'` (unique_violation) * - Generic fallback: message text match — covers wrapped errors from drivers * that lose the structured code. * * Lives here (cycle-free `@stacksjs/orm`) rather than in `@stacksjs/auth` * because every framework write path needs it: auto-CRUD routes, commerce/cms * write functions, and `@stacksjs/auth`'s `register()` (which re-exports this * via './rbac-store-bqb' for back-compat). `@stacksjs/database` is NOT a valid * home — its drivers statically import `@stacksjs/orm`, so orm routes importing * from database would create a package cycle. * * Exported for direct unit testing and for callers that map duplicates to * their own error (e.g. `register()`'s 409) instead of swallowing them. */ export declare function isUniqueViolation(err: unknown): boolean; /** * Classify a write-path error into an HTTP status + JSON body for the * auto-CRUD store/update handlers. Three branches, in priority order: * * 1. HttpError-like (an Error carrying an integer `status` in 400-599) — * preserve its status, message and optional `details`. Duck-typed rather * than `instanceof HttpError` so this helper stays inline-copyable into the * canonical generated routes file without importing @stacksjs/error-handling. * Covers the 400/413/422 throws from getRequestBody / validation. * 2. Unique-constraint violation — 409 with a clean `${Model} already exists` * message (NO raw driver text, which would leak column names in prod). * 3. Anything else — the unchanged 500 contract, including `detail: String(err)`. */ export declare function mapWriteError(err: unknown, modelName: string, op: 'create' | 'update'): { status: number, body: Record }; /** * Attribute names in model definitions may be camelCase; the migration * drivers (database/src/drivers/{sqlite,mysql,postgres}.ts) snake_case them * into column names. Write payload keys must be mapped the same way, LAST on * the write path — fillable filtering, validation, set-hooks and casts are * all keyed by attribute name. Output-identical to @stacksjs/strings * snakeCase for word-shaped attribute names (locked in by tests). */ export declare function toSnakeCase(s: string): string; /** Map every key of a write payload to its snake_case column spelling. */ export declare function toSnakeCaseKeys(data: Record): Record; /** Build the canonical auto-CRUD path while accepting a version prefix. */ export declare function apiBasePath(uri: string, prefix?: string): string; /** * Resolve the database column used for automatic team ownership. * * A belongsTo relation creates its foreign key during model-driven migration, * so Team-owned models do not need to repeat a synthetic teamId attribute just * to activate API isolation. */ export declare function teamOwnershipField(model: { attributes?: Record belongsTo?: unknown[] } | null | undefined): string | null; /** * Remove every client spelling of an ownership field, then apply the trusted * value resolved from the authenticated request. Array ownership is used for * resources owned through a parent relation, so the client must select one of * the allowed values in that case. */ export declare function stampOwnership(data: Record, field: string, value: unknown): { data: Record, error?: string }; /** * Resolve the model fields accepted by generated store/update routes. * * Declared fillable attributes remain the primary allowlist. A `belongsTo` * declaration also defines a real foreign-key column in model-driven * migrations, so its `Id` attribute is writable through `useApi` * without requiring a bespoke action for every relationship. No undeclared * body key is admitted, and hasMany/hasOne relations never contribute keys. */ export declare function getWritableFields(model: { attributes?: Record belongsTo?: unknown[] } | null | undefined): string[]; /** * Filter a request body down to fillable fields. Accepts BOTH the * attribute-name spelling and its snake_case column spelling on input, so * read-modify-write round-trips work (GET responses expose snake_case * columns). The result stays keyed by attribute name — setters, casts and * validation rules all look fields up by that spelling. */ export declare function filterFillable(body: any, fillableFields: string[]): Record; /** * Normalize JSON-safe values for validators whose in-process type cannot be * represented directly in a request body. `schema.date()` validates a Date * instance, while browser forms submit an ISO calendar date string. Keep the * stored write payload unchanged and normalize only the value passed to the * validator. */ export declare function normalizeValidationValue(rule: any, value: unknown): unknown; /** * Drop attribute keys flagged `hidden: true` from an incoming write body. * Must drop BOTH spellings — accepting the snake spelling in filterFillable * without this would let `payment_intent_id` sneak past a camelCase hidden * marker. */ export declare function dropHiddenInputs(data: Record, hiddenFields: string[]): Record; /** * Strip attribute keys flagged `hidden: true` from an outgoing response * record. Must drop BOTH spellings — DB rows come back keyed by snake_case * column names, so deleting only the attribute-name spelling lets a * camelCase hidden attribute (Transaction's `paymentDetails`) leak as * `payment_details` on public reads. Response-side mirror of * `dropHiddenInputs`. */ export declare function stripHidden(record: any, hiddenFields: string[]): any; /** * Build the read-path column allowlist for a model: a map from BOTH the * attribute-name spelling and its snake_case column spelling to the real * snake_case column. One map serves `?sort=` and `?=` filters. * * Why a map and not a set: attribute names may be camelCase * (`discountType`) while DB columns are always snake_case (the migration * drivers snake_case them — same contract as `toSnakeCaseKeys` on the * write path). A set keyed by attribute spelling let `?sort=discountType` * through to `orderBy('discountType')` (ghost column → 500) while * REJECTING the real column spelling `discount_type`. The map accepts * either spelling and always emits the column spelling. * * Hidden attributes are removed under BOTH spellings — sorting or * equality-filtering on a hidden column (`?two_factor_secret=x`) is a * blind-enumeration oracle even though the value never appears in the * response body. */ export declare function buildReadColumnMap(attributes: Record | null | undefined, hiddenFields: string[]): Map; /** * Apply a `?sort=` parameter to a query builder chain. Comma-separated * tokens, each optionally `-` prefixed for descending. Tokens are resolved * through the `columns` allowlist map (see `buildReadColumnMap`) so either * spelling of a declared, non-hidden attribute works and everything else — * unknown names, hidden attributes, non-word tokens — is silently skipped * (the existing contract, matching the filter loop). * * Examples: * ?sort=name → ORDER BY name ASC * ?sort=-rating → ORDER BY rating DESC * ?sort=discountType,name → ORDER BY discount_type ASC, name ASC */ export declare function applySorting(query: any, sortParam: string | null, columns: ReadonlyMap): any; declare function safeJSON(s: string): unknown; declare function safeJSONOrEmpty(_s: string): unknown; /** * Apply a model's `casts` to a record, in either direction: * - `'get'` — DB shape → JS-typed values (read responses) * - `'set'` — input → DB shape (write payloads) * * Casts are declared keyed by attribute name (possibly camelCase: * `instantBook: 'boolean'`) but DB rows come back keyed by snake_case * column names (`instant_book`) — so each cast is applied under BOTH * spellings, whichever is present. A record keyed by attribute names * (the write path) behaves exactly as before; a snake-keyed DB row (the * read path) now gets its casts instead of leaking raw SQLite `"1"`s. */ export declare function applyCasts(record: Record | null | undefined, casts: Record unknown, set: (v: unknown) => unknown }> | null | undefined, direction: 'get' | 'set'): any; export declare function validateWriteBody(data: Record, model: any, hook: 'creating' | 'updating'): WriteValidationResult; /** * A route path with every parameter name flattened to `{}`. * * The "user routes win" guard compared paths literally, so an app's own * `/api/sites/{siteId}` did not suppress the ORM's `/api/sites/{id}` — the two * strings differ, so BOTH were registered and the ORM copy carried none of the * app's authorization. The app had declared the endpoint and still got a second, * unguarded one it never wrote (stacksjs/stacks#2224). * * The parameter's NAME is the app's business. The shape is what decides whether * this URL is already claimed. */ export declare function routeShape(path: string): string; /** * Resolve middleware lists for a model's `useApi` trait value (which may be * `true` or `{ uri, routes, middleware }`). * * Secure-by-default on BOTH sides: with no declared `useApi.middleware`, read * and mutating routes alike get `auth`. * * #1949 gave the mutating routes that default and deliberately left reads * public, reasoning that catalog tables (products, posts) want anonymous * browsing. The cost of that default landed on models that are not catalogs: a * model opting into the trait without declaring middleware published * `GET /api/{uri}` and `GET /api/{uri}/{id}` to anyone. In one real app that * was `GET /api/users` returning the full customer list — only `password` was * `hidden`, so names and emails came back — and the app's own security tests * could not see it, because the route was never declared in its route files * (stacksjs/stacks#2224). * * A wrong "public" default is a data breach; a wrong "private" default is a 401 * on the first request in development. Only one of those is recoverable, so the * default is now `auth` and a public read is something an app asks for. * * Three declaration shapes, so asking is always possible: * * `middleware: ['auth']` both sides get the list (unchanged) * `middleware: []` both sides public — deliberate opt-out, * warned about at the call site * `middleware: { read, write }` per-side lists * * The split form exists because the secure default would otherwise make the * most common real shape — public catalog reads, authenticated writes — * inexpressible: a flat `middleware: []` is the only way to open reads, and it * opens writes at the same time. That is a worse trade than the bug being fixed, * so `{ read: [], write: ['auth'] }` says it exactly. */ export declare function resolveApiMiddleware(useApi: unknown): { read: string[], write: string[], declared: boolean }; /** * Resolve `?page=` / `?per_page=` for the index route into a clamped, * NaN-safe `{ page, perPage, offset }`. * * - `page` is clamped to `>= 1` (a `?page=0` / negative would otherwise * produce a negative OFFSET), defaulting to 1 on missing/NaN. * - `perPage` defaults to {@link INDEX_DEFAULT_PER_PAGE}, is clamped to * `>= 1`, and capped at {@link INDEX_MAX_PER_PAGE}. */ export declare function resolveIndexPageArgs(params: URLSearchParams): { page: number, perPage: number, offset: number }; /** * Build the index pagination `meta`. `hasMore` is the source of truth for * "is there a next page" (derived by the route from a `LIMIT perPage + 1` * probe fetch), so `next_page_url` stays consistent whether or not a total * was counted. When `total` is known, `last_page` uses the * `Math.max(1, ceil(total / perPage))` floor from the Paginator interface. */ export declare function buildIndexMeta(url: URL, page: number, perPage: number, rowCount: number, hasMore: boolean, total?: number): IndexPageMeta; /** * Flat Laravel paginator shape for the index response top level. Same values * as {@link buildIndexMeta} but keyed `current_page` (not `page`) so a * generated-endpoint list response deep-equals a `Model.paginate()` envelope. * The `page` -> `current_page` rename is the only delta; the value math lives * solely in `buildIndexMeta`. */ export declare function buildIndexPaginator(url: URL, page: number, perPage: number, rowCount: number, hasMore: boolean, total?: number): IndexPaginator; /** * Columns every auto-CRUD table carries regardless of declared attributes. * Members of the read allowlist (sort/filter) alongside the model's own * attribute names. * @defaultValue `['id', 'uuid', 'created_at', 'updated_at', 'deleted_at']` */ export declare const SYSTEM_COLUMNS: string[]; /** * Built-in cast resolvers — kept in sync with @stacksjs/orm/define-model. * A duplicate here is the simplest way to keep auto-CRUD parity with the * model-driven path without introducing a circular import. * @defaultValue * ```ts * { * string: { * get: (v) => ReturnType | null, * set: (v) => ReturnType | null * }, * number: { * get: (v) => ReturnType | null, * set: (v) => ReturnType | null * }, * integer: { * get: (v) => ReturnType | null, * set: (v) => ReturnType | null * }, * float: { * get: (v) => ReturnType | null, * set: (v) => ReturnType | null * }, * boolean: { get: (v) => boolean, set: (v) => number }, * json: { * get: (v) => null | ReturnType | unknown, * set: (v) => null | unknown | ReturnType * }, * datetime: { get: (v) => Date | null, set: (v) => unknown }, * date: { get: (v) => Date | null, set: (v) => unknown }, * array: { * get: (v) => never[] | unknown | ReturnType | never[], * set: (v) => null | ReturnType | unknown * } * } * ``` */ export declare const AUTO_CRUD_CASTERS: Record unknown, set: (v: unknown) => unknown }>; // Default page size for the auto-CRUD index route. Matches the // request-aware Model.paginate() / resolvePageArgs default (15) so the // REST list endpoint and the in-process paginator agree out of the box. export declare const INDEX_DEFAULT_PER_PAGE: 15; // Upper bound on ?per_page= so a single request can't ask for an // unbounded page and exhaust memory. export declare const INDEX_MAX_PER_PAGE: 100; /** * Pagination `meta` for the auto-CRUD index envelope (`{ data, meta }`). * * Always carries `page` / `per_page` / `from` / `to` / `has_more_pages` * plus `prev_page_url` / `next_page_url`. `total` / `last_page` and the * `first_page_url` / `last_page_url` are added only when a total is known * (`?with_count=true`). */ export declare interface IndexPageMeta { page: number per_page: number from: number | null to: number | null has_more_pages: boolean prev_page_url: string | null next_page_url: string | null total?: number last_page?: number first_page_url?: string last_page_url?: string } /** * Flat Laravel paginator shape lifted to the index response top level. * Mirrors {@link IndexPageMeta} minus `data`/`path` (the route spreads this * alongside its own `data`), but keys the current page as `current_page` * instead of `page` so a generated-endpoint list response deep-equals a * `Model.paginate()` envelope. `total` / `last_page` / `first_page_url` / * `last_page_url` stay gated on `total` (`?with_count=true`), matching * {@link SimplePaginator} when absent. */ export declare interface IndexPaginator { current_page: number per_page: number from: number | null to: number | null has_more_pages: boolean prev_page_url: string | null next_page_url: string | null total?: number last_page?: number first_page_url?: string last_page_url?: string } /** * Run each declared `validation.rule` against a write payload. * * Returns `{ valid: true }` or `{ valid: false, errors }`. Per-attribute custom * messages from `validation.message` override the rule's default text. * * Fields the caller never sent are skipped on the `updating` hook, so a partial * update does not trip a `required` rule on a sibling field it never touched. * * Lives here rather than in `../routes.ts` so BOTH write paths can reach it. * It used to be a local function in that module, which meant the declared rules * ran on the generated REST routes and nowhere else: `Model.create()`, * `.update()` and `.save()` went straight to the driver, and an over-length * value first got noticed by Postgres as a 22001, surfacing as a 500 on * whichever endpoint performed the write (stacksjs/stacks#2233). Importing it * from `routes.ts` was not an option — that module registers routes on import. */ export type WriteValidationResult = | { valid: true } | { valid: false, errors: Record }