Files
ng-eventually/packages/client/src/surface/inbox.ts
T
Sylvain Duchesne 88914f50ae refactor(layout): ranger les modules par destin à la migration
Les 25 modules étaient à plat, nommés d'après ce qu'ils font mécaniquement
(`store-registry`, `read-model`, `reach`, `caps`). Rien dans l'arborescence ne
disait lesquels DEVIENDRONT le vrai SDK, lesquels tiennent lieu du travail que
le verifier fera nativement, et lesquels n'existent que parce qu'un wallet est
partagé — trois destins sans rapport.

Quatre dossiers, les deux fichiers d'entrée restant à la racine pour que
l'`exports` du paquet et le code du consommateur ne bougent pas :

- `model/` — le modèle d'adressage de la cible, transcrit : vocabulaire pur,
  pas d'I/O. Survit comme connaissance.
- `surface/` — ce que l'app touche, chaque symbole ayant un pendant cible
  documenté. Supprimé quand l'alias bascule ; le code de l'app est inchangé.
- `emulated-verifier/` — les doublures de ce que le verifier fait nativement :
  possession, dépôt des caps, frontière, non-livraison, traitement des inbox,
  registres de branche, ouverture de repo. **C'est le dossier où diverger du
  modèle est possible.** Le préfixe `emulated-` porte le sens : tient lieu de,
  jamais est — cette bibliothèque ne réside dans aucune couche de la cible,
  elle les référence.
- `shared-wallet/` — n'existe que parce qu'un wallet héberge toutes les
  identités. Aucun pendant, rien sur quoi s'aligner ; sa seule loi est de
  rester invisible depuis `surface/`. S'évapore, remplacé par rien.

`store-registry-api.ts` devient `surface/placement.ts` : il faisait déjà à la
main ce que la frontière de dossier fait structurellement — c'est la meilleure
preuve interne du bien-fondé de ce rangement.

Ce commit ne fait que déplacer et recâbler les imports (src, test, e2e). Les
scissions des modules à cheval suivent.

157 tests unitaires, typecheck src/test/e2e vert.
2026-08-04 12:46:44 +02:00

539 lines
25 KiB
TypeScript

