bitbaum Start a project
Package · MIT

listkit

A list as a query: facets, a URL you can share, sorting that puts missing values last. No markup.

Why it exists

Directories — foundations, products, people — kept reinventing filters, and each one encoded the filter in a slightly different URL, so a view could not be sent to someone else.

How it works

A closed set of facet kinds, a URL codec that copies the query instead of rebuilding it, a comparator that sorts missing values last, and page math that cannot produce a negative offset. No React and no HTTP client.

Where it fits

Hirnli's foundation lists and Loki's project lists are the same idea. A list you can link is part of how the studio's work stays checkable.

LicenceMIT
Registry releasev0.2.2
Install
Sourcebitbaum/listkit
Declared consumers9 repositories

API and examples

Documentation from source commit f64939c9ac77 · 2026-11-06. Source documentation can include work beyond the registry release.

Read the package documentation

A list as a query. Filtering, sorting, searching and paging are the same problem everywhere, and they were solved separately everywhere.

A survey of twelve repositories in this fleet on 2026-09-11 found:

whatcount
incompatible filter-state shapes12, no two alike
hand-built URL builders~34
copies of "toggle a value in a set"~25, in two encodings
debounces, at five different delays~11
copies of `Math.ceil(total / pageSize)`~11, three in one repo
repos that escape `%` and `_` before a SQL `LIKE`3 of 7 that need to

Two of the toggle copies were character-for-character identical in repos that share no code. One repo wrote the same three-line body eight times for eight facets, plus eleven more for its boolean flags — about a hundred and twenty lines expressing one idea.

Four live bugs were among the URL builders, and this codec exists so they cannot be written again:

  • one built new URLSearchParams() from scratch, so applying a preset silently

wiped the reader's active search and five other facets;

  • one changed a date range without clearing page, landing the reader on page 4

of a two-page result;

  • one preserved exactly three params by name, so every param added later had to

be remembered in six separate template strings;

  • one used push rather than replace for a debounced search box, so the back

button walked backwards through every intermediate spelling of the word.

What it ships

The decisions, and nothing else.

import { parseQuery, writeQuery, applyQuery, type ListSpec } from "@bitbaum/listkit";

const spec: ListSpec<Project> = {
  facets: [
    { key: "kind", kind: "one", value: (p) => p.kind, options: ["product", "client", "demo"] },
    { key: "status", kind: "many", value: (p) => p.status, options: ["live", "prospect"] },
    { key: "nosite", kind: "flag", value: (p) => !p.site },
  ],
  search: { text: (p) => [p.name, p.description, p.host] },
  sorts: [
    { key: "name", by: [(p) => p.name] },
    { key: "newest", by: [(p) => p.since, (p) => p.name] },
  ],
  defaultSort: "name",
  defaultPageSize: 25,
};

const query = parseQuery(searchParams, spec); // validated, clamped
const { rows, matched, page, counts } = applyQuery(projects, spec, query);
const url = `?${writeQuery(searchParams, next, spec, query)}`;

Five facet kinds, and no magic sentinel

one · many · flag · range · ordinal. The value for "no filter" is the empty selection, never a magic string. One surveyed repo used the translated label ("Alle") as its all-value, which made filter identity depend on the reader's language.

Rules the tests pin

  • An empty selection filters nothing, not everything.
  • Missing values sort last in both directions. A record with no date is not

the oldest record.

  • ?page=0 can never become a negative offset, and an empty list still has

one page rather than "page 1 of 0".

  • Facet counts are computed with that facet's own selection lifted, so while

filtering by kind=product the count beside kind=demo still says how many demos exist, instead of zero.

  • Writing copies the params you were given. A locale or a referrer survives.
  • page is dropped when the result set changes, kept when only the page moves.
  • A superseded async search resolves to undefined, so a slow "ab" cannot

overwrite a fast "abc".

  • Every search word must match, folded, and a non-match scores 0.
  • A reader's chosen sort is never overridden by relevance.

Search that finds what people type

  • Every word, any order. bike sell finds "Sell my old bike".
  • Accents and ß fold. zurich finds "Zürich", strasse finds "Straße".
  • Best match first. While a search is active and the reader has not picked

a sort, results rank by where a word matched — search.text lists fields most important first — and how well: the whole field, its start, a word's start, anywhere inside. A phrase match earns a bonus; ties keep the default order. A sort the reader chose wins. ?sort=relevance asks for it explicitly; search: { rank: false } opts a list out.

  • One rule for SQL too. searchTerms(q) gives the folded words; apply one

likeContains per term, ANDed, and the database agrees with the array. (Fold the column the same way — unaccent() in Postgres.)

No stemming, no typo tolerance, no index: that is a search engine's job.

What it deliberately does not ship

Markup, tokens, React, a search engine, or an HTTP client.

Nine token vocabularies and six chip treatments were found among those same repos, several different on purpose — one marketplace is monochrome by design, one product reserves an accent colour for Bitcoin UI, one is the only RTL-aware surface in the fleet. The fleet has already run the experiment where a shared renderer owns the markup: it reached two consumers out of twenty and shipped the same two defects to both, unfixable downstream because a consumer cannot patch markup it does not own.

