{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table-drizzle",
  "title": "Data Table Drizzle ORM Helpers",
  "description": "Server-side filtering, faceted search, cursor pagination, and sorting helpers for Drizzle ORM.",
  "dependencies": ["drizzle-orm@^0.45.2", "date-fns@^4.1.0"],
  "registryDependencies": [
    "https://data-table.openstatus.dev/r/data-table.json",
    "https://data-table.openstatus.dev/r/data-table-schema.json"
  ],
  "files": [
    {
      "path": "src/lib/drizzle/index.ts",
      "content": "export { buildWhereConditions } from \"./filters\";\nexport { computeFacets } from \"./facets\";\nexport { buildOrderBy } from \"./sorting\";\nexport { buildCursorPagination } from \"./pagination\";\nexport { evaluateIntervalMs } from \"./interval\";\nexport { createDrizzleHandler } from \"./handler\";\nexport type { DrizzleHandlerConfig, DrizzleHandlerResult } from \"./handler\";\nexport type {\n  ColumnMapping,\n  DrizzleDB,\n  DrizzleQueryScope,\n  SortDescriptor,\n  CursorPaginationParams,\n} from \"./types\";\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/drizzle/types.ts",
      "content": "import type { Column, SQL } from \"drizzle-orm\";\nimport type { PgDatabase, PgTable } from \"drizzle-orm/pg-core\";\n\n/**\n * Maps tableSchema field keys to Drizzle table columns.\n * This is the bridge between your table UI schema and your database.\n *\n * @example\n * const columnMapping = {\n *   level: logs.level,\n *   date: logs.date,\n *   latency: logs.latency,\n *   \"timing.dns\": logs.timingDns,\n * } satisfies ColumnMapping;\n */\nexport type ColumnMapping = Record<string, Column>;\n\nexport type DrizzleDB = PgDatabase<any, any, any>;\n\n/** Sort descriptor matching the URL state shape. */\nexport type SortDescriptor = { id: string; desc: boolean } | null;\n\n/** Cursor-based pagination params. */\nexport type CursorPaginationParams = {\n  cursor: Date | number | null;\n  direction: \"prev\" | \"next\";\n  size: number;\n  cursorColumn: Column;\n  /**\n   * Unique column ordered last, so rows sharing a cursor value have one\n   * defined order instead of whatever the plan happens to produce.\n   */\n  tiebreakColumn?: Column;\n};\n\n/**\n * Everything a caller needs to write its own aggregate SQL against the same\n * filtered set the handler queried.\n *\n * The three things such a caller actually needs — the resolved time range, the\n * bucket size, and the composed WHERE — are all computed inside the handler\n * already. Handing back only `SQL[]` meant every caller re-derived them.\n */\nexport type DrizzleQueryScope = {\n  readonly db: DrizzleDB;\n  readonly table: PgTable;\n  readonly columns: ColumnMapping;\n  /** The fully filtered set. */\n  readonly where: SQL | undefined;\n  /** The set slider bounds are computed over (date + non-slider filters). */\n  readonly whereWithoutSliders: SQL | undefined;\n  /** Resolved from an explicit date filter, else from MIN/MAX of the set. */\n  readonly range: { from: Date; to: Date } | null;\n  /** Bucket size for `range`, with the interval ladder already applied. */\n  readonly bucketMs: number;\n};\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/drizzle/handler.ts",
      "content": "import type { FacetMetadataSchema } from \"@/lib/data-table/types\";\nimport type { Filters } from \"@/lib/filters\";\nimport { and, count, eq, sql, type Column, type SQL } from \"drizzle-orm\";\nimport {\n  getTableConfig,\n  type SelectedFields as PgSelectedFields,\n  type PgTable,\n} from \"drizzle-orm/pg-core\";\nimport { computeFacets } from \"./facets\";\nimport { buildWhereConditions } from \"./filters\";\nimport { evaluateIntervalMs } from \"./interval\";\nimport { buildCursorPagination } from \"./pagination\";\nimport { buildOrderBy } from \"./sorting\";\nimport type {\n  ColumnMapping,\n  DrizzleDB,\n  DrizzleQueryScope,\n  SortDescriptor,\n} from \"./types\";\n\n/**\n * Derive slider, facet, and date keys from declared filter semantics.\n *\n * These are pass groupings for the three-pass strategy, not semantics — the\n * semantics live in `Filters` and are the same everywhere.\n */\nfunction deriveKeys(filters: Filters) {\n  const sliderKeys: string[] = [];\n  const facetKeys: string[] = [];\n  const dateKeys: string[] = [];\n\n  for (const { key, type } of filters.specs) {\n    if (type === \"slider\") {\n      sliderKeys.push(key);\n      facetKeys.push(key);\n    }\n    if (type === \"checkbox\") facetKeys.push(key);\n    if (type === \"input\") facetKeys.push(key);\n    if (type === \"timerange\") dateKeys.push(key);\n  }\n\n  return { sliderKeys, facetKeys, dateKeys };\n}\n\n/**\n * The date range the user asked for, if any.\n *\n * Read through `filters.plan` rather than off the raw search values, so the\n * single-date and reversed-bounds cases are normalized the same way they are\n * for the WHERE clause.\n */\nfunction resolveExplicitRange(\n  filters: Filters,\n  search: Record<string, unknown>,\n  dateKeys: string[],\n): { from: Date; to: Date } | null {\n  for (const op of filters.plan(search, { only: dateKeys })) {\n    if (op.op === \"dateRange\") return { from: op.from, to: op.to };\n  }\n  return null;\n}\n\n/** MIN/MAX over the filtered set, for when no explicit range was given. */\nasync function discoverRange(\n  db: DrizzleDB,\n  table: PgTable,\n  column: Column,\n  where: SQL | undefined,\n): Promise<{ from: Date; to: Date } | null> {\n  const result = await db\n    .select({\n      from: sql<Date | null>`MIN(${column})`,\n      to: sql<Date | null>`MAX(${column})`,\n    })\n    .from(table)\n    .where(where);\n\n  const row = result[0];\n  if (!row?.from || !row?.to) return null;\n  const from = new Date(row.from);\n  const to = new Date(row.to);\n  if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return null;\n  return { from, to };\n}\n\n/**\n * One config shape.\n *\n * The old two-shape union existed because `tableSchema` lives in a\n * `\"use client\"` file and could not be imported on the server, so the server\n * had to be handed three untyped `string[]`s instead. `defineFilters` accepts\n * `SchemaJSON`, so the declaration now crosses that boundary as data and one\n * field carries the semantics.\n */\nexport type DrizzleHandlerConfig = {\n  db: DrizzleDB;\n  table: PgTable;\n  /** Built with `defineFilters(tableSchema.definition | schemaJson | specs)`. */\n  filters: Filters;\n  columnMapping: ColumnMapping;\n  /**\n   * The schema key rows are paged by. A `Date` or numeric column, since the\n   * cursor is serialized as a number.\n   *\n   * `scope.range` and `scope.bucketMs` are read off THIS column when no\n   * explicit date filter is set, so a non-time cursor (an auto-increment id,\n   * say) still paginates correctly but hands aggregate callers a range built\n   * from `new Date(id)`. Page by your time column unless you have no use for\n   * `scope.range`.\n   */\n  cursorColumn: string;\n  /**\n   * The schema key rows are ordered by last, to break ties on the cursor\n   * column. Must be unique — the primary key, normally, which is what this\n   * defaults to when the table declares a single-column one.\n   *\n   * Without it `ORDER BY date DESC` leaves rows sharing a timestamp in\n   * whatever order the plan produced, and that order is not stable: updating a\n   * row moves it in the heap, and a different plan reads it back differently.\n   * The page a row lands on stays correct either way — the boundary logic\n   * below refuses to split a tied group — but the rows visibly shuffle within\n   * their group between refetches.\n   *\n   * Set explicitly for a table with a composite primary key, or none.\n   */\n  tiebreakColumn?: string;\n  defaultSize?: number;\n  /**\n   * Extra projected fields merged into each row — columns that are never\n   * filtered or sorted, joins, computed SQL.\n   *\n   * `columnMapping` already declares the schema-key ↔ Drizzle-column\n   * relationship, so it doubles as the projection. Anything the wire contract\n   * needs but the filters do not goes here.\n   */\n  select?: Record<string, Column | SQL.Aliased>;\n};\n\nexport type DrizzleHandlerResult<TRow = Record<string, unknown>> = {\n  /** Keyed by SCHEMA keys — `\"timing.dns\"`, never `timingDns`. */\n  data: TRow[];\n  facets: Record<string, FacetMetadataSchema>;\n  totalRowCount: number;\n  filterRowCount: number;\n  nextCursor: number | null;\n  prevCursor: number | null;\n  /**\n   * One typed handle for callers writing their own aggregate SQL.\n   *\n   * Replaces `allConditions: SQL[]`, which leaked just enough to make callers\n   * re-derive the range, the bucket size, and the WHERE composition by hand —\n   * with `db: any` at the boundary and no test at any level.\n   */\n  scope: DrizzleQueryScope;\n};\n\n/**\n * The table's primary key, when it is a single column — the default tiebreak.\n *\n * A composite key would need every one of its columns to be a valid tiebreak,\n * and one of them alone is not unique, so those tables get `undefined` and\n * have to name a `tiebreakColumn` themselves.\n */\nfunction singleColumnPrimaryKey(table: PgTable): Column | undefined {\n  const config = getTableConfig(table);\n  const inline = config.columns.filter((column) => column.primary);\n  if (inline.length === 1) return inline[0];\n  if (config.primaryKeys.length === 1) {\n    const columns = config.primaryKeys[0]?.columns;\n    if (columns?.length === 1) return columns[0];\n  }\n  return undefined;\n}\n\n/**\n * Create a high-level query handler that encapsulates the three-pass filtering\n * strategy, faceted search, counts, and cursor pagination.\n *\n * Chart data and percentiles are intentionally excluded — handle them in user-land.\n *\n * @example\n * ```ts\n * // `tableSchema` is importable (not a \"use client\" file)\n * const handler = createDrizzleHandler({\n *   db,\n *   table: logs,\n *   filters: defineFilters(tableSchema.definition),\n *   columnMapping,\n *   cursorColumn: \"date\",\n * });\n *\n * // `tableSchema` is \"use client\" — the declaration crosses as data\n * const handler = createDrizzleHandler({\n *   db,\n *   table: logs,\n *   filters: defineFilters(schemaJson),\n *   columnMapping,\n *   cursorColumn: \"date\",\n * });\n *\n * const result = await handler.execute(search);\n * ```\n */\nexport function createDrizzleHandler(config: DrizzleHandlerConfig) {\n  const {\n    db,\n    table,\n    filters,\n    columnMapping,\n    cursorColumn,\n    tiebreakColumn,\n    defaultSize = 40,\n    select: extraSelect,\n  } = config;\n  const { sliderKeys, facetKeys, dateKeys } = deriveKeys(filters);\n\n  const cursorCol = columnMapping[cursorColumn];\n  if (!cursorCol) {\n    throw new Error(\n      `cursorColumn \"${cursorColumn}\" not found in columnMapping`,\n    );\n  }\n\n  let tiebreakCol: Column | undefined;\n  if (tiebreakColumn !== undefined) {\n    tiebreakCol = columnMapping[tiebreakColumn];\n    if (!tiebreakCol) {\n      throw new Error(\n        `tiebreakColumn \"${tiebreakColumn}\" not found in columnMapping`,\n      );\n    }\n  } else {\n    tiebreakCol = singleColumnPrimaryKey(table);\n  }\n\n  // Fail loudly at construction, not silently at query time.\n  //\n  // `buildWhereConditions` and `buildOrderBy` both skip a key that is missing\n  // from the mapping. `ColumnMapping` is `Record<string, Column>`, so a typo\n  // was not a type error either — the filter just stopped filtering, with no\n  // error, no warning, and no failing test.\n  const unmapped = filters.specs\n    .map((spec) => spec.key)\n    .filter((key) => !columnMapping[key]);\n  if (unmapped.length > 0) {\n    throw new Error(\n      `[createDrizzleHandler] These filterable columns are missing from columnMapping:\\n` +\n        unmapped.map((key) => `  - ${key}`).join(\"\\n\") +\n        `\\n\\n  Fix: add them, e.g.\\n` +\n        `  columnMapping: {\\n` +\n        unmapped\n          .map(\n            (key) =>\n              `    ${JSON.stringify(key)}: table.${key.replace(/[.\\-_](\\w)/g, (_, c) => c.toUpperCase())},`,\n          )\n          .join(\"\\n\") +\n        `\\n  }`,\n    );\n  }\n\n  // Rows come back keyed by SCHEMA keys because the projection IS the mapping.\n  // Callers used to hand-write the inverse of this — twice, and the two copies\n  // had already drifted.\n  // `ColumnMapping` is driver-agnostic (`Column`) by design, while `db.select`\n  // wants pg's narrower `SelectedFields`. Every value here is a real pg column\n  // or aliased SQL; narrowing `ColumnMapping` itself would make the public type\n  // pg-specific for every consumer.\n  const projection = {\n    ...columnMapping,\n    ...(extraSelect ?? {}),\n  } as PgSelectedFields;\n\n  return {\n    /** Derived keys (exposed for advanced use cases) */\n    sliderKeys,\n    facetKeys,\n    dateKeys,\n\n    async execute(\n      search: Record<string, unknown>,\n    ): Promise<DrizzleHandlerResult> {\n      const size = typeof search.size === \"number\" ? search.size : defaultSize;\n      const sort = (search.sort as SortDescriptor) ?? null;\n      const cursor = (search.cursor as Date | number | null) ?? null;\n      const direction = (search.direction as \"prev\" | \"next\") ?? \"next\";\n\n      // --- Three-pass filtering strategy ---\n\n      // Pass 1: Date range conditions only\n      const dateConditions = buildWhereConditions(\n        filters,\n        search,\n        columnMapping,\n        {\n          only: dateKeys,\n        },\n      );\n\n      // Pass 2: Date + non-slider filters (for slider facet bounds)\n      const nonSliderConditions = buildWhereConditions(\n        filters,\n        search,\n        columnMapping,\n        { exclude: [...sliderKeys, ...dateKeys] },\n      );\n      const pass2Conditions = [...dateConditions, ...nonSliderConditions];\n\n      // Pass 3: All conditions including sliders\n      const sliderConditions = buildWhereConditions(\n        filters,\n        search,\n        columnMapping,\n        { only: sliderKeys },\n      );\n      const allConditions = [...pass2Conditions, ...sliderConditions];\n\n      const allWhereForScope =\n        allConditions.length > 0 ? and(...allConditions) : undefined;\n      const pass2Where =\n        pass2Conditions.length > 0 ? and(...pass2Conditions) : undefined;\n\n      // Resolve the time range once, here, rather than leaving every caller\n      // writing custom aggregate SQL to rediscover it. An explicit date filter\n      // wins; otherwise it comes from MIN/MAX over the filtered set.\n      const explicitRange = resolveExplicitRange(filters, search, dateKeys);\n\n      // --- Facets + range (parallel) ---\n      const [sliderFacets, otherFacets, discoveredRange] = await Promise.all([\n        computeFacets(db, table, columnMapping, pass2Conditions, sliderKeys, {\n          sliderKeys,\n        }),\n        computeFacets(\n          db,\n          table,\n          columnMapping,\n          allConditions,\n          facetKeys.filter((k) => !sliderKeys.includes(k)),\n        ),\n        // Skipped entirely when the range is already known, so the common\n        // \"user picked a date range\" path costs nothing extra.\n        explicitRange\n          ? Promise.resolve(null)\n          : discoverRange(db, table, cursorCol, allWhereForScope),\n      ]);\n\n      const range = explicitRange ?? discoveredRange;\n\n      const facets = { ...sliderFacets, ...otherFacets };\n\n      // --- Counts (parallel) ---\n      const allWhere = allWhereForScope;\n\n      const [totalResult, filterResult] = await Promise.all([\n        db.select({ total: count() }).from(table),\n        db.select({ total: count() }).from(table).where(allWhere),\n      ]);\n\n      const totalRowCount = totalResult[0]?.total ?? 0;\n      const filterRowCount = filterResult[0]?.total ?? 0;\n\n      // --- Sort + Cursor Pagination ---\n      const orderBy = buildOrderBy(columnMapping, sort);\n\n      const {\n        cursorCondition,\n        orderBy: cursorOrderBy,\n        tiebreakOrderBy,\n        needsReverse,\n      } = buildCursorPagination({\n        cursor,\n        direction,\n        size,\n        cursorColumn: cursorCol,\n        tiebreakColumn: tiebreakCol,\n      });\n\n      const dataConditions = cursorCondition\n        ? [...allConditions, cursorCondition]\n        : allConditions;\n\n      const dataWhere =\n        dataConditions.length > 0 ? and(...dataConditions) : undefined;\n\n      // Cursor first, then the caller's sort, then the tiebreak — the last\n      // one is what makes the order total, so nothing may follow it.\n      const orderClauses = sql.join(\n        [cursorOrderBy, orderBy, tiebreakOrderBy].filter(\n          (clause): clause is SQL => clause !== undefined,\n        ),\n        sql`, `,\n      );\n\n      // One extra row reveals whether the page boundary splits a group of\n      // rows sharing the same cursor value.\n      const rows = await db\n        .select(projection)\n        .from(table)\n        .where(dataWhere)\n        .orderBy(orderClauses)\n        .limit(size + 1);\n\n      // Read by SCHEMA key. This used to read `row[cursorCol.name]` — the SQL\n      // column name. They coincide for `date`; for any cursor column whose DB\n      // name differs from its key (`timing_dns` vs `\"timing.dns\"`) the lookup\n      // was `undefined`, so `boundaryValue` was null, the whole tie-snapping\n      // block was skipped, and `nextCursor` came back null — silently ending\n      // pagination after one page.\n      const getCursorValue = (row: Record<string, unknown>): number | null => {\n        if (!row) return null;\n        const val = row[cursorColumn];\n        if (val instanceof Date) return val.getTime();\n        if (typeof val === \"number\") return val;\n        return null;\n      };\n\n      // --- Page boundary must fall between cursor values ---\n      //\n      // The next page is fetched with a strict `<` (or `>`) predicate on the\n      // cursor column, so any row sharing the boundary value that did not fit\n      // on this page would never be returned by any page. Rather than split a\n      // group of tied rows, end the page before it and let the group start the\n      // next one.\n      let page = rows;\n\n      const boundaryValue =\n        rows.length > size ? getCursorValue(rows[size]) : null;\n\n      // A cursor column that yields neither a Date nor a number cannot be\n      // serialized into a cursor at all, so leave those tables untouched.\n      if (boundaryValue !== null) {\n        const overflowRow = rows[size];\n\n        page = rows.slice(0, size);\n        while (\n          page.length > 0 &&\n          getCursorValue(page[page.length - 1]) === boundaryValue\n        ) {\n          page.pop();\n        }\n\n        // Degenerate case: a single cursor value spans the whole page, so\n        // there is no boundary to retreat to. Return the entire tied group —\n        // overflowing `size` is the only way to make progress without\n        // dropping rows.\n        if (page.length === 0) {\n          page = await db\n            .select(projection)\n            .from(table)\n            .where(\n              and(...dataConditions, eq(cursorCol, overflowRow[cursorColumn])),\n            )\n            .orderBy(orderClauses);\n        }\n      }\n\n      if (needsReverse) {\n        page.reverse();\n      }\n\n      // --- Cursors ---\n      const lastRow = page[page.length - 1];\n      const firstRow = page[0];\n\n      const nextCursor = lastRow ? getCursorValue(lastRow) : null;\n      const prevCursor = firstRow\n        ? getCursorValue(firstRow)\n        : new Date().getTime();\n\n      const scope: DrizzleQueryScope = {\n        db,\n        table,\n        columns: columnMapping,\n        where: allWhereForScope,\n        whereWithoutSliders: pass2Where,\n        range,\n        bucketMs: evaluateIntervalMs(\n          range ? range.to.getTime() - range.from.getTime() : 0,\n        ),\n      };\n\n      return {\n        data: page,\n        facets,\n        totalRowCount,\n        filterRowCount,\n        nextCursor,\n        prevCursor,\n        scope,\n      };\n    },\n  };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/drizzle/facets.ts",
      "content": "import type { FacetMetadataSchema } from \"@/lib/data-table/types\";\nimport { and, count, max, min, sql, type SQL } from \"drizzle-orm\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\nimport type { ColumnMapping, DrizzleDB } from \"./types\";\n\n/**\n * Compute faceted counts for filter fields via SQL.\n *\n * For each key:\n * - Slider fields: SELECT MIN(col), MAX(col), COUNT(*)\n * - Array columns: unnest + GROUP BY for unique values\n * - Standard columns: GROUP BY for unique values with counts\n *\n * All facet queries run in parallel via Promise.all.\n */\nexport async function computeFacets(\n  db: DrizzleDB,\n  table: PgTable,\n  mapping: ColumnMapping,\n  baseConditions: SQL[],\n  facetKeys: string[],\n  options?: {\n    /** Keys that should return min/max instead of grouped values */\n    sliderKeys?: string[];\n  },\n): Promise<Record<string, FacetMetadataSchema>> {\n  const whereCondition =\n    baseConditions.length > 0 ? and(...baseConditions) : undefined;\n  const sliderKeys = options?.sliderKeys ?? [];\n\n  const queries = facetKeys.map(async (key) => {\n    const column = mapping[key];\n    if (!column) return [key, null] as const;\n\n    if (sliderKeys.includes(key)) {\n      const result = await db\n        .select({\n          min: min(column),\n          max: max(column),\n          total: count(),\n        })\n        .from(table)\n        .where(whereCondition);\n\n      const row = result[0];\n\n      return [\n        key,\n        {\n          rows: [],\n          total: Number(row?.total ?? 0),\n          min: row?.min != null ? Number(row.min) : undefined,\n          max: row?.max != null ? Number(row.max) : undefined,\n        } satisfies FacetMetadataSchema,\n      ] as const;\n    }\n\n    // Array columns: unnest + GROUP BY\n    if (column.dataType === \"array\") {\n      const raw = await db.execute(\n        sql`SELECT val as value, COUNT(*)::int as total\n            FROM (\n              SELECT unnest(${column}) as val\n              FROM ${table}\n              ${whereCondition ? sql`WHERE ${whereCondition}` : sql``}\n            ) sub\n            GROUP BY val\n            ORDER BY total DESC`,\n      );\n      const result: { value: string; total: number }[] = Array.isArray(raw)\n        ? raw\n        : (raw as { rows: { value: string; total: number }[] }).rows;\n\n      const total = result.reduce((sum, r) => sum + Number(r.total), 0);\n\n      return [\n        key,\n        {\n          rows: result.map((r) => ({\n            value: r.value,\n            total: Number(r.total),\n          })),\n          total,\n        } satisfies FacetMetadataSchema,\n      ] as const;\n    }\n\n    // Standard column: GROUP BY\n    const raw = await db.execute(\n      sql`SELECT ${column} as value, COUNT(*)::int as total\n          FROM ${table}\n          ${whereCondition ? sql`WHERE ${whereCondition}` : sql``}\n          GROUP BY ${column}\n          ORDER BY total DESC`,\n    );\n    const result: { value: string | number | boolean; total: number }[] =\n      Array.isArray(raw)\n        ? raw\n        : (\n            raw as {\n              rows: { value: string | number | boolean; total: number }[];\n            }\n          ).rows;\n\n    const total = result.reduce((sum, r) => sum + Number(r.total), 0);\n\n    let minVal: number | undefined;\n    let maxVal: number | undefined;\n    if (result.length > 0 && typeof result[0].value === \"number\") {\n      minVal = Math.min(...result.map((r) => Number(r.value)));\n      maxVal = Math.max(...result.map((r) => Number(r.value)));\n    }\n\n    return [\n      key,\n      {\n        rows: result.map((r) => ({\n          value: r.value,\n          total: Number(r.total),\n        })),\n        total,\n        min: minVal,\n        max: maxVal,\n      } satisfies FacetMetadataSchema,\n    ] as const;\n  });\n\n  const results = await Promise.all(queries);\n  return Object.fromEntries(results.filter(([, v]) => v !== null));\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/drizzle/filters.ts",
      "content": "import type {\n  FilterOp,\n  Filters,\n  FilterSelection,\n} from \"@/lib/filters\";\nimport {\n  and,\n  between,\n  eq,\n  gte,\n  ilike,\n  inArray,\n  lte,\n  sql,\n  type Column,\n  type SQL,\n} from \"drizzle-orm\";\nimport type { ColumnMapping } from \"./types\";\n\n/**\n * Compile one canonical op to SQL.\n *\n * Exhaustive over the six `FilterOp` members with no default branch. The op\n * union is closed precisely so that adding a seventh fails to compile here\n * instead of silently matching nothing.\n *\n * Nothing in this function inspects the shape of a filter *value* — it only\n * ever sees an op that the declared semantics already chose. That is why a\n * numeric checkbox can no longer compile to `BETWEEN`.\n */\nfunction toSQL(op: FilterOp, column: Column): SQL {\n  switch (op.op) {\n    case \"substring\":\n      // Case-insensitive, matching the in-memory engine.\n      // NOTE: LIKE metacharacters in the value are deliberately not escaped —\n      // see `sql-injection.test.ts`, which pins this as safe-but-broadening.\n      return ilike(column, `%${op.value}%`);\n\n    case \"equals\":\n      return eq(column, op.value);\n\n    case \"oneOf\":\n      return inArray(column, op.values);\n\n    case \"overlaps\": {\n      // Postgres array overlap. The column is a set on both sides.\n      //\n      // The literal is cast to the column's OWN array type, read off the\n      // schema. `&&` resolves no implicit casts whatsoever: `integer[] &&\n      // text[]` is a type error, and so is `integer[] && numeric[]`. This used\n      // to emit `::text[]` unconditionally, so `col.array(col.number())` — a\n      // combination the builder permits and its docstring endorses — failed at\n      // query time with `operator does not exist`.\n      //\n      // Deriving the cast from the declared `itemKind` cannot work either: a\n      // `number` item says nothing about whether the column is `integer[]`,\n      // `bigint[]` or `double precision[]`, and Postgres rejects all three\n      // mismatches. The column knows, so the column is asked.\n      //\n      // `sql.raw` is safe here for the same reason `${column}` is: the type\n      // comes from the Drizzle schema, never from a request.\n      const arrayType = column.getSQLType();\n      return sql`${column} && ARRAY[${sql.join(\n        op.values.map((value) => sql`${value}`),\n        sql`, `,\n      )}]::${sql.raw(arrayType)}`;\n    }\n\n    case \"numberRange\":\n      return between(column, op.min, op.max);\n\n    case \"dateRange\":\n      return and(gte(column, op.from), lte(column, op.to))!;\n  }\n}\n\n/**\n * Build WHERE conditions from declared filter semantics and a column mapping.\n *\n * @param filters - The declared semantics, from `defineFilters(...)`\n * @param values  - Current filter values from parsed search params\n * @param mapping - Maps filter keys to Drizzle columns\n * @param selection - `{ only, exclude }` to limit keys (for the three-pass strategy)\n */\nexport function buildWhereConditions(\n  filters: Filters,\n  values: Record<string, unknown>,\n  mapping: ColumnMapping,\n  selection?: FilterSelection,\n): SQL[] {\n  const conditions: SQL[] = [];\n  for (const op of filters.plan(values, selection)) {\n    const column = mapping[op.key];\n    if (!column) continue;\n    conditions.push(toSQL(op, column));\n  }\n  return conditions;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/drizzle/interval.ts",
      "content": "/**\n * The bucket-size ladder for time-series aggregation.\n *\n * Thirteen rungs, from one second to 384 minutes (6.4 hours), plus a\n * fall-through at 768 minutes for anything longer. Given how long a range is,\n * this picks a bucket that yields a readable number of points.\n *\n * This lived inline in a demo route with no test at any level. It is exported\n * here so `DrizzleQueryScope.bucketMs` can be tested directly rather than\n * through a chart query.\n */\nconst INTERVALS: readonly { thresholdMinutes: number; intervalMs: number }[] = [\n  { thresholdMinutes: 1, intervalMs: 1_000 }, // 1 second\n  { thresholdMinutes: 5, intervalMs: 5_000 }, // 5 seconds\n  { thresholdMinutes: 10, intervalMs: 10_000 }, // 10 seconds\n  { thresholdMinutes: 30, intervalMs: 30_000 }, // 30 seconds\n  { thresholdMinutes: 60, intervalMs: 60_000 }, // 1 minute\n  { thresholdMinutes: 120, intervalMs: 120_000 }, // 2 minutes\n  { thresholdMinutes: 240, intervalMs: 240_000 }, // 4 minutes\n  { thresholdMinutes: 480, intervalMs: 480_000 }, // 8 minutes\n  { thresholdMinutes: 1_440, intervalMs: 1_440_000 }, // 24 minutes\n  { thresholdMinutes: 2_880, intervalMs: 2_880_000 }, // 48 minutes\n  { thresholdMinutes: 5_760, intervalMs: 5_760_000 }, // 96 minutes\n  { thresholdMinutes: 11_520, intervalMs: 11_520_000 }, // 192 minutes\n  { thresholdMinutes: 23_040, intervalMs: 23_040_000 }, // 384 minutes\n];\n\n/** The bucket used for any range longer than the last threshold. */\nconst MAX_INTERVAL_MS = 46_080_000; // 768 minutes\n\n/**\n * Pick a bucket size for a range of `durationMs`.\n *\n * Negative durations are treated as their absolute value, so callers do not\n * have to order the bounds first.\n */\nexport function evaluateIntervalMs(durationMs: number): number {\n  if (!Number.isFinite(durationMs)) return MAX_INTERVAL_MS;\n  const durationMinutes = Math.abs(durationMs) / 60_000;\n  for (const { thresholdMinutes, intervalMs } of INTERVALS) {\n    if (durationMinutes < thresholdMinutes) return intervalMs;\n  }\n  return MAX_INTERVAL_MS;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/drizzle/pagination.ts",
      "content": "import { asc, desc, gt, lt, type SQL } from \"drizzle-orm\";\nimport type { CursorPaginationParams } from \"./types\";\n\n/**\n * Build cursor-based pagination conditions and ordering.\n *\n * - direction \"next\": fetch rows BEFORE cursor (older) → ORDER BY cursorCol DESC\n * - direction \"prev\": fetch rows AFTER cursor (newer) → ORDER BY cursorCol ASC\n *\n * `tiebreakOrderBy` follows the cursor's direction rather than being fixed, so\n * that a \"prev\" page — fetched ascending and reversed in the handler — ends up\n * in the same total order as the \"next\" pages around it.\n */\nexport function buildCursorPagination(params: CursorPaginationParams): {\n  cursorCondition: SQL | undefined;\n  orderBy: SQL;\n  tiebreakOrderBy: SQL | undefined;\n  needsReverse: boolean;\n} {\n  const { cursor, direction, cursorColumn, tiebreakColumn } = params;\n\n  const cursorValue =\n    cursor instanceof Date\n      ? cursor\n      : cursor != null\n        ? new Date(cursor)\n        : new Date();\n\n  if (direction === \"prev\") {\n    return {\n      cursorCondition: gt(cursorColumn, cursorValue),\n      orderBy: asc(cursorColumn),\n      tiebreakOrderBy: tiebreakColumn ? asc(tiebreakColumn) : undefined,\n      needsReverse: true,\n    };\n  }\n\n  return {\n    cursorCondition: lt(cursorColumn, cursorValue),\n    orderBy: desc(cursorColumn),\n    tiebreakOrderBy: tiebreakColumn ? desc(tiebreakColumn) : undefined,\n    needsReverse: false,\n  };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/drizzle/sorting.ts",
      "content": "import { asc, desc, type SQL } from \"drizzle-orm\";\nimport type { ColumnMapping, SortDescriptor } from \"./types\";\n\n/**\n * Build an ORDER BY clause from a sort descriptor.\n * Returns undefined if no sort is specified or the column isn't mapped.\n */\nexport function buildOrderBy(\n  mapping: ColumnMapping,\n  sort: SortDescriptor,\n): SQL | undefined {\n  if (!sort) return undefined;\n\n  const column = mapping[sort.id];\n  if (!column) return undefined;\n\n  return sort.desc ? desc(column) : asc(column);\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:block"
}
