a8d53010c2
watchShape attrapait l'échec de résolution des documents, journalisait, et
passait une liste vide. Or une barrière sur zéro document est franchie
trivialement — la surface publiait donc { data: [], isPending: false,
isSuccess: true }, octet pour octet l'instantané « synchronisé et vide ». La
seule distinction pour laquelle ce module existe était celle qu'il détruisait.
Un échec de résolution devient isError, jamais isSuccess. Et comme un
observable ne peut pas dé-émettre, l'état « je ne sais plus » conserve la
DERNIÈRE lecture qui a répondu, avec isSuccess à faux : une liste vide n'est
jamais la réponse d'un échec.
Le canal choisi est l'état de chargement, parce que c'est celui qu'une
application lit déjà pour distinguer « en attente » de « vide ». La troisième
valeur ne lui coûte aucun vocabulaire neuf.
Vérification faite en amont plutôt qu'en supposant : readyPromise n'aurait pas
aidé — construit avec resolve seul, rien ne le rejette, et l'échec
d'orm_start_graph n'est qu'un console.error. Le « je n'ai pas pu savoir » de la
cible EST son « toujours en attente ». La classification invention tient, et les
annotations le disent désormais.
C'est le dernier membre connu de cette famille dans le polyfill : après
connectedUser, resolveAccount, userInbox, readInboxCapPairs et
listMyEntityDocs, la couche réactive était le dernier endroit où un échec se
présentait comme une absence.
458 lines
22 KiB
TypeScript
458 lines
22 KiB
TypeScript
/**
|
|
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
|
|
* one logical scope. This is the surface the consuming app will bind (phase B) with
|
|
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
|
|
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
|
|
*
|
|
* ── Why an observable, and why this exact shape ────────────────────────────
|
|
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
|
|
* natively distinguish "sync in progress" from "synced but empty". That distinction
|
|
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
|
|
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
|
|
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
|
|
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
|
|
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
|
|
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
|
|
*
|
|
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
|
|
* 1. Resolve the logical scope → the doc set: the CURRENT wallet's own per-entity
|
|
* documents for that scope (`storeRegistry.listMyEntityDocs`), and nothing
|
|
* else. There is no "everything public" to fold in — **you cannot discover,
|
|
* you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
|
|
* and a link reaches you through an inbox or through a document you already
|
|
* hold. A document whose cap you were given is read by NAMING it
|
|
* (`readUnion`), not by turning up in a scope you never put it in.
|
|
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
|
|
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
|
|
* fallback). `isPending` holds until the barrier is reached for the current
|
|
* doc set AND the first `readUnion` has rendered.
|
|
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
|
|
* NOT double-filter), then FILTER the union by the requested shape's `@type`
|
|
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
|
|
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
|
|
* ShapeType, not from any application concept.
|
|
*
|
|
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
|
|
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
|
|
* pushes, and the KEYRING — a cap that arrives asynchronously (an inbox delivery
|
|
* absorbed by the consumer's `inbox.watch`) makes documents readable that were not,
|
|
* so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
|
|
* unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
|
|
* in the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
|
|
* entity appends a NURI to the scope-index doc), so we ALSO subscribe to the
|
|
* scope-index document: a push there re-RESOLVES the scope and re-keys the
|
|
* subscribed set. Subscriptions are idempotent — an already-followed
|
|
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
|
|
* parallel channel.
|
|
*
|
|
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
|
|
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
|
|
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
|
|
* `isError` fires ONLY on a real thrown exception in the pipeline.
|
|
*
|
|
* ── A scope that did not answer is NOT an empty scope ──────────────────────
|
|
* The pipeline's first step ASKS a question — which documents are mine in this scope
|
|
* — and until 2026-08-17 a failure to answer it was logged and treated as "none":
|
|
* the empty set flowed on, the barrier over zero docs is trivially reached, and the
|
|
* surface published `{ data: [], isSuccess: true }`. That snapshot is BYTE-FOR-BYTE
|
|
* the synced-but-empty one this module exists to distinguish, so the one distinction
|
|
* it publishes was the one it destroyed — an application rendered "you have nothing"
|
|
* for "the store did not answer". `listMyEntityDocs` stopped handing out that reading
|
|
* one layer down (`90712e0`); catching it here put it straight back.
|
|
*
|
|
* A resolution failure now travels the LOAD-STATE channel, which is where "this is
|
|
* not an answer" already lives on this surface: `isError` with the thrown `error`,
|
|
* and never `isSuccess`. The channel is the honest place because it is the one an
|
|
* application already has to read to tell pending from empty — the same three-way
|
|
* question, and the third state costs it no new vocabulary.
|
|
*
|
|
* ── An observable cannot un-emit: `data` survives an error ─────────────────
|
|
* A one-shot call rejects and is done. An observable has already handed a list to a
|
|
* subscriber that rendered it, so its failure snapshot has to say something about
|
|
* that list — and collapsing it to `[]` would hand out, in `data`, exactly the empty
|
|
* answer this fix removes. So `data` KEEPS the last read that actually answered, and
|
|
* `isError` says the current answer is unknown. `isSuccess` is false throughout: the
|
|
* array is a memory, not a reply to the question just asked.
|
|
*/
|
|
|
|
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
|
import { ensureReposOpen, getSyncState } from "../emulated-verifier/open-repo";
|
|
import { readUnion, type UnionSubject } from "./read-model";
|
|
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
|
import { listMyEntityDocs, userStoreDoc } from "../shared-wallet/account-registry";
|
|
import type { Nuri, Scope } from "../model/types";
|
|
|
|
/**
|
|
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
|
|
* constraint on this predicate; we filter the read union by it.
|
|
*/
|
|
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
|
|
/**
|
|
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
|
|
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
|
|
* the generic per-subject property bags of the read-model (NO application domain);
|
|
* the app maps them to its own entity types in phase B.
|
|
*/
|
|
// @provenance ShapeQuery kind=invention level=none ref=none — no upstream type carries load state; `OrmSubscription.readyPromise` expresses pending-vs-ready and nothing else (built with `resolve` alone, and `orm_start_graph` failing is only logged — a failed read is an eternal pending), so `isError` has no counterpart at any level
|
|
export interface ShapeQuery<T = UnionSubject> {
|
|
/** The subjects of the requested shape/scope. Empty array when none (never undefined).
|
|
* When `isError`, this is the last read that ANSWERED — kept, not cleared, because an
|
|
* observable cannot un-emit and `[]` here would be the false "you have nothing" the
|
|
* error state exists to prevent. Read it as a memory then, not as a reply. */
|
|
data: T[];
|
|
/** True while the sync barrier for the current doc set is not yet reached OR the
|
|
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
|
|
isPending: boolean;
|
|
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the
|
|
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
|
|
* `data: []` — the distinction this surface exists for. */
|
|
isSuccess: boolean;
|
|
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`):
|
|
* the scope could not be resolved, or the union could not be read. It means the
|
|
* current answer is UNKNOWN — never that the scope is empty. Mutually exclusive with
|
|
* both `isPending` and `isSuccess`. */
|
|
isError: boolean;
|
|
/** The caught error when `isError`, else `undefined`. */
|
|
error: unknown;
|
|
}
|
|
|
|
/** The observable a caller binds with `useSyncExternalStore` (phase B). */
|
|
// @provenance ShapeObservable kind=invention level=none ref=none — no upstream observable exists; the ORM exposes a hook returning a DeepSignalSet and nothing else
|
|
export interface ShapeObservable<T = UnionSubject> {
|
|
/** The current snapshot. STABLE across calls until it actually changes (so
|
|
* `useSyncExternalStore` does not loop): the same reference is returned until a
|
|
* state transition produces a new one. */
|
|
getSnapshot(): ShapeQuery<T>;
|
|
/** Register a change listener; returns an unsubscribe. The last listener's
|
|
* unsubscribe tears down the underlying doc subscriptions. */
|
|
subscribe(onChange: () => void): () => void;
|
|
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent
|
|
* w.r.t. subscriptions; never polls. */
|
|
refetch(): void;
|
|
}
|
|
|
|
/**
|
|
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
|
|
* A generated shape constrains its subject's type via a triple constraint on the
|
|
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
|
|
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
|
|
* and every subject in the doc set flows through). Purely structural — reads only
|
|
* the SHEX schema, no application domain.
|
|
*/
|
|
function shapeTypeIris(shapeType: unknown): Set<string> | null {
|
|
try {
|
|
const st = shapeType as {
|
|
shape?: string;
|
|
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
|
|
};
|
|
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
|
|
const preds = shape?.predicates ?? [];
|
|
const iris = new Set<string>();
|
|
for (const p of preds) {
|
|
if (p?.iri !== RDF_TYPE) continue;
|
|
for (const dt of p.dataTypes ?? []) {
|
|
for (const lit of dt.literals ?? []) {
|
|
if (typeof lit === "string") iris.add(lit);
|
|
}
|
|
}
|
|
}
|
|
return iris.size > 0 ? iris : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
|
|
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
|
|
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
|
|
const types = subject.props[RDF_TYPE] ?? [];
|
|
return types.some((t) => typeIris.has(t));
|
|
}
|
|
|
|
/**
|
|
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
|
|
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
|
|
* state that still holds the barrier open is `syncing` (subscribed, first `State`
|
|
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
|
|
* best-effort, not an error). `unknown` means the injected `ng` has no
|
|
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
|
|
* a completed open it can only mean that path, so it counts as reached (opened,
|
|
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
|
|
*/
|
|
function barrierReached(docs: Nuri[]): boolean {
|
|
for (const d of docs) {
|
|
if (getSyncState(d) === "syncing") return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
|
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
|
|
* the full pipeline. The returned observable is inert until its first
|
|
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
|
|
* what kicks off resolution, opening and the first read; before then `getSnapshot`
|
|
* reports the initial pending snapshot.
|
|
*/
|
|
// @provenance watchShape kind=invention level=none ref=none — nothing upstream distinguishes syncing from synced-empty at the hook, and nothing anywhere expresses the third state it now publishes: a failed `orm_start_graph` leaves `readyPromise` unsettled forever, so upstream's "could not find out" IS its "still pending"; the header's 'planned useShape upgrade' has NO upstream provenance
|
|
export function watchShape<T = UnionSubject>(
|
|
shapeType: unknown,
|
|
scope: Scope,
|
|
): ShapeObservable<T> {
|
|
const typeIris = shapeTypeIris(shapeType);
|
|
|
|
// The current, STABLE snapshot (same reference until a transition rebuilds it).
|
|
let snapshot: ShapeQuery<UnionSubject> = {
|
|
data: [],
|
|
isPending: true,
|
|
isSuccess: false,
|
|
isError: false,
|
|
error: undefined,
|
|
};
|
|
|
|
const listeners = new Set<() => void>();
|
|
let started = false;
|
|
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe.
|
|
// Idempotent: a doc already here is not re-subscribed. Excludes the container
|
|
// (scope-index / discovery-index) subscriptions, held separately.
|
|
const docSubs = new Map<Nuri, Unsubscribe>();
|
|
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
|
|
// a push here means the doc SET may have changed → re-resolve.
|
|
const containerSubs = new Map<Nuri, Unsubscribe>();
|
|
// Unsubscribe from the held-caps change signal (see the subscription in `start`).
|
|
let capsUnsub: (() => void) | null = null;
|
|
// True while `resolveDocs` runs. Folding a repo link files a cap, which fires the
|
|
// held-caps signal; the resolution in progress already accounts for it, so the
|
|
// signal is ignored during that window instead of restarting the cycle.
|
|
let resolving = false;
|
|
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
|
|
let refreshToken = 0;
|
|
|
|
function emit(): void {
|
|
for (const l of listeners) {
|
|
try {
|
|
l();
|
|
} catch (error) {
|
|
console.error("[watch-shape] listener threw", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function setSnapshot(next: ShapeQuery<UnionSubject>): void {
|
|
snapshot = next;
|
|
emit();
|
|
}
|
|
|
|
/**
|
|
* Publish "the current answer is UNKNOWN" — the one snapshot every failure in the
|
|
* pipeline produces, wherever it was thrown.
|
|
*
|
|
* `data` carries the LAST READ THAT ANSWERED rather than `[]`. A subscriber has
|
|
* already rendered that list and an observable cannot un-emit it; clearing it would
|
|
* put the empty answer back — in the field an application actually renders, and for
|
|
* the very case that must never read as empty. `isSuccess` stays false, so the array
|
|
* is never offered as a reply to the question that just failed, and a subscriber that
|
|
* gates on `isSuccess` shows nothing new while one that renders `data` keeps what it
|
|
* had. Before the first answer there is nothing to keep and `data` is `[]` — the
|
|
* initial value, published under `isError`, never under `isSuccess`.
|
|
*/
|
|
function setUnknown(error: unknown): void {
|
|
setSnapshot({
|
|
data: snapshot.data,
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: true,
|
|
error,
|
|
});
|
|
}
|
|
|
|
/** Resolve the logical scope → the current doc set: the CURRENT wallet's own
|
|
* entity documents for that scope, and nothing else.
|
|
*
|
|
* It PROPAGATES, and that is the whole point: the set it returns is what the rest
|
|
* of the pipeline calls "the scope", so a swallowed failure here does not degrade
|
|
* the answer, it INVENTS one — zero documents, a barrier trivially reached over
|
|
* them, and `isSuccess` published over a store nobody read. Nothing downstream can
|
|
* tell that set apart from a scope that is genuinely empty, because it IS the same
|
|
* set. The caller's error state exists for this.
|
|
*
|
|
* There is no "everything public" to fold in. You cannot discover; you can only
|
|
* follow links, and a link reaches you through an inbox or through a document
|
|
* you already hold — never through a shared index. A document someone gave you
|
|
* the cap for is read by naming it (`readUnion`), not by appearing in
|
|
* a scope you did not put it in. */
|
|
async function resolveDocs(): Promise<Nuri[]> {
|
|
const user = getCurrentUser();
|
|
const set = new Set<Nuri>();
|
|
resolving = true;
|
|
try {
|
|
if (user) {
|
|
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
|
|
}
|
|
} finally {
|
|
resolving = false;
|
|
}
|
|
return [...set];
|
|
}
|
|
|
|
/** Subscribe to the CONTAINER document (the scope index) so a change to the doc
|
|
* SET re-resolves. Idempotent per NURI.
|
|
*
|
|
* This one SWALLOWS, deliberately and unlike `resolveDocs` next door. It resolves
|
|
* no data and publishes no snapshot — it wires a change signal — so its failure
|
|
* cannot dress an unread store up as an empty one. And the answer is not lost with
|
|
* it: `resolveDocs` runs immediately after over the SAME account, so a store that
|
|
* cannot be reached surfaces there, as an error, one line later. What is lost is
|
|
* reactivity to a later change of the SET, which is a different (and much quieter)
|
|
* problem than the one this module was fixed for. */
|
|
async function ensureContainerSubs(): Promise<void> {
|
|
const containers: Nuri[] = [];
|
|
const user = getCurrentUser();
|
|
if (user) {
|
|
try {
|
|
containers.push(await userStoreDoc(user, scope));
|
|
} catch (error) {
|
|
console.error("[watch-shape] userStoreDoc failed", error);
|
|
}
|
|
}
|
|
for (const c of containers) {
|
|
if (!c || containerSubs.has(c)) continue;
|
|
// A push on a container doc means the set may have changed → full re-resolve.
|
|
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
|
|
}
|
|
}
|
|
|
|
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
|
|
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
|
|
function syncDocSubs(docs: Nuri[]): void {
|
|
const wanted = new Set(docs.filter(Boolean));
|
|
for (const [nuri, unsub] of docSubs) {
|
|
if (!wanted.has(nuri)) {
|
|
try {
|
|
unsub();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
docSubs.delete(nuri);
|
|
}
|
|
}
|
|
for (const nuri of wanted) {
|
|
if (docSubs.has(nuri)) continue;
|
|
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
|
|
}
|
|
}
|
|
|
|
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
|
|
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
|
|
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
|
|
let subjects: UnionSubject[];
|
|
try {
|
|
subjects = await readUnion(docs);
|
|
} catch (error) {
|
|
if (token !== refreshToken) return;
|
|
setUnknown(error);
|
|
return;
|
|
}
|
|
if (token !== refreshToken) return; // superseded by a newer refresh/reread
|
|
const data = subjects.filter((s) => matchesShape(s, typeIris));
|
|
const past = barrierReached(docs);
|
|
setSnapshot({
|
|
data,
|
|
isPending: !past,
|
|
isSuccess: past,
|
|
isError: false,
|
|
error: undefined,
|
|
});
|
|
}
|
|
|
|
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
|
|
* (await the barrier), sync per-doc subs, then read + publish.
|
|
*
|
|
* This catch is the surface's ONE answer to "the pipeline could not answer" —
|
|
* scope resolution included, since `resolveDocs` propagates. It is reached only
|
|
* when the cycle is still the current one; a superseded cycle's failure is
|
|
* dropped, because a newer cycle owns the question by then. */
|
|
async function refresh(): Promise<void> {
|
|
const token = ++refreshToken;
|
|
try {
|
|
await ensureContainerSubs();
|
|
const docs = await resolveDocs();
|
|
if (token !== refreshToken) return;
|
|
syncDocSubs(docs);
|
|
// Open/await the barrier (first State per doc, or timed-out). No-op once open.
|
|
await ensureReposOpen(docs);
|
|
if (token !== refreshToken) return;
|
|
await readAndPublish(docs, token);
|
|
} catch (error) {
|
|
if (token !== refreshToken) return;
|
|
setUnknown(error);
|
|
}
|
|
}
|
|
|
|
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve,
|
|
* the set is unchanged). Reuses the docs we are subscribed to. */
|
|
async function reread(): Promise<void> {
|
|
const token = ++refreshToken;
|
|
const docs = [...docSubs.keys()];
|
|
await readAndPublish(docs, token);
|
|
}
|
|
|
|
function start(): void {
|
|
if (started) return;
|
|
started = true;
|
|
// A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the
|
|
// consumer's `inbox.watch`) makes documents readable that were not. Without
|
|
// this the view would stay stale until some unrelated push happened to fire —
|
|
// so re-read whenever they change. This is the delivery channel key
|
|
// ROTATION uses too, which is why keeping an access needs no subscription
|
|
// obligation on the consumer's side.
|
|
capsUnsub = getCaps().onChange(() => {
|
|
if (!resolving) void refresh();
|
|
});
|
|
void refresh();
|
|
}
|
|
|
|
return {
|
|
getSnapshot(): ShapeQuery<T> {
|
|
return snapshot as unknown as ShapeQuery<T>;
|
|
},
|
|
subscribe(onChange: () => void): () => void {
|
|
listeners.add(onChange);
|
|
start();
|
|
return () => {
|
|
listeners.delete(onChange);
|
|
if (listeners.size === 0) {
|
|
// Last listener gone → tear down the underlying subscriptions. A later
|
|
// subscribe restarts a fresh cycle.
|
|
for (const u of docSubs.values()) {
|
|
try {
|
|
u();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
for (const u of containerSubs.values()) {
|
|
try {
|
|
u();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
docSubs.clear();
|
|
containerSubs.clear();
|
|
if (capsUnsub) {
|
|
capsUnsub();
|
|
capsUnsub = null;
|
|
}
|
|
started = false;
|
|
}
|
|
};
|
|
},
|
|
refetch(): void {
|
|
start();
|
|
void refresh();
|
|
},
|
|
};
|
|
}
|