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

    1.2.0 — the latest read wins

    Version 1.2.0 makes DataSource.read() safe to call while another read is running: the latest read wins. Until 1.1.0 two overlapping reads ran in parallel and the state came from the read that resolved last, not from the one requested last: a slow read for "ab" resolved after the fast read for "abc" overwrote the right result with a stale one, isReading went back to false when the first read ended while the second was still in flight, and the stale request ran to completion for nothing. Every consumer that wanted "the latest read wins" had to guard it outside the source.

    The read cycle now runs in the AbortableDelegate of @mantlebee/ts-core 1.3.0: a read() requested while another is running supersedes it. Every export keeps its signature; the readers and the hooks gain an optional AbortSignal as last parameter. The one change a consumer can notice is that a read() can now reject with AbortedException: see Upgrading.


    DataSource owns one AbortableDelegate whose function is the read cycle, and read() is a call() of it. A read() requested while another is running aborts the running one:

    • the superseded read() rejects with AbortedException at once, without waiting for its reader;
    • the signal of the superseded read is aborted, so a reader that handed it to fetch sees its request cancelled;
    • the superseded cycle stops at its next await and never touches the state: no items assignment, no items event, no getItemsAfter, no error event, whatever its reader resolves or rejects with later;
    • the new read runs and, when it completes, is the one that sets items and raises items.

    items is therefore always the result of the latest completed read, whatever the order the readers resolve in.

    const users = new DataSource<User>(
    (payload, signal) =>
    fetch("/users", {
    body: JSON.stringify(payload),
    method: "POST",
    signal,
    }).then((a) => a.json()),
    [filtering],
    );
    users.on("items", (items) => render(items)); // raised once, with the result of "abc"

    filtering.replaceFiltersOf("name", {
    field: "name",
    operator: FilterOperators.contains,
    value: "ab",
    });
    users.read().catch((error) => {
    if (!isAbortedException(error)) throw error; // superseded: nothing to do
    });
    filtering.replaceFiltersOf("name", {
    field: "name",
    operator: FilterOperators.contains,
    value: "abc",
    });
    await users.read(); // the only read that sets `items`

    A read() requested from a handler raised during the cycle supersedes the running read like any other: e.g. paginating.on("page", () => users.read()) when the pagination goes back to a valid page because the total count shrank. The outer read() then rejects with AbortedException and the inner one completes.

    isReading follows the latest read: it is true from the first read() of a burst until the latest one settles. A superseded read ending does not reset it and does not raise isReading(false), so a spinner bound to it no longer disappears while a read is still in flight. The isReading event is raised only on actual transitions: true once when the first read of the burst starts, false once when the last one settles, successfully or not.

    isReading reads a private flag kept in sync with isRunning of the delegate rather than isRunning itself: the getter and the event must never disagree, and isRunning has no event of its own, so the transitions are detected where the event is raised (before the call, and when the latest call settles).

    QueryItemsReader<TItem, TResult> (and DataSourceReader, the same type) gains an optional second parameter:

    type QueryItemsReader<TItem, TResult = List<TItem>> = (
    payload: QueryItemsPayload<TItem>,
    signal?: AbortSignal,
    ) => Promise<TResult>;

    The data source passes the signal of the current read to its reader, and the PaginationExtension passes it to its count reader. Hand it to fetch (or to anything taking an AbortSignal) to cancel the request of a superseded read; ignore it and nothing changes, the data source discards the result anyway. Every existing reader, written for the first parameter only, keeps working untouched. The in-memory readers of createInMemoryQueryItemsReader ignore it on purpose: they resolve on the next microtask.

    IDataSourceExtension.getItemsBefore?(payload, signal?) and getItemsAfter?(items, payload, signal?) gain the same optional last parameter, so an extension that reads something can hand it to its reader. An implementer that declares fewer parameters still type-checks. Once the signal is aborted the data source stops the cycle: the remaining hooks and the reader are not invoked, and getItemsAfter of a superseded read never runs. PaginationExtension forwards the signal to its count reader and leaves totalItemsCount and the page untouched when the read was superseded while the count was pending.

    dispose() aborts the running read before removing the handlers: its read() rejects with AbortedException and a disposed source never raises items afterwards (until 1.1.0 a late read did).

    • A read() requested while another is running supersedes it: the superseded read() rejects with AbortedException at once, the new one runs.
    • Only the latest read touches the state: items and the items event come from the latest completed read; a superseded read assigns nothing, raises nothing and does not run its getItemsAfter.
    • isReading is true from the first read() until the latest one settles; the isReading event is raised exactly on the transitions.
    • A superseded read is not an error: no error event, even when its reader rejects afterwards (e.g. the AbortError of fetch). The errors of the reader and of the hooks of the latest read keep raising error and rejecting read().
    • The reader and the hooks receive the AbortSignal of the read, aborted when the read is superseded or the source disposed, never otherwise.
    • dispose() aborts the running read.
    • A read requested after a failed or superseded one works: the delegate is reusable.
    • No public abort() on IDataSource: the known use cases (a combobox, a table) are covered by "the next read supersedes the previous one" and by dispose(); none of the specs needed it.

    No new export. AbortedException and isAbortedException are exports of @mantlebee/ts-core:

    import { isAbortedException } from "@mantlebee/ts-core";
    

    Install @mantlebee/ts-core 1.3.0 or later: the peer dependency is now ^1.3.0 (^1.2.1 resolves to a version without AbortableDelegate).

    npm install @mantlebee/ts-data@^1.2.0 @mantlebee/ts-core@^1.3.0
    

    One behavioural change can be noticed: a read() can now reject with AbortedException when a newer read() (or dispose()) supersedes it.

    • A consumer that awaits read() and treats every rejection as an error must ignore that one with isAbortedException:

      try {
      await dataSource.read();
      } catch (error) {
      if (!isAbortedException(error)) throw error;
      }
    • A consumer that only listens to items and error has nothing to change: a superseded read raises neither.

    • A consumer that guarded "the latest read wins" outside the source (a request counter, a stale-result check) can drop the guard.

    • A reader that hands the new signal to fetch may see fetch reject with its own AbortError when the read is superseded: the data source swallows it for a superseded read (no error event, read() already rejected with AbortedException). A reader that ignores the signal needs no change.

    • isReading stays true across a burst of reads instead of flickering: a spinner bound to it is now right, nothing to change.