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

    2.1.0 — lookup sources

    Version 2.1.0 adds the lookup folder: ILookupSource, a stateless, request/response source of reference items, for the widgets that ask questions of an entity on behalf of other data, a combobox searching departments by name, the lookup column of a grid showing the department of each row from its id. The release is additive: no existing export changes, data-source and query-items are untouched.

    Until 2.0.0 such widgets used an IDataSource with a FilterExtension: they wrote their filters into the extension, read, restored the filters, under a lock, because a data source is a cursor ("the latest read wins", one items) and a combobox panel, the value of its input and the fifty cells of a grid page ask different questions of the same entity at the same time. The readItems hook of 2.0.0 can cache or batch the reading of a cursor, but the cursor still has one query and one items: the problem was the contract, not the reader.


    interface ILookupSource<TItem = Any, TValue = LookupValue> {
    search(
    query: string,
    page: LookupPage,
    signal?: AbortSignal,
    ): Promise<LookupSearchResult<TItem>>;
    resolve(values: List<TValue>, signal?: AbortSignal): Promise<List<TItem>>;
    valueOf(item: TItem): TValue;
    }
    type LookupPage = { limit: number; offset: number };
    type LookupSearchResult<TItem> = { hasMore: boolean; items: List<TItem> };
    type LookupValue = number | string;

    Each call has its own parameters, its own result and its own signal: no events, no items, no lock, no state on the interface, so one instance serves any number of consumers at once. search pages the items whose text contains query (every item for ""), by offset and limit, and says whether more follow. resolve returns the items of the keys given, without the missing ones, in no particular order: index them with valueOf. The keys are compared with === everywhere (the in filter, the cache, the batch), so TValue defaults to number | string and should stay a primitive.

    const departments = createLookupSource(readDepartments, {
    textField: "name",
    valueField: "id",
    });
    // ILookupSource<Department, number>: the key type is the type of `valueField`
    const fixtures = createInMemoryLookupSource(Departments, {
    textField: "name",
    valueField: "id",
    });

    createLookupSource(reader, options) builds a source over the same QueryItemsReader a data source reads through. search sends textField contains query (no filter for "") with pagination: { page: offset / limit + 1, itemsPerPage: limit, totalItemsCount: 0 } (Pagination is 1-based and page-based, the count is unknown): offset must be a multiple of limit, a panel always pages by its own limit, and search rejects otherwise. hasMore is items.length >= limit: no count reader, at the cost of one empty page when the total is a multiple of limit; asking for one item more than limit would shift the next page, so it is not done. resolve sends valueField in values without pagination, and resolves [] without calling the reader for an empty list. There are no fixed filters: wrap the reader to add a condition to every call.

    createInMemoryLookupSource(items | getItems, options) is the same over a list, through createInMemoryQueryItemsReader of query-items, with its comparison options (caseSensitive, toComparable), so a list in a story or a spec behaves like a server.

    A service with its own endpoints (search by text, read by key) needs no factory: an object literal satisfying ILookupSource is a source.

    const departments = withBatching(source, { chunkSize: 50 });
    // Rendered in the same tick, the cells share one `id in [1, 2]` request.
    cell1.show(departments.resolve([1]));
    cell2.show(departments.resolve([2]));
    cell3.show(departments.resolve([1]));

    The fifty cells of a grid page each resolve their own key without knowing of the others: withBatching coalesces the resolve calls of the same microtask (queueMicrotask, no timers) into one call of the inner source with the distinct values, chunkSize values at most per call, and gives each caller the items of its own values, picked from the shared result with valueOf. A rejected call of the inner source rejects every caller of the batch; the next tick opens a new one. search and valueOf pass through.

    const departments = withCache(withBatching(source));
    await departments.resolve([1, 2]); // one request
    await departments.resolve([2, 3]); // one request, for 3 only
    departments.invalidate([2]); // 2 is asked again next time; no argument forgets everything

    The same department is shown in twenty rows and on every page: withCache remembers the item of every resolved value for the life of the source, only asks the inner source the values it has never seen, shares a pending resolution among the callers asking for the same value, remembers a value the source does not return as missing and forgets a rejected one, so the next call tries again. It returns an ICachedLookupSource, the source plus invalidate(values?), which forgets entries by hand, pending ones included. Compose it outside the batching: the misses of the cache are what the batch coalesces.

    An aborted call rejects with the AbortedException of ts-core, the rejection of a superseded read of a data source, so one isAbortedException handles both. The reader-based source hands the signal to the reader as it is (a fetch cancels the request) and rejects as soon as the signal is aborted, whatever the reader does with it: the in-memory reader ignores the signal and still behaves. The two decorators do not forward the signal to the inner source, since one caller's abort must not starve the others sharing the batch or the pending resolution: the aborted caller alone rejects, at once, and the result still fills the cache.

    New, from the package root: ILookupSource, ICachedLookupSource, LookupValue, LookupPage, LookupSearchResult, LookupReaderOptions, BatchingOptions, createLookupSource, createInMemoryLookupSource, withBatching, withCache. Nothing else changes.

    npm install @mantlebee/ts-data@^2.1.0
    

    Nothing to migrate. Where a combobox or a lookup column drives an IDataSource through its FilterExtension, build an ILookupSource over the same reader instead, withCache(withBatching(createLookupSource(reader, { textField, valueField }))), and call search from the panel and resolve from the cells; the lock and the save/restore of the filters go away. The data source stays the right tool for the grid or the list the user browses.