fix!: une lecture qui n'atteint pas son document echoue, elle ne repond plus vide
`fetchReadCap` rendait `false` pour trois situations sans rapport : l'emulation inerte, le document qui n'expose aucune capacite, et le reseau qu'on n'a pas pu interroger. Les trois appelants vivants lisaient ce `false` comme « pas de capacite » et transformaient une panne en affirmation sur le document : `ensureRepoOpen` renoncait en silence et laissait la lecture suivante accuser le lecteur de ne rien detenir, et `documentInboxAddress` repondait « ce document n'a pas d'inbox », ce qu'une application lit comme zero depot. Le telechargement rejette desormais jusqu'a l'appelant, avec le document nomme et la cause d'origine conservee. `false` ne veut plus dire qu'une chose : le document n'expose rien, et c'est une reponse. Le memo garde la meme asymetrie qu'avant. `documentInboxAddress` propage aussi l'echec de sa propre lecture d'adresse : le `catch` qui rendait `undefined` se justifiait par un refus que `mustNotAttempt` a deja ecarte une ligne plus haut, donc il n'avalait que des pannes. `useShapeQuery` franchit la barriere sur chaque document NOMME par `scope.graphs` avant d'ouvrir la requete : capacite demandee au store public, refus a voix haute si elle n'est pas detenue, ouverture et synchronisation. Un document nomme qu'on ne peut pas lire donnait `isSuccess` avec `data: []` apres que le filtre de capacites l'eut vide - la seule reponse que cette surface existe pour reserver a « ce scope ne contient rien ». Restent intacts et succes : `did:ng:i`, `graphs: []`, un document joignable et vide, et tout le cas inerte. `fetchReadCaps` supprime : aucun appelant, et une politique de tolerance par lot sans proprietaire au-dessus d'un appel qui rejette desormais.
This commit is contained in:
@@ -302,6 +302,14 @@ export async function restoreOwnCaps(holderKey: string): Promise<void> {
|
|||||||
*
|
*
|
||||||
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
|
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
|
||||||
* only its owner opens one, and only on its own document.
|
* only its owner opens one, and only on its own document.
|
||||||
|
*
|
||||||
|
* **`undefined` is an ANSWER, and only an answer** (`1.0.0-dev.9`). It means the document
|
||||||
|
* carries no address — nobody opened an inbox on it — or that it is not this holder's to
|
||||||
|
* see one on. It no longer means "the read did not happen": both routes by which a
|
||||||
|
* breakdown used to arrive here as `undefined` now reject. That mattered because two
|
||||||
|
* published calls turn this value into a statement an application acts on:
|
||||||
|
* `inbox.postToDocument` says "its owner never opened one", and `inbox.readForDocument`
|
||||||
|
* says zero deposits — for a document whose inbox is full.
|
||||||
*/
|
*/
|
||||||
// @provenance documentInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — the deposit-side half of the same divergence: upstream you are TOLD an address, you never read one off a document
|
// @provenance documentInboxAddress kind=divergent level=1 ref=engine/net/src/types.rs:ContactDetails — the deposit-side half of the same divergence: upstream you are TOLD an address, you never read one off a document
|
||||||
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
|
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
|
||||||
@@ -313,7 +321,9 @@ export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>
|
|||||||
// …but ask the (emulated) network first: a document in a public store serves its cap
|
// …but ask the (emulated) network first: a document in a public store serves its cap
|
||||||
// to whoever asks (public-store.ts), and "where do I deposit for this public
|
// to whoever asks (public-store.ts), and "where do I deposit for this public
|
||||||
// document" is exactly the question a third party arrives with, holding nothing but
|
// document" is exactly the question a third party arrives with, holding nothing but
|
||||||
// the reference.
|
// the reference. Its rejection is NOT caught: "the store could not be asked" is not
|
||||||
|
// "this document has no inbox", and the whole value of the answer below is that the
|
||||||
|
// two are told apart.
|
||||||
await fetchReadCap(doc);
|
await fetchReadCap(doc);
|
||||||
if (mustNotAttempt(doc)) return undefined;
|
if (mustNotAttempt(doc)) return undefined;
|
||||||
const s = await session();
|
const s = await session();
|
||||||
@@ -330,10 +340,17 @@ export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>
|
|||||||
if (a && isNuri(a)) return a;
|
if (a && isNuri(a)) return a;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Unreadable document (no cap) or not synced → no address to give. Refusing to
|
// **Propagates a failed read** (`1.0.0-dev.9`). This used to answer `undefined`, on
|
||||||
// read is the boundary doing its job, not an error to propagate here.
|
// the ground that "refusing to read is the boundary doing its job" — but the boundary
|
||||||
|
// has already had its say at `mustNotAttempt` above, which returned before reaching
|
||||||
|
// here. What this catch could still meet is a breakdown: a repo that would not open,
|
||||||
|
// a broker that did not answer, a query that rejected. Answering `undefined` for that
|
||||||
|
// told the caller the document has no inbox, which is the failure-as-absence this
|
||||||
|
// path has paid for before. A caller that genuinely prefers to carry on catches it.
|
||||||
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
|
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
// No address on the Header branch: nobody opened an inbox on this document. An answer.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,8 +188,16 @@ async function syncSession(): Promise<void> {
|
|||||||
* `"synced"`). Idempotent: a repo already opened (or in flight) is not re-opened.
|
* `"synced"`). Idempotent: a repo already opened (or in flight) is not re-opened.
|
||||||
*
|
*
|
||||||
* Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g.
|
* Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g.
|
||||||
* the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged.
|
* the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged. The
|
||||||
* Never throws; a failed open just leaves the read to behave as it did before.
|
* open itself never throws: a `doc_subscribe` that never pushes a `State` is bounded by
|
||||||
|
* the fallback timeout and leaves the read to behave as it did before.
|
||||||
|
*
|
||||||
|
* **It DOES throw when the public store could not be asked** (`1.0.0-dev.9`). That is the
|
||||||
|
* one step here that can fail to ANSWER, and until then it answered `false` — "this
|
||||||
|
* document exposes no cap" — so the open declined silently and the anchored read that
|
||||||
|
* followed refused the reader for holding no cap, which was true but was not the reason.
|
||||||
|
* A caller that means to carry on regardless says so with a `catch` of its own; none does
|
||||||
|
* today, because every one of them is about to read the document.
|
||||||
*
|
*
|
||||||
* `holderKey` names WHOSE possession decides, when it is not the connected identity's —
|
* `holderKey` names WHOSE possession decides, when it is not the connected identity's —
|
||||||
* the emulated inbox processor opening an inbox repo for its owner during someone else's
|
* the emulated inbox processor opening an inbox repo for its owner during someone else's
|
||||||
@@ -205,7 +213,10 @@ export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise<vo
|
|||||||
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
|
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
|
||||||
// before deciding whether we may touch it, or the answer would be "no" purely for
|
// before deciding whether we may touch it, or the answer would be "no" purely for
|
||||||
// want of asking, and a bare reference to a public document would never suffice.
|
// want of asking, and a bare reference to a public document would never suffice.
|
||||||
// Memoised and inert once the cap is held (see public-store.ts).
|
// Memoised and inert once the cap is held (see public-store.ts). REJECTS when the
|
||||||
|
// store could not be asked at all, which is deliberately not the same event as "this
|
||||||
|
// document exposes no cap": the open has not happened and the caller must not read as
|
||||||
|
// though it had.
|
||||||
if (holderKey === undefined) await fetchReadCap(nuri);
|
if (holderKey === undefined) await fetchReadCap(nuri);
|
||||||
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
|
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
|
||||||
// pulls its state. A user that holds no cap for it has no business asking.
|
// pulls its state. A user that holds no cap for it has no business asking.
|
||||||
|
|||||||
@@ -181,10 +181,21 @@ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
|
|||||||
* Nothing is asked when the cap is already held: a document you can read needs no
|
* Nothing is asked when the cap is already held: a document you can read needs no
|
||||||
* fetching, and skipping it keeps the ordinary path free of physical reads.
|
* fetching, and skipping it keeps the ordinary path free of physical reads.
|
||||||
*
|
*
|
||||||
* Never throws. Two different negatives reach the caller as the same `false`, and the
|
* **Two outcomes only, and a THIRD that is not an outcome.** `false` means the network
|
||||||
* caller is right not to tell them apart — it obtained no cap either way. They are kept
|
* answered and the document exposes no cap — an absence, and the normal case. `true` means
|
||||||
* apart where it matters, in the memo: an absence is remembered, a failure is not (see
|
* a cap was obtained and filed. A download that REJECTED is neither: it REJECTS here too.
|
||||||
* the note on {@link attempted}).
|
*
|
||||||
|
* It used to answer `false` for that as well, which is the defect this function was the
|
||||||
|
* head of. `false` then meant "no cap" to three call sites at once, and each of them
|
||||||
|
* turned a breakdown into a statement about the document: {@link ensureRepoOpen} declined
|
||||||
|
* to open and let the read that followed blame the reader for holding no cap, and
|
||||||
|
* `documentInboxAddress` answered "this document has no inbox", which an application reads
|
||||||
|
* as zero deposits. `downloadReadCap` below produces the distinction deliberately and says
|
||||||
|
* why; collapsing it one line later destroyed it. A failure must not read as an absence.
|
||||||
|
*
|
||||||
|
* The memo keeps the same asymmetry it always did, and it is now the same asymmetry: an
|
||||||
|
* absence is remembered, a failure is not (see the note on {@link attempted}), so the next
|
||||||
|
* caller asks the network again instead of replaying a rejection.
|
||||||
*/
|
*/
|
||||||
// @provenance fetchReadCap kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the type DECLARES the cap is downloaded from the outer overlay if the brokers allow; it is constructed nowhere, both `PinRepo` constructors hard-code `expose_outer: false`, and `ExtTopicSyncReq` is `unimplemented!()`
|
// @provenance fetchReadCap kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the type DECLARES the cap is downloaded from the outer overlay if the brokers allow; it is constructed nowhere, both `PinRepo` constructors hard-code `expose_outer: false`, and `ExtTopicSyncReq` is `unimplemented!()`
|
||||||
export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
|
export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
|
||||||
@@ -200,7 +211,10 @@ export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
|
|||||||
pending = downloadReadCap(doc);
|
pending = downloadReadCap(doc);
|
||||||
attempted.set(doc, pending);
|
attempted.set(doc, pending);
|
||||||
// A failure is not an answer, so it must not be filed as one: drop the memo the
|
// A failure is not an answer, so it must not be filed as one: drop the memo the
|
||||||
// moment the download rejects, and the next caller reaches the broker again.
|
// moment the download rejects, and the next caller reaches the broker again. The
|
||||||
|
// report stays even though the rejection now reaches the caller: this handler runs
|
||||||
|
// once per download however many callers shared it, and it runs at all when every one
|
||||||
|
// of them has given up, which is the only trace left in that case.
|
||||||
// Attached HERE — once per download, before anybody awaits — so the eviction and the
|
// Attached HERE — once per download, before anybody awaits — so the eviction and the
|
||||||
// report each happen exactly once however many callers share this one in-flight
|
// report each happen exactly once however many callers share this one in-flight
|
||||||
// fetch, and happen at all even if every one of them has already given up. The
|
// fetch, and happen at all even if every one of them has already given up. The
|
||||||
@@ -215,12 +229,24 @@ export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
|
|||||||
let cap: ReadCap | null;
|
let cap: ReadCap | null;
|
||||||
try {
|
try {
|
||||||
cap = await pending;
|
cap = await pending;
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Reported and forgotten by the handler attached where the download started, just
|
// The download REJECTED: the repo would not open, the broker did not answer, the read
|
||||||
// above — not swallowed here. The answer to the caller is the same as for an absence
|
// threw. Whether this document may be read is UNKNOWN, and that is not what `false`
|
||||||
// (no cap was obtained), but the memo now holds nothing for this document, so the
|
// says. Rethrown rather than returned so the caller's read fails instead of continuing
|
||||||
// next call asks the network instead of replaying this failure.
|
// on a document it never obtained anything for — upstream's observable is that a read
|
||||||
return false;
|
// rejects or it does not, and this is the side of it that was missing.
|
||||||
|
//
|
||||||
|
// Wrapped so the reason survives the trip: the message a caller would otherwise meet
|
||||||
|
// is `assertMayReach`'s "you do not hold this document's cap", which is a different
|
||||||
|
// event calling for a different action (give up, rather than retry). The original is
|
||||||
|
// on `cause`, and the handler attached where the download started has already logged
|
||||||
|
// it and dropped the memo, so the next caller asks the network again.
|
||||||
|
throw new Error(
|
||||||
|
"[ng-eventually] fetchReadCap: the public store could not be asked for this " +
|
||||||
|
"document's read cap, so whether it may be read is UNKNOWN — this is not " +
|
||||||
|
`"the document exposes no cap". ${JSON.stringify(doc)}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (cap === null) return false;
|
if (cap === null) return false;
|
||||||
// Filed for whoever is connected NOW, on every call — the memo spares the round-trip,
|
// Filed for whoever is connected NOW, on every call — the memo spares the round-trip,
|
||||||
@@ -266,13 +292,10 @@ async function downloadReadCap(doc: Nuri): Promise<ReadCap | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// `fetchReadCaps(docs)` was REMOVED in `1.0.0-dev.9`. It was the batch form — a
|
||||||
* Ask for a SET of documents' caps, in parallel — what a batch read does before it
|
// `Promise.all` over the singular, described as "independent and tolerant" — and it had
|
||||||
* decides which documents it may touch. Each fetch is independent and tolerant.
|
// ZERO callers, the last one having gone with `readUnion` in `1.0.0-dev.8`. Keeping it
|
||||||
*/
|
// would have left an unowned tolerance policy standing over a call that now rejects:
|
||||||
// @provenance fetchReadCaps kind=declared-not-wired level=1 ref=engine/net/src/types.rs:PublicRepoLinkV0 — the batch form of the same unwired mechanism
|
// "each fetch is independent" is exactly the skip-and-carry-on this line of work has
|
||||||
export async function fetchReadCaps(docs: Nuri[]): Promise<void> {
|
// removed twice. Whoever needs several caps asks for each, at the call site that knows
|
||||||
const unique = [...new Set(docs.filter(Boolean))];
|
// what a missing one costs it. Internal throughout, so nothing published moves.
|
||||||
if (unique.length === 0) return;
|
|
||||||
await Promise.all(unique.map((d) => fetchReadCap(d)));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ export async function sparqlQuery(
|
|||||||
// public store gives its cap to whoever asks, and this is a door an application can
|
// public store gives its cap to whoever asks, and this is a door an application can
|
||||||
// reach with nothing but a bare reference. Inert once the cap is held, memoised
|
// reach with nothing but a bare reference. Inert once the cap is held, memoised
|
||||||
// otherwise — see public-store.ts.
|
// otherwise — see public-store.ts.
|
||||||
|
//
|
||||||
|
// It REJECTS when the store could not be asked, and that rejection is deliberately not
|
||||||
|
// caught here. The refusal below would otherwise take its place and report the wrong
|
||||||
|
// event: "you do not hold this document's cap" for "nobody answered". Both stop the
|
||||||
|
// read; only one of them tells the reader to try again.
|
||||||
if (anchor !== undefined) {
|
if (anchor !== undefined) {
|
||||||
await fetchReadCap(anchor);
|
await fetchReadCap(anchor);
|
||||||
assertMayReach(anchor, "docs.sparqlQuery");
|
assertMayReach(anchor, "docs.sparqlQuery");
|
||||||
|
|||||||
@@ -73,6 +73,38 @@
|
|||||||
* bounded by what it exposes today; the day the ORM surfaces a failed start, this is
|
* bounded by what it exposes today; the day the ORM surfaces a failed start, this is
|
||||||
* where it lands.
|
* where it lands.
|
||||||
*
|
*
|
||||||
|
* -- A named document is REACHED before the query opens, since `1.0.0-dev.9` --------
|
||||||
|
* `scope.graphs` does not only narrow a search: each entry NAMES a document, and naming
|
||||||
|
* one is the same act `docs.sparqlQuery`'s anchor is. So each named document is put
|
||||||
|
* through the same two steps that call makes - the (emulated) public store is asked for
|
||||||
|
* its cap, and the document is refused out loud if the cap is not held - plus the
|
||||||
|
* cold-start open every other read path in this library crosses (`ensureRepoOpen`).
|
||||||
|
*
|
||||||
|
* What it repairs is this surface's own version of the failure it exists against. A named
|
||||||
|
* document whose cap the holder does not hold used to reach the engine, come back, and be
|
||||||
|
* emptied by the capability filter below, publishing `isSuccess` with `data: []` - "this
|
||||||
|
* document holds nothing of that shape" for "you cannot read this document", while the
|
||||||
|
* anchored read of the SAME document refuses. A named document this session has never
|
||||||
|
* synced published the same snapshot for a document that is not empty. Both are now
|
||||||
|
* `isError`, which is what `isError` has always meant here: the current answer is UNKNOWN.
|
||||||
|
*
|
||||||
|
* **What is deliberately NOT guarded, and each is an answer rather than a failure:**
|
||||||
|
* - `graphs: "did:ng:i"` or `""` - the whole local dataset. The application named no
|
||||||
|
* document, so there is no cap question to ask; the capability filter is the right
|
||||||
|
* and only answer, exactly as the broker delivering only what your keys open is.
|
||||||
|
* - `graphs: []` - no objects, by the SDK's own definition. `isSuccess` with `data: []`.
|
||||||
|
* - a named document held and synced that holds nothing of the shape - `isSuccess` with
|
||||||
|
* `data: []`, which is the distinction this whole surface exists to publish.
|
||||||
|
* - the INERT case: before the first cap is issued nothing is enforcing, the public
|
||||||
|
* store is not asked at all, and `assertMayReach` passes for everything.
|
||||||
|
*
|
||||||
|
* A scope naming SEVERAL documents fails as a whole when one of them cannot be reached,
|
||||||
|
* rather than answering the others. A short list is the same lie as an empty one, and it
|
||||||
|
* is the tolerance `readUnion` was removed for in `1.0.0-dev.8`.
|
||||||
|
*
|
||||||
|
* The barrier is why the open below is asynchronous: it prolongs `isPending`, and nothing
|
||||||
|
* else about the states moves.
|
||||||
|
*
|
||||||
* -- An observable cannot un-emit: `data` survives an error -----------------
|
* -- 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
|
* 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
|
* subscriber that rendered it, so its failure snapshot has to say something about
|
||||||
@@ -84,7 +116,10 @@
|
|||||||
|
|
||||||
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
|
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
|
||||||
import { filterReadable } from "../emulated-verifier/read-filter";
|
import { filterReadable } from "../emulated-verifier/read-filter";
|
||||||
import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types";
|
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||||||
|
import { assertMayReach } from "../emulated-verifier/reach";
|
||||||
|
import { isNuri } from "../model/nuri";
|
||||||
|
import type { Nuri, OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types";
|
||||||
// The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org`
|
// The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org`
|
||||||
// import is added here). The provenance of what is PUBLISHED is recorded in `index.ts`.
|
// import is added here). The provenance of what is PUBLISHED is recorded in `index.ts`.
|
||||||
import type { Scope } from "@ng-org/orm";
|
import type { Scope } from "@ng-org/orm";
|
||||||
@@ -135,6 +170,32 @@ export interface ShapeObservable<T extends BaseType = BaseType> {
|
|||||||
refetch(): void;
|
refetch(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole local dataset, as the SDK spells it. `normalizeScope` maps `""` onto this
|
||||||
|
* very string (`@ng-org/orm`'s `types.ts`), so both forms are the same marker and neither
|
||||||
|
* names a document.
|
||||||
|
*/
|
||||||
|
const WHOLE_DATASET = "did:ng:i";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DOCUMENTS a scope NAMES - the only entries a capability question can be asked
|
||||||
|
* about.
|
||||||
|
*
|
||||||
|
* Flattened the way the SDK flattens `graphs` (a bare string is one entry), then reduced
|
||||||
|
* to what identifies a document: the whole-dataset marker is dropped because it names
|
||||||
|
* none, and so is anything that is not a NextGraph reference - an ordinary IRI names no
|
||||||
|
* repo this library could hold a cap for, and the engine is left to match it or not.
|
||||||
|
* Deduplicated, so a scope repeating a document opens it once.
|
||||||
|
*/
|
||||||
|
function namedDocuments(scope: Scope): Nuri[] {
|
||||||
|
const out: Nuri[] = [];
|
||||||
|
for (const g of [scope.graphs].flat()) {
|
||||||
|
if (typeof g !== "string" || g === "" || g === WHOLE_DATASET) continue;
|
||||||
|
if (isNuri(g) && !out.includes(g)) out.push(g);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
||||||
* `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the
|
* `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the
|
||||||
@@ -287,40 +348,77 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross the barrier on every document the scope NAMES, before the query opens - the
|
||||||
|
* same two steps `docs.sparqlQuery` takes on its anchor, in the same order.
|
||||||
|
*
|
||||||
|
* `ensureRepoOpen` asks the (emulated) public store for the document's cap, and opens
|
||||||
|
* and syncs the repo when it may be touched; it declines SILENTLY when it may not,
|
||||||
|
* which is right for a caller that has another way to find out. This one has none, so
|
||||||
|
* `assertMayReach` says it out loud immediately after - without that, an unheld
|
||||||
|
* document would simply produce no objects and read as a document holding none.
|
||||||
|
*
|
||||||
|
* Sequential rather than parallel, deliberately: the first document that cannot be
|
||||||
|
* reached is the one reported, and there is no batch policy to own (`ensureReposOpen`,
|
||||||
|
* the parallel form, was removed in `1.0.0-dev.8`/`.9` for having none worth keeping).
|
||||||
|
* Each open is memoised per session, so a re-subscribe costs a map hit.
|
||||||
|
*/
|
||||||
|
async function reachNamedDocuments(): Promise<void> {
|
||||||
|
for (const doc of namedDocuments(scope)) {
|
||||||
|
await ensureRepoOpen(doc);
|
||||||
|
assertMayReach(doc, "useShapeQuery");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a shape query for `(shapeType, scope)` and wire its two signals.
|
* Open a shape query for `(shapeType, scope)` and wire its two signals.
|
||||||
*
|
*
|
||||||
* Supersedes whatever was open: the previous generation's callbacks are dropped on
|
* Supersedes whatever was open: the previous generation's callbacks are dropped on
|
||||||
* arrival rather than raced, so a slow answer can never clobber a newer question.
|
* arrival rather than raced, so a slow answer can never clobber a newer question. The
|
||||||
|
* barrier above runs first, which is why the body is asynchronous - every step past an
|
||||||
|
* `await` re-checks that it is still the current generation before touching anything,
|
||||||
|
* so a `refetch` or a teardown during the barrier leaves no query behind.
|
||||||
*/
|
*/
|
||||||
function open(): void {
|
function open(): void {
|
||||||
closeQuery();
|
closeQuery();
|
||||||
const gen = ++generation;
|
const gen = ++generation;
|
||||||
let handle: OrmSubscriptionHandle;
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
handle = shapeQuery()(shapeType, scope);
|
await reachNamedDocuments();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUnknown(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
query = handle;
|
|
||||||
const onChange = (): void => publish(handle, gen);
|
|
||||||
onQueryChange = onChange;
|
|
||||||
handle.addChangeListener(onChange);
|
|
||||||
handle.readyPromise.then(
|
|
||||||
() => {
|
|
||||||
if (gen !== generation) return;
|
|
||||||
ready = true;
|
|
||||||
publish(handle, gen);
|
|
||||||
},
|
|
||||||
(error: unknown) => {
|
|
||||||
// Nothing upstream rejects this promise today (see the module header); the
|
|
||||||
// rejection path is wired all the same, because a state nobody handles is how
|
|
||||||
// a failure becomes silence the day the ORM starts reporting one.
|
|
||||||
if (gen !== generation) return;
|
if (gen !== generation) return;
|
||||||
setUnknown(error);
|
setUnknown(error);
|
||||||
},
|
return;
|
||||||
);
|
}
|
||||||
|
// Superseded while the barrier was crossing: drop this open rather than leaving a
|
||||||
|
// subscription nothing will ever close.
|
||||||
|
if (gen !== generation) return;
|
||||||
|
let handle: OrmSubscriptionHandle;
|
||||||
|
try {
|
||||||
|
handle = shapeQuery()(shapeType, scope);
|
||||||
|
} catch (error) {
|
||||||
|
setUnknown(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
query = handle;
|
||||||
|
const onChange = (): void => publish(handle, gen);
|
||||||
|
onQueryChange = onChange;
|
||||||
|
handle.addChangeListener(onChange);
|
||||||
|
handle.readyPromise.then(
|
||||||
|
() => {
|
||||||
|
if (gen !== generation) return;
|
||||||
|
ready = true;
|
||||||
|
publish(handle, gen);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
// Nothing upstream rejects this promise today (see the module header); the
|
||||||
|
// rejection path is wired all the same, because a state nobody handles is how
|
||||||
|
// a failure becomes silence the day the ORM starts reporting one.
|
||||||
|
if (gen !== generation) return;
|
||||||
|
setUnknown(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
function start(): void {
|
function start(): void {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* document-inbox-surfaces-failure.test.ts - "this document has no inbox" must be an ANSWER.
|
||||||
|
*
|
||||||
|
* -- The defect this pins, and why it is worse than it looks ---------------
|
||||||
|
* `documentInboxAddress` reads a document's Header branch for the address to deposit at,
|
||||||
|
* and answers `undefined` when there is none. Two published calls turn that value into a
|
||||||
|
* statement an application acts on: `inbox.postToDocument` says "its owner never opened
|
||||||
|
* one", and `inbox.readForDocument` says zero deposits.
|
||||||
|
*
|
||||||
|
* Until `1.0.0-dev.9` it answered `undefined` for two things that are not that. The public
|
||||||
|
* store could not be asked for the document's cap, and `fetchReadCap` reported a breakdown
|
||||||
|
* with the same `false` a document exposing no cap answers with; or the address read itself
|
||||||
|
* broke, and a `catch` returned `undefined`. Either way a depositor was told the document
|
||||||
|
* has no inbox, and a reader was handed an empty list - for a document whose inbox is full.
|
||||||
|
* That is the failure-as-absence this package has closed roughly eight times.
|
||||||
|
*
|
||||||
|
* -- What is under test, and what must NOT move ---------------------------
|
||||||
|
* The pair is the point, and neither half proves anything alone. A document nobody opened
|
||||||
|
* an inbox on still answers empty, and still without an error: **an absence is an answer**,
|
||||||
|
* and a repair that turned it into a failure would only have moved the lie. The tests below
|
||||||
|
* run BOTH over the same harness, so one cannot be satisfied at the other's expense.
|
||||||
|
*
|
||||||
|
* -- The fault is the broker's --------------------------------------------
|
||||||
|
* Nothing here reaches into the library to make one of its functions throw. The world is
|
||||||
|
* built through the surface - Alice writes a public note and opens it for messages - and
|
||||||
|
* then the fake broker stops answering for one named repo (`wallet-fake._failReadsOn`),
|
||||||
|
* which is what `resolve_target_for_sparql` does upstream for a repo the verifier does not
|
||||||
|
* hold: it rejects, and never answers 0 rows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
|
||||||
|
import { inbox as inboxSurface, storeRegistry } from "../src/index";
|
||||||
|
import { bootPage, forgetEverything, signIn, type Quad } from "./wallet-fake";
|
||||||
|
import type { Nuri } from "../src/model/types";
|
||||||
|
|
||||||
|
/** The broker's own cold start, so the barrier is a real one here too. */
|
||||||
|
const COLD = { unsyncedUntilSubscribed: true } as const;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
forgetEverything();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
forgetEverything();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a document's inbox address says nothing it does not know", () => {
|
||||||
|
test("a broker that cannot answer for the document is a REJECTION, not 'no inbox'", async () => {
|
||||||
|
const quads: Quad[] = [];
|
||||||
|
const fake = bootPage(quads, COLD);
|
||||||
|
await signIn("alice");
|
||||||
|
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||||
|
await storeRegistry.openDocumentInbox(note);
|
||||||
|
|
||||||
|
// Bob holds nothing: the public store is what would serve him the note's cap, and it
|
||||||
|
// is exactly that round-trip the broker now cannot answer.
|
||||||
|
await signIn("bob");
|
||||||
|
fake._failReadsOn.add(note);
|
||||||
|
|
||||||
|
// Before `1.0.0-dev.9` this resolved to `undefined` and `postToDocument` turned it
|
||||||
|
// into "its owner never opened one" - a statement about Alice's note, made out of a
|
||||||
|
// broker outage. Bob would have gone away believing there was nowhere to write.
|
||||||
|
await expect(
|
||||||
|
inboxSurface.postToDocument(note, { payload: { text: "j'apporte le cafe" } }),
|
||||||
|
).rejects.toThrow(/UNKNOWN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the reader is told too: a failed address read does not read as zero deposits", async () => {
|
||||||
|
const quads: Quad[] = [];
|
||||||
|
const fake = bootPage(quads, COLD);
|
||||||
|
await signIn("alice");
|
||||||
|
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||||
|
await storeRegistry.openDocumentInbox(note);
|
||||||
|
|
||||||
|
// Alice's own page, so the cap question never arises and `fetchReadCap` is a memo hit:
|
||||||
|
// what breaks is the ADDRESS read itself, which used to be caught and answered
|
||||||
|
// `undefined`. This is the second of the two routes, and it needs its own test because
|
||||||
|
// repairing the first one leaves it untouched.
|
||||||
|
fake._failReadsOn.add(note);
|
||||||
|
await expect(inboxSurface.readForDocument(note)).rejects.toThrow(/RepoNotFound/);
|
||||||
|
await expect(inboxSurface.readSyncedForDocument(note)).rejects.toThrow(/RepoNotFound/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and a document nobody opened an inbox on still answers EMPTY, and succeeds", async () => {
|
||||||
|
const quads: Quad[] = [];
|
||||||
|
bootPage(quads, COLD);
|
||||||
|
await signIn("alice");
|
||||||
|
// Created through the surface and never opened for messages: a genuine absence, on a
|
||||||
|
// broker answering perfectly well. This must stay a success, or the repair above
|
||||||
|
// would have swapped one lie for another.
|
||||||
|
const bare: Nuri = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||||
|
|
||||||
|
expect(await inboxSurface.readForDocument(bare)).toEqual([]);
|
||||||
|
expect(await inboxSurface.readSyncedForDocument(bare)).toEqual([]);
|
||||||
|
// And the deposit side says the same thing in its own words - a refusal that names
|
||||||
|
// the real state, not an outage.
|
||||||
|
await expect(
|
||||||
|
inboxSurface.postToDocument(bare, { payload: { text: "hello" } }),
|
||||||
|
).rejects.toThrow(/has no inbox/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -201,6 +201,14 @@ test("resetting the caps forgets the memo — a stale yes would hand back what i
|
|||||||
// transient outage recorded "no capability" for that document until the session ended.
|
// transient outage recorded "no capability" for that document until the session ended.
|
||||||
// Deposits being consumed, the deposit was retired before its arm read anything — and
|
// Deposits being consumed, the deposit was retired before its arm read anything — and
|
||||||
// every re-deposit sent to repair it met the same memoised `null` and was destroyed too.
|
// every re-deposit sent to repair it met the same memoised `null` and was destroyed too.
|
||||||
|
//
|
||||||
|
// SECOND HALF (`1.0.0-dev.9`). That repair kept the two negatives apart in the MEMO and
|
||||||
|
// still handed the caller one value for both: a failure and an absence were both `false`.
|
||||||
|
// The tests below said so, and what they were really asserting was the memo's arithmetic
|
||||||
|
// - which is why every `asksAbout` count here is unchanged. What moved is the ANSWER: a
|
||||||
|
// download that could not happen now rejects, because three call sites read `false` as
|
||||||
|
// "this document exposes no cap" and turned a breakdown into a statement about the
|
||||||
|
// document. A genuine absence still answers `false`, and still only asks once.
|
||||||
|
|
||||||
/** How many times the network was asked ABOUT `nuri` — anchored reads only, so the
|
/** How many times the network was asked ABOUT `nuri` — anchored reads only, so the
|
||||||
* connection work happening in the background on other documents does not count. */
|
* connection work happening in the background on other documents does not count. */
|
||||||
@@ -216,7 +224,8 @@ test("a fetch that FAILED is asked again, and the cap arrives once the broker re
|
|||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
|
|
||||||
unreachable.add(PUB); // the broker cannot answer for this document
|
unreachable.add(PUB); // the broker cannot answer for this document
|
||||||
expect(await fetchReadCap(PUB)).toBe(false);
|
// NOT `false`: "the store could not be asked" is not "the document exposes no cap".
|
||||||
|
await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/);
|
||||||
expect(asksAbout(sparql_query, PUB)).toBe(1);
|
expect(asksAbout(sparql_query, PUB)).toBe(1);
|
||||||
expect(getCaps().capFor(PUB)).toBeUndefined();
|
expect(getCaps().capFor(PUB)).toBeUndefined();
|
||||||
|
|
||||||
@@ -234,14 +243,16 @@ test("an absence is remembered, a failure is not — the two negatives are not t
|
|||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
|
|
||||||
const absent = "did:ng:o:exposes-nothing" as Nuri;
|
const absent = "did:ng:o:exposes-nothing" as Nuri;
|
||||||
expect(await fetchReadCap(absent)).toBe(false);
|
expect(await fetchReadCap(absent)).toBe(false); // an ANSWER, and `false` is what it is
|
||||||
expect(await fetchReadCap(absent)).toBe(false);
|
expect(await fetchReadCap(absent)).toBe(false);
|
||||||
expect(asksAbout(sparql_query, absent)).toBe(1); // read once, and the answer stands
|
expect(asksAbout(sparql_query, absent)).toBe(1); // read once, and the answer stands
|
||||||
|
|
||||||
const unreached = "did:ng:o:cannot-ask" as Nuri;
|
const unreached = "did:ng:o:cannot-ask" as Nuri;
|
||||||
unreachable.add(unreached);
|
unreachable.add(unreached);
|
||||||
expect(await fetchReadCap(unreached)).toBe(false);
|
// Not an answer at all, so it does not get to look like one. The two negatives now
|
||||||
expect(await fetchReadCap(unreached)).toBe(false);
|
// differ in what the caller SEES, not only in what the memo keeps.
|
||||||
|
await expect(fetchReadCap(unreached)).rejects.toThrow(/UNKNOWN/);
|
||||||
|
await expect(fetchReadCap(unreached)).rejects.toThrow(/UNKNOWN/);
|
||||||
expect(asksAbout(sparql_query, unreached)).toBe(2); // never answered, so asked every time
|
expect(asksAbout(sparql_query, unreached)).toBe(2); // never answered, so asked every time
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,11 +263,18 @@ test("concurrent askers share ONE failing round-trip, and exactly one re-ask fol
|
|||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
|
|
||||||
unreachable.add(PUB);
|
unreachable.add(PUB);
|
||||||
const answers = await Promise.all([fetchReadCap(PUB), fetchReadCap(PUB), fetchReadCap(PUB)]);
|
// `allSettled`, so all three outcomes are asserted rather than only the first: a
|
||||||
expect(answers).toEqual([false, false, false]);
|
// failure reaching one asker and not the others would be a worse bug than the one this
|
||||||
|
// test was written for.
|
||||||
|
const answers = await Promise.allSettled([
|
||||||
|
fetchReadCap(PUB),
|
||||||
|
fetchReadCap(PUB),
|
||||||
|
fetchReadCap(PUB),
|
||||||
|
]);
|
||||||
|
expect(answers.map((a) => a.status)).toEqual(["rejected", "rejected", "rejected"]);
|
||||||
expect(asksAbout(sparql_query, PUB)).toBe(1); // one in-flight fetch served all three
|
expect(asksAbout(sparql_query, PUB)).toBe(1); // one in-flight fetch served all three
|
||||||
|
|
||||||
expect(await fetchReadCap(PUB)).toBe(false);
|
await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/);
|
||||||
expect(asksAbout(sparql_query, PUB)).toBe(2); // and the failure left nothing behind
|
expect(asksAbout(sparql_query, PUB)).toBe(2); // and the failure left nothing behind
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,7 +289,7 @@ test("a cap obtained after a failure is filed for whoever is connected THEN", as
|
|||||||
|
|
||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
unreachable.add(PUB);
|
unreachable.add(PUB);
|
||||||
expect(await fetchReadCap(PUB)).toBe(false);
|
await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/);
|
||||||
|
|
||||||
unreachable.delete(PUB);
|
unreachable.delete(PUB);
|
||||||
setCurrentUser("carol");
|
setCurrentUser("carol");
|
||||||
@@ -281,3 +299,22 @@ test("a cap obtained after a failure is filed for whoever is connected THEN", as
|
|||||||
setCurrentUser("bob");
|
setCurrentUser("bob");
|
||||||
expect(getCaps().capFor(PUB)).toBeUndefined(); // bob only ever met the outage
|
expect(getCaps().capFor(PUB)).toBeUndefined(); // bob only ever met the outage
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The rejection has to be USABLE, or a caller meeting it learns no more than it did from
|
||||||
|
// `false`: it must name the document it is about, and it must not lose what actually went
|
||||||
|
// wrong underneath. `cause` is where the broker's own words survive.
|
||||||
|
test("the rejection names the document and keeps the original failure on `cause`", async () => {
|
||||||
|
const { unreachable } = inject();
|
||||||
|
armEmulation();
|
||||||
|
setCurrentUser("bob");
|
||||||
|
|
||||||
|
const doc = "did:ng:o:outage" as Nuri;
|
||||||
|
unreachable.add(doc);
|
||||||
|
const error = await fetchReadCap(doc).then(
|
||||||
|
() => null,
|
||||||
|
(e: unknown) => e,
|
||||||
|
);
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain(doc);
|
||||||
|
expect(String((error as Error).cause)).toContain("cannot serve that overlay");
|
||||||
|
});
|
||||||
|
|||||||
@@ -498,6 +498,117 @@ describe("useShapeQuery", () => {
|
|||||||
unsub();
|
unsub();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -- a NAMED document is reached before the query opens, since `1.0.0-dev.9` ---
|
||||||
|
//
|
||||||
|
// `graphs` does not only narrow a search: each entry NAMES a document, and this surface
|
||||||
|
// used to name one, hand it to the engine, and empty the result with the capability
|
||||||
|
// filter - publishing `isSuccess` with `data: []`, which is the one snapshot it exists
|
||||||
|
// to reserve for "this scope holds nothing". The four cases below are the distinct ones:
|
||||||
|
// the document that cannot be reached, the one that can and is empty, the list where
|
||||||
|
// only one member fails, and the emulation not yet in force.
|
||||||
|
|
||||||
|
it("(l) a named document the holder holds no capability for is isError, not an empty success", async () => {
|
||||||
|
const ng = makeFake();
|
||||||
|
const orm = makeOrm();
|
||||||
|
inject(ng, orm);
|
||||||
|
setCurrentUser("kate");
|
||||||
|
// Creating one document is what puts the emulation in force - the cap registry is
|
||||||
|
// inert until a first cap exists, and (o) below is the case where it still is.
|
||||||
|
await createEntityDoc("kate", "protected");
|
||||||
|
const theirs = "did:ng:o:someone-elses";
|
||||||
|
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
||||||
|
expect(getCaps().isEnforcing()).toBe(true);
|
||||||
|
|
||||||
|
const obs = useShapeQuery(EventShape, { graphs: [theirs] });
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const snap = obs.getSnapshot();
|
||||||
|
expect(snap.isError).toBe(true);
|
||||||
|
expect(snap.isSuccess).toBe(false);
|
||||||
|
expect(snap.isPending).toBe(false);
|
||||||
|
expect(String((snap.error as Error).message)).toContain("does not hold this document");
|
||||||
|
// RULE 2, and it is the half a filter could never give: the question was not put at
|
||||||
|
// all. Kate's session never asked the engine about a document she cannot open.
|
||||||
|
expect(orm.openCount()).toBe(0);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(m) a named document that IS reachable and holds nothing of the shape is isSuccess with data []", async () => {
|
||||||
|
const ng = makeFake();
|
||||||
|
const orm = makeOrm();
|
||||||
|
inject(ng, orm);
|
||||||
|
setCurrentUser("liam");
|
||||||
|
// His own, so the barrier passes; nothing of the shape is ever seeded into it.
|
||||||
|
const mine = await createEntityDoc("liam", "protected");
|
||||||
|
expect(getCaps().isEnforcing()).toBe(true);
|
||||||
|
|
||||||
|
const obs = useShapeQuery(EventShape, { graphs: [mine] });
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// THE line that must not move. An empty document is a document that answered, and
|
||||||
|
// the repair above would only have relocated the lie if this had become an error.
|
||||||
|
const snap = obs.getSnapshot();
|
||||||
|
expect(snap.isSuccess).toBe(true);
|
||||||
|
expect(snap.isError).toBe(false);
|
||||||
|
expect(snap.isPending).toBe(false);
|
||||||
|
expect(snap.data).toEqual([]);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(n) one unreachable document in a named list refuses the whole scope", async () => {
|
||||||
|
const ng = makeFake();
|
||||||
|
const orm = makeOrm();
|
||||||
|
inject(ng, orm);
|
||||||
|
setCurrentUser("maya");
|
||||||
|
const mine = await createEntityDoc("maya", "protected");
|
||||||
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
||||||
|
const theirs = "did:ng:o:not-mayas";
|
||||||
|
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
||||||
|
|
||||||
|
const obs = useShapeQuery(EventShape, { graphs: [mine, theirs] });
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Not a short list. Answering "Mine" alone would say the scope holds one object when
|
||||||
|
// the truth is that half of it was never asked - the tolerance `readUnion` was
|
||||||
|
// removed for in `1.0.0-dev.8`, arriving by another door.
|
||||||
|
const snap = obs.getSnapshot();
|
||||||
|
expect(snap.isError).toBe(true);
|
||||||
|
expect(snap.isSuccess).toBe(false);
|
||||||
|
expect(snap.data).toEqual([]);
|
||||||
|
expect(orm.openCount()).toBe(0);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(o) INERT: before the first cap exists a named document reads, and nothing refuses", async () => {
|
||||||
|
const ng = makeFake();
|
||||||
|
const orm = makeOrm();
|
||||||
|
inject(ng, orm);
|
||||||
|
setCurrentUser("nora");
|
||||||
|
// No document created here, so no cap was ever minted: the emulation is not in force,
|
||||||
|
// which is the state every early path runs in. A barrier that fired here would break
|
||||||
|
// all of them.
|
||||||
|
const early = "did:ng:o:early-doc";
|
||||||
|
seedEvent(orm, early, `${early}:q:one`, "Early");
|
||||||
|
expect(getCaps().isEnforcing()).toBe(false);
|
||||||
|
|
||||||
|
const obs = useShapeQuery(EventShape, { graphs: [early] });
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const snap = obs.getSnapshot();
|
||||||
|
expect(snap.isError).toBe(false);
|
||||||
|
expect(snap.isSuccess).toBe(true);
|
||||||
|
expect(titles(snap.data)).toEqual(["Early"]);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
// -- teardown ------------------------------------------------------------
|
// -- teardown ------------------------------------------------------------
|
||||||
|
|
||||||
it("(k) the last unsubscribe closes the shape query", async () => {
|
it("(k) the last unsubscribe closes the shape query", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user