/**
* Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
* decision). The mechanism itself knows no application domain: the consumer
* supplies the inbox document NURI and interprets the `payload`. (An example
* consumer mapping, purely illustrative: a consumer might use one inbox for a
* registration deposit and another for submitting a reference to an index.)
*
* ── Real target vs this emulation ─────────────────────────────────────────
* In real NextGraph, a message is sealed to the recipient's key and queued into
* their inbox; the recipient's own verifier unseals each queued message and
* applies it inline as it processes the inbox — there is no separate curator
* process. There is NO sender-side JS call for this today: the verifier has no
* `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`,
* named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md` —
* no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.)
*
* Here, on one shared wallet where everything is readable, both sides run in-lib:
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
* them. This in-lib read stands in for the recipient's own inbox processing
* until a sealed-inbox path is exposed to JS.
*
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
* never `makeNg`), so this module imports no `@ng-org` package.
*/
import { depositInto, sparqlQuery } from "./docs";
import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../polyfill";
import { addLink, documentInboxAddress, isOwnInbox } from "../shared-wallet/account-registry";
import { escapeLiteral } from "./sparql";
import { hasReadCap } from "../model/nuri";
import {
accessLogPrefix,
enabled as accessLogEnabled,
logAccess,
logStage,
shortNuri,
} from "../shared-wallet/access-log";
import type { Nuri, PrincipalId, ReadCap } from "../model/types";
// --- deposit model --------------------------------------------------------
/** One deposit as materialized from an inbox document. */
export interface Deposit {
/** The sender, if identified; `null` when the deposit was anonymous. */
from: PrincipalId | null;
/** The consumer-defined payload (opaque here — JSON-serialized in storage). */
payload: unknown;
/** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
ts: number;
}
/** Options for {@link post}. `from` and `ts` are both optional. */
export interface PostOptions {
/**
* Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
* principal id to identify the sender. Defaults to the current polyfill user
* ({@link getCurrentUser}) when the property is entirely absent, so callers
* that want anonymity must pass `from: null` explicitly.
*/
from?: PrincipalId | null;
/** The payload to deposit (interpreted only by the consumer). */
payload: unknown;
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
* keeps tests deterministic. */
ts?: number;
}
const SHIM = "urn:ng-eventually:inbox";
const P = {
type: `${SHIM}:Deposit`,
from: `${SHIM}:from`,
payload: `${SHIM}:payload`,
ts: `${SHIM}:ts`,
} as const;
// --- session access (shared with the storeRegistry) -----------------------
/** The inbox documents live in the shared wallet, so we reuse the registry's
* injected session provider for the sessionId. Disappears at migration. */
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
// --- diagnostic logging helper ---------------------------------------------
/**
* Best-effort, length-capped JSON rendering of a deposit payload for the
* inbox diagnostic log (see {@link enabled}/{@link logAccess}). This module
* stays domain-agnostic (see module header) — it never interprets payload
* fields, it only dumps them verbatim so the consumer's own shape (e.g. a
* Festipod participation: `{ participantId, eventId, … }`) is visible in the
* log without this module knowing that shape. Capped so one oversized payload
* can't blow up a log line; a payload that fails to stringify (e.g. a
* circular structure a caller mistakenly passed) falls back to `String()`.
*/
function summarizePayload(payload: unknown): string {
let s: string;
try {
s = JSON.stringify(payload) ?? String(payload);
} catch {
s = String(payload);
}
return s.length > 200 ? s.slice(0, 200) + "…" : s;
}
// --- SPARQL result helpers ------------------------------------------------
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
// --- deposit (client side) ------------------------------------------------
/**
* Deposit a payload into `targetInbox`.
*
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
* graph, so concurrent deposits don't collide.
*
* `from` is bound to the current identity — it is authenticated, not
* caller-supplied. Omit it to stamp the current identity; pass `null` to deposit
* anonymously (a legitimate choice — identified if known, anonymous otherwise).
* A `from` naming another identity is rejected as a spoof: in the target the
* broker seals the sender from the wallet's own key, so a client cannot forge
* another's identity. This check is redundant once the seal enforces it, but
* until then it closes the spoof the shared wallet would otherwise allow.
*/
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
const current = getCurrentUser();
let from: PrincipalId | null;
if (opts.from === undefined) {
from = current; // default: stamp the current identity
} else if (opts.from === null) {
from = null; // explicit anonymous deposit
} else if (opts.from === current) {
from = opts.from; // identifying as self — allowed
} else {
throw new Error(
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
"(anonymous) — depositing as another principal is a spoof.",
);
}
const ts = opts.ts ?? Date.now();
const sid = await sessionId();
// A unique subject per deposit (in the inbox graph) — no collisions.
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
const fromTriple =
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
// NO explicit `GRAPH <…>` wrapper — write the anchored DEFAULT graph:
// `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's
// default graph (same shape as read-model.ts readDoc/readUnion). This is the
// CANONICAL, always-safe shape and the one the anchored default-graph read
// queries. (Not a round-trip necessity on the current broker: the e2e harness
// `packages/client/e2e/` verified that an anchored `GRAPH <plainNuri>` write
// ALSO round-trips here — it resolves to the same repo graph, no phantom graph.
// The no-GRAPH form is kept as a simplicity/safety convention; re-verify with
// that harness if the broker version changes.)
const update = `
INSERT DATA {
<${subject}> a <${P.type}> ;
<${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} .
}`;
// A deposit crosses the boundary on purpose — see docs.depositInto.
await depositInto(sid, update, targetInbox, "deposit");
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
// who deposited WHAT into which inbox — the decoded payload, not just the
// triple-write. Gated by the same access-log flag; skip the JSON work when off.
if (accessLogEnabled()) {
logAccess(
"WRITE",
targetInbox,
"inbox deposit",
" from=" + (from ?? "anonymous") + " payload=" + summarizePayload(opts.payload ?? null),
);
}
}
/**
* Deposit into the inbox of a DOCUMENT — resolve where, then deposit there.
*
* The call an app makes to reach a document's owner: it needs the document (which it
* must be able to read) and nothing else. Where the inbox is, and whether the owner
* ever opened one, are the library's business.
*
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
* to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody`
* carries no document (`engine/net/src/types.rs:4265`), because the address already
* identifies it. Tagging deposits with their document would be an invention consumers
* would have to unlearn at migration, so this resolves the address and stops there.
*
* @throws if the document has no inbox — its owner never opened one, so there is
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
* this path: a deposit that vanishes without an error is worse than a refusal, and it
* is exactly the bug per-document inboxes shipped with
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
*/
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void> {
const target = await documentInboxAddress(doc);
if (target === undefined) {
throw new Error(
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
"never opened one, or you cannot read the document (the address rides on it): " +
JSON.stringify(doc),
);
}
return post(target, opts);
}
// --- cap delivery ---------------------------------------------------------
/**
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it
* is the same one at all three stages: `InboxMsgContent::Link` is the message
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
* the recipient processes their inbox and files it.
*
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
* no special case on the surface — a re-delivered cap is just another Link.
*/
const LINK_KIND = "urn:ng-eventually:inbox:link";
/** Links observed during the last read of an inbox, awaiting durable filing. */
const seenByInbox = new Map<Nuri, ReadCap[]>();
function capsSeenIn(inbox: Nuri): ReadCap[] {
return seenByInbox.get(inbox) ?? [];
}
/** The cap a deposit carries, if it is a Link rather than consumer data. */
function capOfPayload(payload: unknown): ReadCap | null {
const p = payload as { kind?: unknown; cap?: unknown } | null;
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
}
/**
* Share ONE document's read cap with ONE recipient, addressed by their inbox.
*
* The unit of sharing is the DOCUMENT: never hand over a store's cap, which would
* give away everything the store contains, present and future. The recipient needs
* no dedicated operation to receive it — the cap arrives as a deposit that their
* existing {@link watch} absorbs into what they hold (see {@link read}).
*
* Reaching several recipients means calling this once per inbox, which is what the
* real model does too: each delivery is sealed to one recipient.
*
* Upstream this path is a GAP, not a disagreement — verified at both ends:
* - the field exists, `ContactDetails.read_cap: Option<ReadCap>`
* (`engine/net/src/types.rs:4233`), but building a message that carries one is
* `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`);
* - and the receiver ignores it: `InboxMsgContent::ContactDetails` writes only
* `ng:site`/`ng:protected` + `ng:*_inbox` into a fresh contact document
* (`engine/verifier/src/inbox_processor.rs:778-830`), never `details.read_cap`.
*
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
*
* The shape is right; the implementation is absent at both ends, so we emulate it
* meanwhile.
*/
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void> {
if (!hasReadCap(cap)) {
throw new Error(
"[ng-eventually] inbox.shareCap: expected a ReadCap (a NURI carrying `:r:`), " +
`got a bare reference — naming is not reading: ${JSON.stringify(cap)}`,
);
}
await post(toInbox, { payload: { kind: LINK_KIND, cap } });
}
// --- the read guard ------------------------------------------------------
/**
* Refuse to READ an inbox that is not the current wallet's.
*
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
* is how a link reaches another wallet at all — see {@link post} / {@link shareCap});
* READING one is not, and it is not symmetric with it. Since caps travel as
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
* addressed to its owner, which defeats directed sharing entirely.
*
* Anonymous owns no inbox, so it can read none — an identity has to be established
* first. At migration this disappears: an inbox is sealed to its owner's key, and
* the guard is the cryptography.
*/
async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
if (getCurrentUser() === null) {
throw new Error(
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
"session — call setCurrentUser() first. Depositing (post/shareCap) stays open.",
);
}
if (!(await isOwnInbox(targetInbox))) {
throw new Error(
`[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` +
"the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " +
"your own — otherwise the caps addressed to its owner would be collectable by " +
`whoever knows its NURI: ${JSON.stringify(targetInbox)}`,
);
}
}
// --- read --------------------------------------------------------------
/**
* Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In
* real NextGraph the recipient's own verifier applies queued messages inline as
* it processes the inbox; here this read stands in for that until the
* sealed-inbox path is available. The consumer interprets each deposit's
* `payload`.
*
* Cap deliveries ({@link shareCap}) are applied inline and NOT returned: they land
* in what the current holder holds, like the verifier applying a queued message.
* That is why receiving a cap needs no dedicated operation — a consumer already
* watching its inbox gets them, and the resulting change re-triggers the
* reads that were empty for want of that cap.
*/
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
await assertOwnInbox(targetInbox, "read");
const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
// (a cold reader that opens the repo before reading), NOT here — `inbox.watch`
// already holds the repo open via its own `subscribeDoc`, so opening a second
// bootstrap subscription from inside a watch's re-read would be redundant and can
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
// read leaves both callers correct: the watch path stays event-driven, and the
// cold direct-read path opens the repo explicitly before calling `read`.
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
// default graph, exactly where `post` writes.
const query = `
SELECT ?payload ?ts ?from WHERE {
?d a <${P.type}> ;
<${P.payload}> ?payload ;
<${P.ts}> ?ts .
OPTIONAL { ?d <${P.from}> ?from }
}`;
const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead");
const deposits: Deposit[] = [];
for (const row of readBindings(result)) {
const rawPayload = row.payload?.value ?? "null";
let payload: unknown;
try {
payload = JSON.parse(rawPayload);
} catch {
payload = rawPayload; // tolerate a non-JSON literal
}
const tsRaw = row.ts?.value ?? "0";
const ts = Number.parseInt(tsRaw, 10) || 0;
const fromValue = row.from?.value;
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
}
deposits.sort((a, b) => a.ts - b.ts);
// Links are infrastructure, not consumer data: they never reach the caller. They
// are only KEPT here (in memory, for this session) — FILING them durably is
// `processInbox`'s job, because reading an inbox must not quietly write to a
// user's store. Filing fires the registry's change signal, which is what makes a
// view that was empty for want of that cap re-read instead of staying stale.
const delivered: Deposit[] = [];
const links: ReadCap[] = [];
for (const d of deposits) {
const cap = capOfPayload(d.payload);
if (cap) {
getCaps().learn(cap);
links.push(cap);
continue;
}
delivered.push(d);
}
if (links.length > 0) seenByInbox.set(targetInbox, links);
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
// each — the exact visibility needed to trace materialization at the owner
// side. Gated by the same access-log flag; skip the JSON work when off.
if (accessLogEnabled()) {
logAccess(
"READ",
targetInbox,
"inbox materialize",
" → " + delivered.length + " message(s)" +
(deposits.length !== delivered.length
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
: ""),
);
for (const d of delivered) {
logAccess(
"READ",
targetInbox,
"inbox message",
" ts=" + d.ts + " from=" + (d.from ?? "anonymous") + " payload=" + summarizePayload(d.payload),
);
}
}
return delivered;
}
/** Alias for {@link read} — the name that reads as "process the inbox now". */
export const materialize = read;
/**
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
*
* Why this over a plain {@link read}: on a FRESH session over the persistent
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
* returns 0 deposits — even for a deposit a remote session already synced to the
* broker. Gating on the sync barrier makes the read see the synced deposits. This
* is the same cold-read heal any cold direct reader needs.
*
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
* re-read would be redundant and could race the watch's own initial-`State`
* delivery. Use this from a COLD reader (materialize-at-connection), like
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
*/
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
// Marks the cold, connection-triggered entry point in the trace — the BARRIER
// line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below
// (from the read() this wraps) follow right after, so a live session shows
// the whole owner-reconnect sequence together.
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
await assertOwnInbox(targetInbox, "readSynced");
await ensureRepoOpen(targetInbox);
return read(targetInbox);
}
/**
* PROCESS an inbox: read it, and **apply** what it contains.
*
* Applying a {@link shareCap} Link means filing it durably — `storeRegistry.addLink`,
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
* the cap survives the session. Upstream this is what a verifier does when it
* processes queued messages: an inbox is a **queue you consume**, not a store you
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
* database, and it is the thing this replaces.
*
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
* {@link read} does — Links are never surfaced.
*/
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]> {
const deposits = await readSynced(targetInbox);
// `readSynced` already put every Link in memory for this session; now make
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
// are taken from what the read just observed.
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
seenByInbox.delete(targetInbox);
return deposits;
}
/**
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
* `onDeposits` fires once on the initial state push and again on every subsequent
* change to the inbox document — a local deposit OR a broker-synced remote one.
* Returns an unsubscribe function.
*
* On each push it re-reads the full deposit list ({@link read}) and invokes
* `onDeposits` only when the deposit count changed (grew), keeping the same
* "fires on change" contract the polling watcher had — same callback signature
* and same behaviour, just event-driven instead of `setInterval`.
*
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
* there is no polling. (The inbox document is a single doc, so this is immune to
* the ORM fan-out hang — see {@link subscribeDoc}.)
*/
export function watch(
targetInbox: Nuri,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void {
let stopped = false;
let lastCount = -1;
// Re-read on every push; fire onDeposits only when the set changed (grew).
const refresh = async (): Promise<void> => {
if (stopped) return;
try {
const deposits = await read(targetInbox);
const changed = deposits.length !== lastCount;
// Owner-side processing decision: did this push actually grow the
// deposit set (→ onDeposits fires, the polyfill's stand-in for
// materialization) or was it a no-op push (→ skipped)? This is the
// exact line to check for the "must reconnect an extra time" symptom:
// a push whose read still sees the OLD count means the barrier/read
// raced the write, not that watch itself failed to fire.
if (accessLogEnabled()) {
logAccess(
"READ",
targetInbox,
"inbox watch",
" → " + deposits.length + " message(s)" + (changed ? " (materializing)" : " (unchanged, skip)"),
);
}
if (!stopped && changed) {
lastCount = deposits.length;
onDeposits(deposits);
}
} catch (error) {
console.error(accessLogPrefix() + " watch read failed:", error);
}
};
// Subscribe to the inbox document: the initial State push fires the first read
// (so onDeposits fires once immediately, as before), each later Patch a re-read.
// The ownership guard runs inside `read`, so a watch on someone else's inbox
// yields nothing but logged refusals rather than their deposits.
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
return () => {
stopped = true;
unsubscribe();
};
}