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:
- filters become the same portable condition tree used by table filter descriptors
- sorting becomes
{ sort, order } - pagination becomes
{ page, limit } - 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 translatingctx.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.