Centralize the rule; render it locally.

The execution engine is also yours. applyQuery is the in-memory engine, for bounded collections. The same ListSpec describes what a SQL builder should do with a large one — escapeLike and likeContains are exported for exactly that. Forcing one execution model would serve one repo and strand the rest.

Install

pnpm add @bitbaum/listkit

Develop

pnpm install
pnpm run verify   # format, lint, typecheck, build, test

Tests import the package by name, so a broken exports or files map fails here rather than at the first consumer's install.

MIT.


Part of bitbaum — AI-native products on open infrastructure, built in Zürich. Every package here lists the apps that use it: which apps use listkit.

Git releases include the compiled dist/ artifact and require no consumer-side prepare build. Package CI rebuilds it and refuses a mismatch with committed output, so consumers can retain pnpm's default build-script restrictions. The same public exports work for Git and npm installs.

Changelog and releases

Read the repository changelog

This retrospective record was added on 2026-10-27 from the immutable sources linked below. Git commit dates are source dates, not inferred npm publication dates. A GitHub release does not itself establish npm publication. Application adoption and deployment evidence are tracked separately.

0.2.1

Recorded release notes

What's Changed

* fix(release): ship compiled Git artifacts without prepare builds by @catomean in https://github.com/bitbaum/listkit/pull/8

Full Changelog: https://github.com/bitbaum/listkit/compare/v0.2.0...v0.2.1

  1. v0.2.2
    Release notes

    What's Changed

    * chore: add the fleet package template files this repo is missing (AGENTS.md, CLAUDE.md) by @catomean in https://github.com/bitbaum/listkit/pull/9 * chore: add the fleet package template files this repo is missing (release-notify.yml) by @catomean in https://github.com/bitbaum/listkit/pull/10 * docs: record verified package release history by @catomean in https://github.com/bitbaum/listkit/pull/11 * docs(readme): install from npm, the way the registry says by @catomean in https://github.com/bitbaum/listkit/pull/12 * ci(publish): the fleet's one publish.yml, with a no-publish check of the npm link by @catomean in https://github.com/bitbaum/listkit/pull/13 * docs(agents): the org rules block, generated from bitbaum/fleet by @catomean in https://github.com/bitbaum/listkit/pull/14 * chore(deps): dependabot leaves TypeScript 7 alone until typescript-eslint supports it by @catomean in https://github.com/bitbaum/listkit/pull/15 * chore(deps-dev): bump the dev group with 5 updates by @dependabot[bot] in https://github.com/bitbaum/listkit/pull/16

    New Contributors

    * @dependabot[bot] made their first contribution in https://github.com/bitbaum/listkit/pull/16

    Full Changelog: https://github.com/bitbaum/listkit/compare/v0.2.1...v0.2.2

  2. v0.2.1
    Release notes

    What's Changed

    * fix(release): ship compiled Git artifacts without prepare builds by @catomean in https://github.com/bitbaum/listkit/pull/8

    Full Changelog: https://github.com/bitbaum/listkit/compare/v0.2.0...v0.2.1

  3. v0.2.0
    Release notes

    What's Changed

    * docs: link back to bitbaum, and to who uses this package by @catomean in https://github.com/bitbaum/listkit/pull/1 * feat(ci): listkit had a complete verify and nothing to run it by @catomean in https://github.com/bitbaum/listkit/pull/2 * feat(search): every word in any order, accents folded, best match first (v0.2.0) by @catomean in https://github.com/bitbaum/listkit/pull/3 * feat!: publish as @bitbaum/listkit — the bare name is taken on npm by @catomean in https://github.com/bitbaum/listkit/pull/4 * chore: add the fleet package template files this repo is missing (dependabot.yml, git-release.yml, release.yml) by @catomean in https://github.com/bitbaum/listkit/pull/5

    New Contributors

    * @catomean made their first contribution in https://github.com/bitbaum/listkit/pull/1

    Full Changelog: https://github.com/bitbaum/listkit/compare/v0.1.0...v0.2.0

Recent source changes

Repository activity can include unreleased work; it is not a list of deployed changes.

  1. docs(agents): the org rules block, generated from bitbaum/fleet (#20)
  2. chore(deps-dev): bump the dev group with 2 updates (#19)
  3. chore(deps): clear dev-toolchain audit findings inside existing ranges (#18)
  4. docs(agents): the org rules block, generated from bitbaum/fleet (#17)
  5. release v0.2.2
  6. chore(deps-dev): bump the dev group with 5 updates (#16)
  7. chore(deps): dependabot leaves TypeScript 7 alone until typescript-eslint supports it (#15)
  8. docs(agents): the org rules block, generated from bitbaum/fleet (#14)
All releases · All tags

Where it is declared

Linked products declare this package in their manifests. A declaration alone does not verify every feature, version or deployed integration.

Sources and verification

Install commands and declared consumers come from Fleet’s generated registry. Documentation and release notes come from the package repository. The catalogue does not certify production journey coverage.

Documentation from source commit f64939c9ac77 · 2026-11-06. Source documentation can include work beyond the registry release.

Package verification runs