dataVersion 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:
@mantlebee/ts-data 0.1.0 (browsing / querying): the
whole API is replaced, see 1;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.
data consumers
DataSource: one class, no named extensionsread() raises error and rethrowsdispose() is asynchronousgetCountBefore removedFilterOperators valuesFilter and FiltersExpression typesFilterExtension and fixed filtersPaginationExtension completedSortExtensionFilterRange on eventsgetHash replaced by createHashThe 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.
| 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).
data consumersclient/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:
^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.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:
getItemsBefore(payload): adds the contribution of the extension to the
payload and returns it for the next extension;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.
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).BrowseItemsPayload → QueryItemsPayload,
Filter.operation → operator, FilterOperations → FilterOperators,
FilterOperators.and/or → FilterLogics.and/or, Sort.by → field,
Select → Projection (1.2).FiltersList / FiltersGroup nodes
and update the reader that serialises them, operator values included
(1.2).skip / top with paging in the reader (1.2).items event or
property (1.3,
1.4).Queryable queries with the extensions until 1.1.0
(1.1).tsc --noEmit: every removed or renamed name shows up here.data@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).dataSource.filtering / paginating / projecting / sorting
with your own references or getExtension (2.2).read() in a try/catch, or add an error handler and a .catch
(2.3); await every dispose()
(2.4).getCountBefore from custom extensions (2.5)."in" now means in list,
contains is "contains", range is "between" (2.6).FiltersExpression: groups and the type
guards instead of the mixed filters array (2.7).and by default, FiltersGroup shape
(2.8).MultiSortExtension → SortExtension; sortsLimit is a number
(2.10).FilterRange / FilterRangeLimit: subscribe → on("change"),
unsubscribe(id) → subscription.dispose() (2.11).getHash → createHash of ts-core, number → string (2.12).tsc --noEmit.