Tulip Logo IconTulip
Local (Beta)

Server Filters

Normalize on-demand subset filters so one server procedure can power both suspense and live query flows.

The hardest part of syncMode: "on-demand" is not the collection itself. The hard part is that loadSubsetOptions expresses filters differently from the query inputs we normally send to the server.

Use the shared load-subset helpers from @tulip-systems/query/tanstack-db so every module translates that shape the same way.

Strategy

Tulip standardizes the pieces that both flows need:

  1. filters become the same portable condition tree used by table filter descriptors
  2. sorting becomes { sort, order }
  3. pagination becomes { page, limit }
  4. the oRPC procedure can keep using the module's normal list input

Target contract

For a typical table endpoint, the server input still looks like normal table query input:

type ModuleListInput = {
  filters?: Record<string, unknown>;
  sort?: string;
  order?: "asc" | "desc";
  pagination: { page: number; limit: number };
};

That gives us one server entry point for both:

  • orpc.module.list.queryOptions(input)
  • local collection queryFn(ctx) after translating ctx.meta?.loadSubsetOptions

Load subset helpers

Import the helpers from the local entrypoint:

import {
  parseLoadSubsetFilters,
  parseLoadSubsetPagination,
  parseLoadSubsetSorting,
} from "@tulip-systems/query/tanstack-db";

parseLoadSubsetFilters(...) returns the same shape produced by defineFilters(...) mappers:

type ParsedFilter =
  | { field: string; operator: string; value?: unknown }
  | { and: ParsedFilter[] }
  | { or: ParsedFilter[] }
  | { not: ParsedFilter };

That keeps on-demand collection filters compatible with the new filter descriptor API.

parseLoadSubsetSorting(...) returns the same parsed-sort shape produced by dataset sort parsers:

type ParsedSort = { field: string; direction: "asc" | "desc" };

Collection flow

queryFn: async (ctx) => {
  const { page, limit } = parseLoadSubsetPagination(ctx.meta?.loadSubsetOptions);
  const parsedSorts = parseLoadSubsetSorting(ctx.meta?.loadSubsetOptions);
  const parsedFilters = parseLoadSubsetFilters(ctx.meta?.loadSubsetOptions);
  const filters = parseModuleLoadSubsetFilters(parsedFilters);

  return orpc.module.getCollection.call({
    pagination: { page, limit },
    parsedSorts,
    filters,
  });
};

Module-specific conversion stays small because it receives a stable condition tree:

function parseModuleLoadSubsetFilters(filter: ParsedFilter | null) {
  if (!filter) return {};

  if ("and" in filter) {
    return Object.assign({}, ...filter.and.map(parseModuleLoadSubsetFilters));
  }

  if (!("field" in filter)) return {};

  if (filter.operator === "in" && Array.isArray(filter.value)) {
    return { [filter.field]: filter.value };
  }

  if (filter.operator === "eq") {
    return { [filter.field]: filter.value };
  }

  return {};
}

Child collections can extract a single field the same way:

function getCustomerId(filter: ParsedFilter | null): string | undefined {
  if (!filter) return undefined;
  if ("and" in filter) return filter.and.map(getCustomerId).find(Boolean);

  return "field" in filter &&
    filter.field === "customerId" &&
    filter.operator === "eq" &&
    typeof filter.value === "string"
    ? filter.value
    : undefined;
}

Recommendation

Do not read raw loadSubsetOptions directly inside every module collection. Parse once with the shared helpers, then map the stable filter condition tree into the module's existing router input.

On this page