@mantlebee/ts-data
    Preparing search index...

    1.0.0 — upgrading from 0.1.0 and from bl-core data

    Version 1.0.0 rebuilds the package around an extensible, event-driven DataSource: the core only runs a read cycle and knows nothing about filters, pagination or sorting, which are extensions invoked in array order. The query model (QueryItemsPayload) is a plain, storage-agnostic description that a reader translates into the actual request.

    There are two kinds of consumers, with two different migration paths:

    • projects on @mantlebee/ts-data 0.1.0 (browsing / querying): the whole API is replaced, see 1;
    • projects using the copy in bl-core (client/lib/bl-core/src/data), which this package replaces: the API is the same one, with bug fixes and a few renames, see 2.

    Both need @mantlebee/ts-core ^1.2.1 installed as a peer dependency.


    The browsing module (plugin-based DataSource, BrowseItemsPayload) and the querying module (the fluent Queryable DSL, QueryableDataSource) no longer exist. 1.0.0 exports two modules:

    Module Contents
    query-items the query model: QueryItemsPayload, Filter, FiltersExpression, Sort, Paging, Projection, FilterRange, …
    data-source DataSource, its hooks, and the extensions: filtering, pagination, projection, sorting

    The fluent DSL (where("age").isGreaterThan(18).and(...).read()) is planned for 1.1.0 on top of the new DataSource. Until then, use the extensions directly (1.4).

    The payload given to the reader and every type inside it change name and shape.

    0.1.0 1.0.0
    BrowseItemsPayload<TItem> QueryItemsPayload<TItem>
    payload.skip / payload.top payload.paging: Paging (page, itemsPerPage, totalItemsCount)
    payload.selects: List<Select> payload.projections: List<Projection>
    — payload.distinct?: boolean
    Filter = { field, operation: FilterOperations, value } Filter = { field, operator: FilterOperators, value }
    FilterOperations (string enum of comparisons) FilterOperators (string enum of comparisons, new values)
    FilterOperators { and, or } (numeric enum) FilterLogics { and = "and", or = "or" }
    FiltersExpression = { operator, filters, childExpressions } FiltersExpression = FiltersList | FiltersGroup
    Sort = { by, asc } Sort = { field, asc }
    Select = { alias, key?, defaultValue? } Projection = { field }

    Watch the name FilterOperators: in 0.1.0 it was the numeric and/or enum, in 1.0.0 it is the enum of comparisons (the old FilterOperations). Code written against the old meaning fails to type-check, which is the intended way to catch it: every FilterOperators.and becomes FilterLogics.and, every FilterOperations.x becomes FilterOperators.x.

    The comparison values follow OData 4 where it defines them, otherwise the most common naming (Strapi, Prisma, PrimeVue). Readers that serialise the operator must be updated:

    Member 0.1.0 (FilterOperations) 1.0.0 (FilterOperators)
    equal "eq" "eq"
    notEqual "neq" "ne"
    greaterThan "gt" "gt"
    greaterThanOrEqual "gte" "ge"
    lessThan "lt" "lt"
    lessThanOrEqual "lte" "le"
    contains "contains" "contains"
    notContains "notcontains" "notContains"
    in "in" "in"
    notIn "nin" "notIn"
    startsWith — "startsWith"
    endsWith — "endsWith"
    equalInsensitive — "eqi"
    range — "between"

    Filter is now a union discriminated by operator: value is a list for in/notIn (ListFilter), a Range for range (RangeFilter), a single nullable value otherwise (ValueFilter). In 0.1.0 value was Any.

    A filters expression is a tree whose node is either a flat list of filters or a group of expressions, never both; isFiltersList / isFiltersGroup tell them apart:

    // 0.1.0
    const expression: FiltersExpression<User> = {
    operator: FilterOperators.or,
    filters: [
    { field: "role", operation: FilterOperations.equal, value: "admin" },
    ],
    childExpressions: [
    {
    operator: FilterOperators.and,
    filters: [
    {
    field: "age",
    operation: FilterOperations.greaterThanOrEqual,
    value: 18,
    },
    { field: "active", operation: FilterOperations.equal, value: true },
    ],
    childExpressions: [],
    },
    ],
    };

    // 1.0.0 — the same condition: role = admin or (age >= 18 and active)
    const expression: FiltersExpression<User> = {
    logic: FilterLogics.or,
    groups: [
    {
    logic: FilterLogics.and,
    filters: [
    { field: "role", operator: FilterOperators.equal, value: "admin" },
    ],
    },
    {
    logic: FilterLogics.and,
    filters: [
    {
    field: "age",
    operator: FilterOperators.greaterThanOrEqual,
    value: 18,
    },
    { field: "active", operator: FilterOperators.equal, value: true },
    ],
    },
    ],
    };

    Pagination is expressed as a 1-based page instead of skip/top. A reader that needs the offsets computes them:

    const { page, itemsPerPage } = payload.paging ?? { page: 1, itemsPerPage: 0 };
    const skip = (page - 1) * itemsPerPage;
    const top = itemsPerPage; // 0 when there is no paging: take everything

    Select with alias and custom values (selectCustom) has no equivalent: Projection only names the field to return.

    0.1.0 1.0.0
    new DataSource(readDelegate, plugins) new DataSource(reader, extensions)
    DataSourceReadDelegate<TItem> DataSourceReader<TItem, TResult = List<TItem>>
    read(): Promise<List<TItem>> read(): Promise<void>; the items are in items and in the items event
    getPlugin(constructor) getExtension(constructor)
    isPristine removed
    IDataSourcePlugin.beforeRead(payload) IDataSourceExtension.getItemsBefore(payload)
    IDataSourcePlugin.afterRead(items) (never invoked) IDataSourceExtension.getItemsAfter(items, payload) (invoked)
    DataSourcePlugin with enable() / disable() removed: an extension is active while it is in the array
    plugins run in parallel (Promise.all) extensions run in array order, each receiving the payload of the previous
    no events DataSource and every extension are Subscribables of ts-core

    The order of the array is now meaningful and up to you: FilterExtension must precede PaginationExtension, so the count reader receives the filters. In 0.1.0 the same dependency existed but was hidden by the parallel execution, and [pagination, filter] silently counted without filters.

    // 0.1.0
    const filterPlugin = new DataSourceFilterPlugin<User>();
    const paginationPlugin = new DataSourcePaginationPlugin<User>(20, countUsers);
    const dataSource = new DataSource<User>(readUsers, [
    filterPlugin,
    paginationPlugin,
    ]);
    const users = await dataSource.read();

    // 1.0.0
    const filtering = new FilterExtension<User>();
    const paginating = new PaginationExtension<User>(countUsers, 20);
    const dataSource = new DataSource<User>(readUsers, [filtering, paginating]);
    dataSource.on("items", (users) => render(users));
    await dataSource.read(); // or: await dataSource.read(); render(dataSource.items);

    A read that fails (reader or hook) raises the error event and rejects, and isReading always goes back to false. In 0.1.0 a rejecting beforeRead left isReading stuck at true.

    Write the item type on every extension (new FilterExtension<User>()): TypeScript cannot infer it from the data source the extension is later passed to, and the generic defaults to Any, so a missing type argument compiles but loses the field checks.

    DataSourceFilterPlugin → FilterExtension. The plugin kept a whole FiltersExpression and exposed addFilter(filter, expression?) to add to any node; the extension keeps a flat list combined with one logic (and by default) and sends it as a FiltersList. Several filters on the same field are allowed (in 0.1.0 addFilter replaced the existing one, making an interval impossible).

    0.1.0 1.0.0
    filtersExpression filters, logic
    addFilter(filter, expr?) addFilter(filter)
    addFilters(filters, expr?) setFilters(filters) (replaces) or several addFilter
    removeFilter(filter, expr?) removeFilter(filter) (by reference)
    clearFilters(expr?) clearFilters()
    — removeFiltersOf(field), replaceFiltersOf(field, ...filters), setLogic(logic), hasFilters

    For a tree with nested groups use FilterExtensionWithFixedFilters, whose fixed part is any FiltersExpression, or write your own extension (a dozen lines, see 3.2).

    DataSourcePaginationPlugin → PaginationExtension. The constructor arguments are swapped and the page is clamped.

    0.1.0 1.0.0
    new DataSourcePaginationPlugin(itemsPerPage, countDelegate) new PaginationExtension(countReader, itemsPerPage, itemsPerPageOptions?, options?)
    currentPage page
    goFirstPage(), goLastPage(), goNextPage(), goPrevPage() returning boolean goToFirstPage(), goToLastPage(), goToNextPage(), goToPreviousPage() returning void; listen to the page event
    goToPage(page) unbounded goToPage(page) clamped to [1, lastPage]
    canGoPrevPage canGoPreviousPage
    nextPage, prevPage removed (page + 1 / page - 1 when canGo…)
    pagesCount (always 0) removed, use lastPage
    setItemsPerPage(n) unchecked setItemsPerPage(n) throws if n is not a positive integer or not among itemsPerPageOptions; re-clamps the page
    payload.skip / payload.top payload.paging

    DataSourceSortPlugin → SortExtension. The toggle semantics of sortBy(by) (asc, then desc, then removed) are gone: addSort takes an explicit Sort.

    0.1.0 1.0.0
    new DataSourceSortPlugin(sortsNumberLimit = null) new SortExtension(sortsLimit = 0, options?), 0 = unlimited
    sorts: Nullable<List<Sort>> (null initially) sorts: List<Sort> ([] initially), hasSorts
    sortBy(by, asc?): Nullable<Sort> addSort({ field, asc }): void; replaces the sort on the same field
    removeSort(by) removeSort(field)
    clearSorts() clearSorts()
    — setSorts(sorts); SingleSortExtension for one sort at most

    DataSourceSelectPlugin → ProjectionExtension. select(key, alias?) becomes addProjection({ field }); selectCustom has no equivalent. clearSelects() → clearProjections(); also removeProjection(field), setProjections(projections), hasProjections.

    DataSourceSkipTopPlugin: removed. Paging is the only pagination the payload carries: use PaginationExtension. Raw offsets that are not a whole page (skip not multiple of top) are not representable; if you need them, a custom extension can pass them to your reader outside the payload (e.g. through a closure), see 3.2 for the shape of an extension.

    Queryable, QueryableDataSource: removed, see 1.1.

    1.5 Peer dependency and entry points

    0.1.0 1.0.0
    dependencies: { "@mantlebee/ts-core": "0.13.0" } peerDependencies: { "@mantlebee/ts-core": "^1.2.1" }
    CommonJS bundle only (dist/index.js) exports map: import → ES modules, require → CommonJS, types
    deep imports (@mantlebee/ts-data/dist/...) only the package root and @mantlebee/ts-data/package.json resolve

    Install the peer yourself: npm install @mantlebee/ts-data @mantlebee/ts-core. ts-core 1.x is itself a breaking upgrade from 0.13.0: see its 1.0.0 release notes. The minimum is 1.2.1 because the DebouncedDelegate of 1.2.0, used by every extension for its debounced *Change event, called window.setTimeout and failed outside a browser (Node, SSR, Jest without jsdom).


    client/lib/bl-core/src/data is the ancestor of this package: the classes, the events and the payload are the same. What follows is the list of differences, in decreasing order of impact.

    bl-core 1.0.0
    import { … } from "@/data" import { … } from "@mantlebee/ts-data"
    import { … } from "@/events" import { … } from "@mantlebee/ts-core"
    import { getHash } from "@/data" import { createHash } from "@mantlebee/ts-core" (2.12)

    Every class of the package extends Subscribable of ts-core ^1.2.1, so EventsDefinition, EventSubscription, ISubscribable and friends must come from ts-core too: a local copy of the events module produces incompatible types.

    DataSourceBase is renamed DataSource (IDataSourceBase → IDataSource). The former DataSource, with the filtering / paginating / projecting / sorting properties resolved by instanceof, is gone: the data source knows nothing about the concrete extensions (open-closed). Keep your own references to the extensions, or ask for them with getExtension.

    // bl-core
    const dataSource = new DataSource<User>(readUsers, [
    new FilterExtension<User>(),
    new PaginationExtension<User>(countUsers, 20),
    ]);
    dataSource.filtering?.addFilter(filter);
    dataSource.paginating?.goToPage(2);

    // 1.0.0 — keep the instances…
    const filtering = new FilterExtension<User>();
    const paginating = new PaginationExtension<User>(countUsers, 20);
    const dataSource = new DataSource<User>(readUsers, [filtering, paginating]);
    filtering.addFilter(filter);
    paginating.goToPage(2);

    // …or look them up
    const filtering =
    dataSource.getExtension<FilterExtension<User>>(FilterExtension);
    filtering?.addFilter(filter);

    getExtension takes one type argument (ConstructorOf<TExtension>) instead of two. Without it the result is typed with the default item type (FilterExtension<Any>: methods available, fields unchecked). The extensions argument of the constructor defaults to [].

    bl-core swallowed every error of a read (catch {}), so a failed read looked like an empty one. 1.0.0 raises the new error event and rejects; isReading goes back to false in both cases. Every await dataSource.read() that is not already inside a try/catch now needs one, or an error handler plus a .catch:

    dataSource.on("error", (error) => notify(error));
    await dataSource.read().catch(() => {}); // the handler above already dealt with it

    dispose() returns Promise<void> on DataSource, on every extension and on FilterRange / FilterRangeLimit (it is Subscribable.dispose() of ts-core 1.x). DataSource.dispose() also disposes its extensions, and an extension cancels its pending debounced *Change event.

    // bl-core
    dataSource.dispose();
    // 1.0.0
    await dataSource.dispose();

    IDataSourceExtension.getCountBefore was declared and implemented everywhere but never invoked by the data source. It is removed from the interface and from every extension: a custom extension that implemented it just drops the method. The count still happens inside PaginationExtension.getItemsBefore, on every read, with the payload built by the previous extensions.

    The members are the same (plus five new ones), so code using FilterOperators.contains keeps compiling, but two serialised values change. A backend, or a mapping to a UI library, that reads the value must follow:

    Member bl-core 1.0.0
    contains "in" "contains"
    range "range" "between"
    in new — "in"
    notIn new — "notIn"
    notContains new — "notContains"
    startsWith new — "startsWith"
    endsWith new — "endsWith"
    every other member unchanged ("eq", "ne", "gt", "ge", "lt", "le", "eqi")

    Note the swap on "in": in bl-core it meant contains, now it means is one of the list, as in OData.

    Filter is a union discriminated by operator, so the type of value follows the operator:

    Operator Type value
    in, notIn ListFilter List<FilterFieldValue<TItem>>
    range RangeFilter Range<FilterFieldValue<TItem>>
    every other ValueFilter Nullable<FilterFieldValue<TItem>>

    A range filter was not expressible before (value did not admit a Range). Code that builds a filter with an operator not known at compile time (operator: someVariable typed FilterOperators) no longer type-checks, because TypeScript cannot pick the member of the union: narrow the operator first, or build the three shapes separately.

    FiltersExpression is FiltersList | FiltersGroup. The former filters: List<Filter> | FiltersExpression[] (an array whose kind had to be guessed from its first element) is split into two properties:

    bl-core 1.0.0
    { logic, filters: List<Filter> } FiltersList = { logic, filters: List<Filter> }
    { logic, filters: FiltersExpression[] } FiltersGroup = { logic, groups: List<FiltersExpression> }
    Array.isArray(expression.filters[0]?.filters) and the like isFiltersGroup(expression) / isFiltersList(expression)

    A reader that translates the expression into a request checks the node kind with the type guards:

    function toQuery<TItem>(expression: FiltersExpression<TItem>): string {
    if (isFiltersGroup(expression))
    return expression.groups.map(toQuery).join(` ${expression.logic} `);
    return expression.filters.map(toCondition).join(` ${expression.logic} `);
    }
    bl-core 1.0.0
    hasFilters is filters.length > 1 filters.length > 0; the payload carries filters iff hasFilters
    filtersLogic and logic getters logic only
    replaceFiltersOf(field, ...filters: List<Filter>) replaceFiltersOf(field, ...filters: List<Filter<TItem>>)
    addFilter pushes into the current array every mutation assigns a new array: do not keep a reference to filters across changes
    new FilterExtension() new FilterExtension(options?), options.changeDelay for the debounce

    FilterExtensionWithFixedFilters changes behaviour: the fixed expression is always sent (bl-core dropped it when the user had no filters), the user filters are combined with and by default (was or), and the payload is a FiltersGroup:

    bl-core 1.0.0
    new FilterExtensionWithFixedFilters(fixed) new FilterExtensionWithFixedFilters(fixed, { groupsLogic?, changeDelay? })
    no user filters → no filters in the payload payload.filters = fixed
    user filters → { filters: [fixed, user], logic: "and" } { groups: [fixed, user], logic: groupsLogic } ("and" by default)
    user logic or by default and by default, change it with setLogic
    fixedFilters protected fixedFilters, groupsLogic public getters
    bl-core 1.0.0
    goToFirstPage, goToLastPage, goToNextPage, goToPreviousPage throw Method not implemented implemented
    goToPage(page) accepts any number truncated to an integer and clamped to [1, lastPage]
    — lastPage, canGoNextPage, canGoPreviousPage
    setItemsPerPage(n) and the constructor accept any number throw an Error unless n is a positive integer and, when itemsPerPageOptions is set, one of them; the page is re-clamped
    page, itemsPerPage, totalItemsCount events on every call raised only when the value changes (goToPage(1) on page 1 is a no-op)
    the page never changes by itself after the count, a page beyond lastPage is clamped (page event); when the query built by the previous extensions differs from the one of the previous read, the page goes back to 1 (3.4)
    new PaginationExtension(countReader, itemsPerPage, options?) fourth argument { resetPageOnQueryChange?: boolean | List<keyof DataSourcePayload> }

    If your code resets the page by hand on filtersChange, it can go: the extension does it on the next read. Keeping it is harmless.

    bl-core 1.0.0
    new SortExtension(sortsLimit?: number) new SortExtension(sortsLimit = 0, options?)
    sortsLimit: Nullable<number>, 0 becomes null sortsLimit: number, 0 = unlimited
    MultiSortExtension removed: SortExtension is already multi-sort
    new SingleSortExtension() new SingleSortExtension(options?)
    addSort(sort, existingSorts, sortsLimit?) util addSort(sort, existingSorts, sortsLimit = 0)

    SingleSortExtension.addSort replaces the current sort, whatever its field, raising a single addSort event (no implicit clearSorts event).

    FilterRange and FilterRangeLimit still extended the single-channel Subscribable<TData> of ts-core 0.x (subscribe / unsubscribe / notifyData), removed in ts-core 1.0.0. They are now Subscribable<FilterRangeEvents<TValue>> and Subscribable<FilterRangeLimitEvents<TValue>>, with one change event.

    bl-core 1.0.0
    range.subscribe((range) => …): number range.on("change", (range) => …): EventSubscription
    range.unsubscribe(id) await subscription.dispose()
    limit.subscribe((limit) => …) limit.on("change", (limit) => …)
    toRange(): Range (Range<Any>) toRange(): Range<TValue>
    toRangeLimit(): Nullable<RangeLimit> toRangeLimit(): Nullable<RangeLimit<TValue>>
    new FilterRange(min?, max?) new FilterRange(min?, max?, excludeMin?, excludeMax?)
    dispose(): void dispose(): Promise<void>, disposes both limits too
    // bl-core
    const price = new FilterRange<number>();
    const id = price.subscribe((range) =>
    filtering.replaceFiltersOf("price", toFilter(range)),
    );
    price.unsubscribe(id);

    // 1.0.0
    const price = new FilterRange<number>();
    const subscription = price.on("change", (range) =>
    filtering.replaceFiltersOf("price", {
    field: "price",
    operator: FilterOperators.range,
    value: range,
    }),
    );
    await subscription.dispose();

    change is raised only when a limit actually changes (same value and same exclusion → no event), as before.

    The hashing module is not part of the package. ts-core ^1.1.0 exposes createHash(data): string, which also handles undefined (where getHash threw). The result is a string (8 hex characters) instead of a number: hashes persisted or compared as numbers must be recomputed.

    // bl-core
    import { getHash } from "@/data";
    const key: number = getHash(payload);
    // 1.0.0
    import { createHash } from "@mantlebee/ts-core";
    const key: string = createHash(payload);

    The migration was rehearsed on a throwaway copy of the client monorepo: bl-core/src/data replaced by export * from "@mantlebee/ts-data", @mantlebee/ts-core bumped from 0.31.0 to 1.2.0 in every workspace package, pnpm install, vite build of the four libraries and tsc --noEmit in each package. What it showed, beyond the sections above:

    • ts-core ^1.2.1 first. pnpm resolves the peer dependency to the version the workspace package already has, so with ts-core 0.31.0 the package would run against a Subscribable without _eventBus, a DebouncedDelegate without abort and no createHash. bl-core compiles unchanged against ts-core 1.2.0; its local events module can stay for its own classes, but the types it declares are not the ones the package uses (2.1).
    • vite build succeeds for bl-core, ui-core, ui-vue3 and ui-primevue3; bl-core and ui-vue3 have no type error at all.
    • Named extensions (2.2): 18 usages in ui-primevue3/src/components/table-data-source (events/page.ts, events/sort.ts, component.vue with dataSource.sorting?.sorts) and in app/example/src/components/table/adapter (filtering/models.ts, pagination/models.ts, sorting/models.ts, utils.ts). The props and the adapter options have to carry the extensions next to the IDataSource, or look them up with getExtension.
    • FiltersExpression.filters (2.7): appendFiltering in app/example/src/components/table/__stories__/stories.ts and convertExpressionToRangeFilters in app/example/src/components/table/components/filters/utils.ts must branch with isFiltersList / isFiltersGroup.
    • Range is not FilterRange (2.7, 2.11): the value of a range filter is a plain Range. In components/filters/utils.ts, convertFilterToRangeFilter must rebuild the instance from it (new FilterRange(value.min?.value, value.max?.value, value.min?.exclude, value.max?.exclude)) and convertFilterRangeToFilter must send filterRange.toRange(): a FilterRange is structurally accepted as a Range (its limits expose value and exclude), so it compiles, but it would serialise the instance with its private fields.
    • useDataSourceApi (ui-vue3/src/apis/data-source/api.ts) sets isReading by hand around read().then(…): a rejected read now leaves it true and the rejection unhandled. Listen to the isReading and items events instead, or add a .catch (2.3).
    • getDataTableAdapter calls dataSource.dispose() without awaiting it: harmless (2.4).

    Not covered by the rehearsal: the .vue templates (plain tsc, not vue-tsc), the storybooks and the runtime behaviour.


    Everything is re-exported flat from @mantlebee/ts-data.

    Module Exports
    query-items QueryItemsPayload
    query-items/filtering Filter, ListFilter, RangeFilter, ValueFilter, FilterFieldValue, FilterOperators, FilterListOperators, FilterRangeOperators, FilterValueOperators, FilterLogics, FiltersExpression, FiltersList, FiltersGroup, isFiltersList, isFiltersGroup, Range, RangeLimit, FilterRange, FilterRangeLimit, FilterRangeEvents, FilterRangeLimitEvents
    query-items/pagination Paging
    query-items/projection Projection
    query-items/sorting Sort, addSort, removeSort
    data-source DataSource, IDataSource, IDataSourceExtension, DataSourceExtension, DataSourceEvents, DataSourceExtensionOptions, DataSourceExtensionChangeDelayDefault, DataSourcePayload, DataSourceReader
    data-source/filtering FilterExtension, FilterExtensionWithFixedFilters, IFilterExtension, FilterExtensionEvents, FilterExtensionWithFixedFiltersOptions
    data-source/pagination PaginationExtension, IPaginationExtension, PaginationExtensionEvents, PaginationExtensionOptions
    data-source/projection ProjectionExtension, IProjectionExtension, ProjectionExtensionEvents
    data-source/sorting SortExtension, SingleSortExtension, ISortExtension, SortExtensionEvents

    Every export has TSDoc; the generated API reference is published at mantlebee.github.io/ts-data.

    IDataSourceExtension has two optional hooks, invoked in array order:

    1. getItemsBefore(payload): adds the contribution of the extension to the payload and returns it for the next extension;
    2. the reader resolves the payload into the items;
    3. getItemsAfter(items, payload) (new): transforms the items and returns them for the next extension.

    An error thrown anywhere in the cycle propagates as a read error (2.3). A custom extension is any ISubscribable with those hooks; DataSourceExtension is the optional base class used by the package, with a debounced *Change event (options.changeDelay, default DataSourceExtensionChangeDelayDefault = 100 ms):

    import { EventsDefinition, Subscribable } from "@mantlebee/ts-core";
    import { DataSourcePayload, IDataSourceExtension } from "@mantlebee/ts-data";

    /** Drops the duplicated items and asks the storage to do the same. */
    class DistinctExtension<TItem>
    extends Subscribable<EventsDefinition>
    implements IDataSourceExtension<TItem>
    {
    public async getItemsBefore(payload: DataSourcePayload<TItem>) {
    payload.distinct = true;
    return payload;
    }
    public async getItemsAfter(items: TItem[]) {
    return Array.from(new Set(items));
    }
    }

    Every class is a Subscribable of ts-core: on(event, handler) returns an EventSubscription, dispose() it to stop listening. Each mutation raises its own event synchronously; the *Change events are debounced, so one handler can react once to a burst of changes (e.g. read again).

    Class Events (arguments)
    DataSource isReading (boolean), items (List<TItem>), error (unknown) new
    FilterExtension addFilter (filter, filters), removeFilter (filter, filters), removeFiltersOf (field, filters), replaceFiltersOf (field, filters), setFilters (filters), clearFilters (), setLogic (logic), filtersChange (filters, hasFilters) debounced
    PaginationExtension page (page), itemsPerPage (itemsPerPage), totalItemsCount (count), each only on an actual change
    ProjectionExtension addProjection (projection, projections), removeProjection (field, projections), setProjections (projections), clearProjections (), projectionsChange (projections, hasProjections) debounced
    SortExtension addSort (sort, sorts), removeSort (field, sorts), setSorts (sorts), clearSorts (), sortsChange (sorts, hasSorts) debounced
    FilterRange change (Range<TValue>)
    FilterRangeLimit change (Nullable<RangeLimit<TValue>>)

    PaginationExtension goes back to page 1 when the query built by the previous extensions (filters, sorts, projections, anything a custom extension adds) differs from the one of the previous read: a page chosen under other filters is never requested again. The comparison is by hash (createHash of ts-core), so a filter removed and added back is not a change, and the first read never resets. The option resetPageOnQueryChange tunes it:

    new PaginationExtension<User>(countUsers, 20); // default: every property but `paging`
    new PaginationExtension<User>(countUsers, 20, undefined, {
    resetPageOnQueryChange: ["filters"],
    }); // filters only
    new PaginationExtension<User>(countUsers, 20, undefined, {
    resetPageOnQueryChange: false,
    }); // never

    The pagination stays decoupled from FilterExtension: it only looks at the payload it receives, so it works with any extension placed before it.

    The package ships a CommonJS bundle (require) and ES modules, one file per source module (import), with sideEffects: false and enums compiled as pure expressions. A bundler keeps only what you import: a project that uses FilterRange alone does not ship DataSource or the extensions. The only runtime dependency is the @mantlebee/ts-core peer.


    1. Update the dependencies: npm install @mantlebee/ts-data@^1.0.0 @mantlebee/ts-core@^1.2.1 and migrate to ts-core 1.x first (its own release notes).
    2. Rename the query types: BrowseItemsPayload → QueryItemsPayload, Filter.operation → operator, FilterOperations → FilterOperators, FilterOperators.and/or → FilterLogics.and/or, Sort.by → field, Select → Projection (1.2).
    3. Rebuild the filters expressions as FiltersList / FiltersGroup nodes and update the reader that serialises them, operator values included (1.2).
    4. Replace skip / top with paging in the reader (1.2).
    5. Replace the plugins with the extensions, choose the array order (filtering before pagination), take the items from the items event or property (1.3, 1.4).
    6. Rewrite Queryable queries with the extensions until 1.1.0 (1.1).
    7. Run tsc --noEmit: every removed or renamed name shows up here.
    1. Install @mantlebee/ts-data@^1.0.0 with @mantlebee/ts-core@^1.2.1; point @/data imports to the package and @/events ones to ts-core (2.1).
    2. Replace dataSource.filtering / paginating / projecting / sorting with your own references or getExtension (2.2).
    3. Wrap read() in a try/catch, or add an error handler and a .catch (2.3); await every dispose() (2.4).
    4. Drop getCountBefore from custom extensions (2.5).
    5. Update whatever reads the operator values: "in" now means in list, contains is "contains", range is "between" (2.6).
    6. Update the readers that walk a FiltersExpression: groups and the type guards instead of the mixed filters array (2.7).
    7. Check the fixed filters: always sent, and by default, FiltersGroup shape (2.8).
    8. Remove workarounds for the unimplemented page navigation and, if you want, the manual page reset on filters change (2.9).
    9. MultiSortExtension → SortExtension; sortsLimit is a number (2.10).
    10. FilterRange / FilterRangeLimit: subscribe → on("change"), unsubscribe(id) → subscription.dispose() (2.11).
    11. getHash → createHash of ts-core, number → string (2.12).
    12. Run tsc --noEmit.