# Query Implementation

## Generated Query Shells

Run `erp-kit module generate code -p <path>` to generate query shells from `docs/queries/*.md`. Each generated shell wraps your `run` function with `defineQuery`.

### Generated shell example

```typescript
// @generated — do not edit
import { run } from "./getItem";
import { defineQuery } from "@tailor-platform/erp-kit/module";

export const getItem = defineQuery(run);
```

### Hand-written implementation

```typescript
import type { DB } from "../generated/kysely-tailordb";
import { ok, type ReadonlyDB } from "@tailor-platform/erp-kit/module";

export type GetItemInput = { id: string } | { sku: string };

export async function run(db: ReadonlyDB<DB>, input: GetItemInput) {
  let query = db.selectFrom("Item").selectAll();

  if ("id" in input) {
    query = query.where("id", "=", input.id);
  } else {
    query = query.where("sku", "=", input.sku);
  }

  const item = await query.executeTakeFirst();
  return ok({ item: item ?? null });
}
```

## Custom Queries

For queries beyond simple lookups (joins, aggregations, complex filters), write them by hand in `query/*.ts`. List queries must use pagination — see [Paginated List Queries](#paginated-list-queries) for the full pattern.

## Conventions

- `defineQuery` wraps all queries via generated shells
- `ReadonlyDB<DB>` ensures read-only access
- All queries return Result types using `ok()` / `err()` from `@tailor-platform/erp-kit/module` — consistent with commands
- Get queries return `ok({ entity: T | null })` using `executeTakeFirst()`
- List queries return `ok(PaginatedResult<T>)` using pagination utilities — see [Paginated List Queries](#paginated-list-queries)
- Input types: `type` for union inputs (get), `interface` for single-shape inputs (list)
- `.generated.ts` files are always overwritten — never edit them

## Paginated List Queries

All list queries use offset/limit pagination via shared utilities. This is the standard for ERP module-level APIs.

### Types

```typescript
// PaginationInput accepts a generic TOrderBy for per-query sortable fields.
// "id" is always available as a sort field.
interface PaginationInput<TOrderBy extends string = never> {
  limit?: number; // default: 20 (DEFAULT_PAGE_SIZE)
  offset?: number; // default: 0
  orderBy?: "id" | TOrderBy; // default: "id"
  orderDirection?: "asc" | "desc"; // default: "asc"
}

interface PaginatedResult<T> {
  items: T[];
  total?: number; // optional — caller opts in to COUNT(*) overhead
  hasNextPage: boolean;
}
```

### Pattern

1. Extend `PaginationInput<T>` with query-specific sortable fields
2. Apply `.orderBy()`, `.limit(limit + 1)`, `.offset()` to the Kysely query
3. Use `buildPaginatedResult(rows, limit)` to build the response — the limit+1 strategy determines `hasNextPage` without a separate count query

```typescript
import type { DB } from "../generated/kysely-tailordb";
import {
  ok,
  type ReadonlyDB,
  type PaginationInput,
  buildPaginatedResult,
  DEFAULT_PAGE_SIZE,
} from "@tailor-platform/erp-kit/module";

// Define sortable fields for this query
type UnitOrderByField = "name" | "symbol" | "createdAt";

export interface ListUnitsByCategoryInput extends PaginationInput<UnitOrderByField> {
  categoryId: string;
}

export async function run(db: ReadonlyDB<DB>, input: ListUnitsByCategoryInput) {
  const limit = input.limit ?? DEFAULT_PAGE_SIZE;
  const offset = input.offset ?? 0;
  const orderBy = input.orderBy ?? "id";
  const orderDirection = input.orderDirection ?? "asc";

  const units = await db
    .selectFrom("Unit")
    .selectAll()
    .where("categoryId", "=", input.categoryId)
    .orderBy(orderBy, orderDirection)
    .limit(limit + 1)
    .offset(offset)
    .execute();

  return ok(buildPaginatedResult(units, limit));
}
```

### Rules

- Every list query must extend `PaginationInput` — no unbounded result sets
- Define a `TOrderBy` type alias with the query's sortable columns (exclude `id` — it's always included)
- Default sort is `"id" asc` — deterministic ordering is required for stable pagination
- Use `limit + 1` fetch strategy — never issue a separate `COUNT(*)` unless the caller explicitly needs `total`
- Queries with no custom sort fields use plain `PaginationInput` (only `"id"` is sortable)

## Status-Aware Query Rules

Models with a status lifecycle (Stateful models with `status` enum, or simple models with `isActive` bool) follow specific naming and filtering conventions. The core principle: **if a status filter is a business use case, put it in the query name. If it's a search parameter, put it in the input.**

### Naming Convention

| Query type                     | Pattern                              | Returns                                     |
| ------------------------------ | ------------------------------------ | ------------------------------------------- |
| **Get (single lookup)**        | `get{Entity}`                        | Any status — caller already has a reference |
| **List (status-filtered)**     | `list{Status}{Entities}`             | Only records matching the named status      |
| **List (unfiltered)**          | `list{Entities}`                     | All records, all statuses                   |
| **Search (admin/exploratory)** | `search{Entities}({ status?, ... })` | Ad-hoc filtering via input params           |

### Examples

```typescript
// Business use case — status baked into the name
// List queries use pagination (see Paginated List Queries section)
export interface ListActiveItemsByCategoryInput extends PaginationInput {
  categoryId: string;
}

export async function run(db: ReadonlyDB<DB>, input: ListActiveItemsByCategoryInput) {
  const limit = input.limit ?? DEFAULT_PAGE_SIZE;
  const offset = input.offset ?? 0;

  const items = await db
    .selectFrom("Item")
    .selectAll()
    .where("categoryId", "=", input.categoryId)
    .where("status", "=", "ACTIVE")
    .orderBy(input.orderBy ?? "id", input.orderDirection ?? "asc")
    .limit(limit + 1)
    .offset(offset)
    .execute();
  return ok(buildPaginatedResult(items, limit));
}

// Single lookup — status-unaware, returns any status (no pagination)
export async function run(db: ReadonlyDB<DB>, input: GetItemInput) {
  const item = await db
    .selectFrom("Item")
    .selectAll()
    .where("id", "=", input.id)
    .executeTakeFirst();
  return ok({ item: item ?? null });
}

// Admin/exploratory — parametric status filter with pagination
export interface SearchItemsInput extends PaginationInput {
  status?: string;
  categoryId?: string;
}

export async function run(db: ReadonlyDB<DB>, input: SearchItemsInput) {
  const limit = input.limit ?? DEFAULT_PAGE_SIZE;
  const offset = input.offset ?? 0;

  let query = db.selectFrom("Item").selectAll();
  if (input.status) {
    query = query.where("status", "=", input.status);
  }
  if (input.categoryId) {
    query = query.where("categoryId", "=", input.categoryId);
  }
  const items = await query
    .orderBy(input.orderBy ?? "id", input.orderDirection ?? "asc")
    .limit(limit + 1)
    .offset(offset)
    .execute();
  return ok(buildPaginatedResult(items, limit));
}
```

### `isActive` (bool) Models

The same naming rules apply. For models using `isActive` instead of a `status` enum (e.g., Currency, Unit, UomCategory):

```typescript
// Business use case — active units only
// listActiveUnitsByCategory
export interface ListActiveUnitsByCategoryInput extends PaginationInput {
  categoryId: string;
}

export async function run(db: ReadonlyDB<DB>, input: ListActiveUnitsByCategoryInput) {
  const limit = input.limit ?? DEFAULT_PAGE_SIZE;
  const offset = input.offset ?? 0;

  const units = await db
    .selectFrom("Unit")
    .selectAll()
    .where("categoryId", "=", input.categoryId)
    .where("isActive", "=", true)
    .orderBy(input.orderBy ?? "id", input.orderDirection ?? "asc")
    .limit(limit + 1)
    .offset(offset)
    .execute();
  return ok(buildPaginatedResult(units, limit));
}
```

### Anti-patterns

- **Silent defaults**: `listItems()` returning only ACTIVE without the name saying so — hidden behavior violates the principle of least surprise
- **"listAll" prefix**: `listAllItems()` implies the unfiltered variant is the exception — use `listItems()` for unfiltered instead
- **Fat queries with many optional filters for business use cases**: prefer purpose-built queries with intent in the name over swiss-army-knife queries

### Status preconditions in commands

Status checks that guard mutations (e.g., "only DRAFT items can be deleted") belong in commands, not queries. See [CQRS command-side read rule](commands.md#command-side-reads-cqrs-separation).

## See Also

- [CQRS command-side read rule](commands.md#command-side-reads-cqrs-separation) — queries are read-side only; commands inline own reads
