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:
read() rejects with AbortedException at once, without
waiting for its reader;fetch sees its request cancelled;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;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).
read() requested while another is running supersedes it: the superseded
read() rejects with AbortedException at once, the new one runs.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.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().AbortSignal of the read, aborted
when the read is superseded or the source disposed, never otherwise.dispose() aborts the running read.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.