Compare commits
2 Commits
75378fc5a4
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| aeb8c7d157 | |||
| f4050b95c0 |
@@ -4,3 +4,8 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
bun.lockb
|
bun.lockb
|
||||||
bun.lock
|
bun.lock
|
||||||
|
e2e/.dist/
|
||||||
|
*.ngw
|
||||||
|
|
||||||
|
# Per-developer contract access map — canonical identities are committed, local paths are not
|
||||||
|
.project/contracts.local.yaml
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!-- generated — edit .project/concepts/*/_overview.md vocabulary blocks, then run lint --fix; do not edit this file -->
|
||||||
|
|
||||||
|
## Project vocabulary — canonical terms: use VERBATIM in any language, marked `like this`
|
||||||
|
|
||||||
|
```text
|
||||||
|
«indexing»
|
||||||
|
`index` an ordinary public document that holds one entry per indexed object, plus its own field declaration (never: catalogue, registry, listing)
|
||||||
|
`deposit` a bare object reference left in an index document's inbox — open to anyone, and never an instruction (never: message, submission, request)
|
||||||
|
`curate` the owner resolving the references deposited on its index and adding what it can (never: process, ingest, sync)
|
||||||
|
`entry` what an index holds for one indexed object — its NURI and its value for the index's field (never: row, record, item)
|
||||||
|
```
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
type: overview
|
||||||
|
summary: An index is an ordinary public document that only ever grows — what this repo publishes to applications, and what it consumes from NextGraph
|
||||||
|
triggers:
|
||||||
|
keywords: [index, indexing, curate, curation, deposit, depositor, entry, descriptor, reference, only-grows]
|
||||||
|
paths:
|
||||||
|
- "src/**"
|
||||||
|
- "test/**"
|
||||||
|
- "e2e/**"
|
||||||
|
- "README.md"
|
||||||
|
vocabulary:
|
||||||
|
- term: index
|
||||||
|
gloss: an ordinary public document that holds one entry per indexed object, plus its own field declaration
|
||||||
|
not: [catalogue, registry, listing]
|
||||||
|
- term: deposit
|
||||||
|
gloss: a bare object reference left in an index document's inbox — open to anyone, and never an instruction
|
||||||
|
not: [message, submission, request]
|
||||||
|
- term: curate
|
||||||
|
gloss: the owner resolving the references deposited on its index and adding what it can
|
||||||
|
not: [process, ingest, sync]
|
||||||
|
- term: entry
|
||||||
|
gloss: what an index holds for one indexed object — its NURI and its value for the index's field
|
||||||
|
not: [row, record, item]
|
||||||
|
---
|
||||||
|
|
||||||
|
# indexing — an index built on top of NextGraph, and the boundaries around it
|
||||||
|
|
||||||
|
NextGraph has no indexing concept and will not grow one, so this is a construction **above** it, in its own repository, and the dependency runs one way only: this repo depends on `@ng-eventually/polyfill`, and the polyfill must never learn anything about indexing.
|
||||||
|
|
||||||
|
An index is an **ordinary document** in its creator's public store. What makes it an index is that an application references its NURI in its own source. Anyone may hand it a reference by depositing into its inbox; its owner resolves those references itself and adds what it finds. **An index only ever grows** — no removal was ever built, and none is planned.
|
||||||
|
|
||||||
|
## Roles at the repository boundary
|
||||||
|
|
||||||
|
This repo is a **provider** of `indexing-layer`, which the Festipod application consumes, and a **consumer** of two engagements published by `ng-eventually-js`: `polyfill-surface` and `ng-e2e-helpers`. Each pair lives in its own interface folder: the engagement is pulled and never hand-edited, our declaration beside it is ours to keep current.
|
||||||
|
|
||||||
|
Frictions are the main path for telling a provider what we need. They go in our `usage_` leaf, the signal goes out of band, and the entry is pruned once the engagement absorbs it.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `indexing-layer/contract_indexing-layer` — what an application may rely on from `@ng-helpers/indexing`.
|
||||||
|
- `polyfill-surface/usage_ng-helpers` — the exact polyfill entries this layer stands on, and what it had to build for want of them.
|
||||||
|
- `ng-e2e-helpers/usage_ng-helpers` — what our end-to-end suite calls, and the peer-dependency constraint it must respect.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
type: contract
|
||||||
|
summary: The API @ng-helpers/indexing exposes to an application — creating an index, depositing references into it, curating it, and reading it back
|
||||||
|
---
|
||||||
|
|
||||||
|
# contract_indexing-layer — `@ng-helpers/indexing`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This package builds an **index** on top of NextGraph: an ordinary public document that holds one entry per indexed object, keyed by that object's NURI and carrying its value for a single declared field.
|
||||||
|
|
||||||
|
It covers creating an index, handing an index a reference to an object (open to anyone), the owner resolving those references and adding what it can, and reading the entries back in order.
|
||||||
|
|
||||||
|
It does not cover NextGraph itself — documents, identity, sharing, inboxes, transport — all of which reach it through a port you supply. It does not cover search, filtering, pagination, or querying by anything but the index's own field. It **never removes anything**, from anywhere, and that is a property of the engagement rather than a missing feature.
|
||||||
|
|
||||||
|
### Deployment requirements
|
||||||
|
|
||||||
|
An application using this package must:
|
||||||
|
|
||||||
|
- have a NextGraph session already open under the identity it wants to act as, and build the port from it — `polyfillPort({ sessionId })`, where `sessionId` is what `@ng-eventually/polyfill`'s own `init(…)` hands its callback;
|
||||||
|
- reach a broker, since every operation here is a document read, a document write, or an inbox deposit;
|
||||||
|
- **hardcode the index's NURI in its own source.** Nothing marks a document as an index; the reference is what makes it one, and it is the only way anyone reaches it.
|
||||||
|
|
||||||
|
One handle is one identity: the port carries a session and no call takes an identifier. Two users mean two handles.
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ── wiring: one handle, one identity ─────────────────────────────────────────
|
||||||
|
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort;
|
||||||
|
export interface PolyfillPortOptions { readonly sessionId: string | number }
|
||||||
|
export function indexing(port: NextGraphPort): Indexing;
|
||||||
|
|
||||||
|
// ── addressing (re-exported so you import them from here) ────────────────────
|
||||||
|
export type Nuri = `did:ng:${string}`;
|
||||||
|
export type NuriLike = Nuri | string;
|
||||||
|
export type { PrincipalId, UnionSubject, NextGraphPort, IncomingDeposit, ObjectResolution };
|
||||||
|
|
||||||
|
// ── everything this package does ─────────────────────────────────────────────
|
||||||
|
export interface Indexing {
|
||||||
|
/** Creates an index in THIS identity's public store and opens its inbox. Any user may.
|
||||||
|
* `field` is the predicate an indexed object must carry, declared once and for good;
|
||||||
|
* an empty or blank one throws. Returns the NURI to hardcode. */
|
||||||
|
createIndex(field: string): Promise<Nuri>;
|
||||||
|
/** Deposits a bare reference into the index's inbox. Open to ANYONE. Nothing lands in
|
||||||
|
* the index until its owner curates. Throws if the index has no inbox. */
|
||||||
|
refer(index: NuriLike, object: NuriLike): Promise<void>;
|
||||||
|
/** OWNER only — resolves the references received and adds what it can. */
|
||||||
|
curate(index: NuriLike): Promise<CurationReport>;
|
||||||
|
/** The entries, ordered by value. Sugar over `readUnion([index])`. */
|
||||||
|
read(index: NuriLike): Promise<IndexEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── what an index holds ──────────────────────────────────────────────────────
|
||||||
|
export interface IndexEntry { readonly object: Nuri; readonly value: string }
|
||||||
|
export interface IndexDescriptor { readonly field: string }
|
||||||
|
|
||||||
|
// ── what curating reports ────────────────────────────────────────────────────
|
||||||
|
export type CurationOutcome =
|
||||||
|
| { readonly result: "indexed"; readonly object: Nuri; readonly value: string }
|
||||||
|
| { readonly result: "unchanged"; readonly object: Nuri }
|
||||||
|
| { readonly result: "skipped"; readonly object: Nuri; readonly reason: SkipReason }
|
||||||
|
| { readonly result: "unresolved"; readonly object: Nuri; readonly reason: string }
|
||||||
|
| { readonly result: "foreign"; readonly reason: string };
|
||||||
|
export type SkipReason = "no-field" | "several-values" | "self-reference";
|
||||||
|
export interface CurationReport {
|
||||||
|
readonly index: Nuri;
|
||||||
|
readonly outcomes: readonly CurationOutcome[]; // one per deposit, in deposit order
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── what travels from a depositor to a curator ───────────────────────────────
|
||||||
|
export type IndexDeposit = Nuri; // the reference IS the whole payload
|
||||||
|
export function decodeReference(payload: unknown): Nuri | null; // untrusted input
|
||||||
|
|
||||||
|
// ── the IRIs, for a reader going straight to `readUnion` ─────────────────────
|
||||||
|
export const INDEX_FIELD: string; // on the index's own subject: the field it indexes by
|
||||||
|
export const ENTRY_VALUE: string; // on an entry: that object's value for the field
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guarantees
|
||||||
|
|
||||||
|
**An index is an ordinary public document, and nothing marks it as one.** It lives in its creator's public store, so any reader opens it from the reference alone; its creator owns it, and any user may create one.
|
||||||
|
|
||||||
|
**The field is declared once, inside the document, and cannot be changed.** `createIndex` refuses an empty or blank field at the door, because nothing here deletes and an index created on a useless field is useless for good. Declaring it in the document rather than in an application's source is what stops two applications curating the same index on two different fields.
|
||||||
|
|
||||||
|
**`createIndex` opens the index's inbox itself.** Only the owner can, and creation is the one moment the owner is present, so it is not left to a later call to remember.
|
||||||
|
|
||||||
|
**Depositing is open to anyone; writing is the owner's alone.** `refer` is a deposit into the index document's inbox — not a write — so a stranger can contribute to an index they do not own. `curate` reads that inbox and writes the document, and both are refused to anyone but the owner. The deposit is a **bare reference**: it carries no operation, no index reference (the inbox address already identifies the index), and no copy of the indexed value. What the object itself says is what goes in.
|
||||||
|
|
||||||
|
**An index ONLY EVER GROWS.** There is no call that removes an entry, for anyone including the owner, and none is planned. This package cannot express a removal at all. The only answer to "this entry must go" is a fresh index.
|
||||||
|
|
||||||
|
**Curation is convergent and order-independent.** Deposits are never consumed, so every run sees every deposit again; re-applying one re-resolves the reference and lands on the same result. An already-indexed object is skipped outright as `unchanged`. Nothing depends on the order references arrived in.
|
||||||
|
|
||||||
|
**A reference that does not resolve costs nothing and is reported.** It comes back as `unresolved`, nothing is written for it, and nothing already in the index is touched — a later deposit adds it. Every unresolved reference appears in `CurationReport.outcomes`: harmless is not the same as invisible.
|
||||||
|
|
||||||
|
**Reading is per-entry tolerant.** `read` returns entries ordered by value, ties broken on the object NURI, so two readers of the same index always see the same order. Values are compared **as strings** — an index whose field holds ISO-8601 dates therefore comes out in chronological order. A subject that is not a NURI is skipped, never thrown on, and only own properties are read: one stray triple cannot make every real entry unreadable.
|
||||||
|
|
||||||
|
**An entry carrying several values keeps the smallest, deterministically** — which two curation runs racing each other can produce. The entry stays visible and every reader agrees on it.
|
||||||
|
|
||||||
|
**`read` refuses a document that declares no field at all**, rather than answering "an empty index". An unreadable document and an empty one arrive as the same empty result, so an empty answer would be a failure wearing the shape of a fact. Retry before concluding the document is malformed.
|
||||||
|
|
||||||
|
**An index declaring SEVERAL fields refuses to CURATE, loudly and permanently — and stays readable.** Picking one would leave a single list ordered by two different properties, because entries already written are never re-read. Existing entries stay visible and correct; nothing new is added. The refusal cannot be undone, and it says so instead of suggesting a retry.
|
||||||
|
|
||||||
|
**Reading needs nothing from this package.** An application that knows the NURI can call the polyfill's `readUnion([index])` and get the entries as subjects — one per indexed object, keyed by its NURI — plus the index's own subject declaring its field, which `read` drops. `INDEX_FIELD` and `ENTRY_VALUE` are published for exactly that reader.
|
||||||
|
|
||||||
|
**Every inbox payload is untrusted.** Anyone may deposit anything; `decodeReference` returns `null` for everything that is not a reference, and such a payload is reported as `foreign` rather than crashing curation.
|
||||||
|
|
||||||
|
## Non-guarantees
|
||||||
|
|
||||||
|
**No removal, at any level, ever.** Not an oversight and not "not yet": it was deliberately never built. Do not design around a future delete.
|
||||||
|
|
||||||
|
**No refresh.** An already-indexed object is never re-read, so an object whose field value changes later keeps its original value in the index, indefinitely.
|
||||||
|
|
||||||
|
**No private data.** Indexing is limited to objects the curator can open itself. An object the index's owner cannot read is simply `unresolved`.
|
||||||
|
|
||||||
|
**`unresolved` does not tell you why.** Gone, unreadable, and "the read failed" arrive identically and are deliberately not distinguished. Never read it as "the object does not exist".
|
||||||
|
|
||||||
|
**The narrow behaviours are open questions, not promises.** An object carrying nothing for the field is `skipped: "no-field"`; one carrying several values is `skipped: "several-values"`; a raced entry keeps the smallest value. Each is implemented in its narrowest form and reported rather than generalised, and each may change.
|
||||||
|
|
||||||
|
**No stable error text.** What a throw or an `unresolved` reason reads is for a human reading a report. Do not parse it or branch on it.
|
||||||
|
|
||||||
|
**No timing and no delivery promise.** A deposit is not in the index until the owner curates, and nothing here schedules curation. There is no notification, no queue depth, and no ordering between a deposit and a read.
|
||||||
|
|
||||||
|
**The report grows with the inbox.** Since deposits are never retired, `CurationReport.outcomes` has one entry per deposit ever made, not per change.
|
||||||
|
|
||||||
|
**No cross-broker reach.** A NURI resolves for users of the same broker.
|
||||||
|
|
||||||
|
**No depositor authentication or rate limit.** Anyone may deposit any number of payloads into any index's inbox.
|
||||||
|
|
||||||
|
## Change policy
|
||||||
|
|
||||||
|
**Semver, and majors are the normal case.** This layer sits on a polyfill that is itself converging on a NextGraph that does not ship yet, and several of its own behaviours are declared above as open questions. Settling one of them narrows this surface — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
|
||||||
|
|
||||||
|
What each level means here, in this package's own terms:
|
||||||
|
|
||||||
|
- **major** — an exported symbol is removed or renamed, **or** an existing call narrows: it now throws where it returned, or reports a state you did not have to handle before. Settling an open question counts, and so does adding a `CurationOutcome` variant or a `SkipReason` — an exhaustive `switch` in your code stops being exhaustive. A signature change a caller must react to counts; one that only accepts more than before does not.
|
||||||
|
- **minor** — a symbol is added and nothing existing moves: a new read helper, a new optional option.
|
||||||
|
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees`, including the text of a throw, which is explicitly disclaimed above.
|
||||||
|
|
||||||
|
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||||
|
|
||||||
|
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it. This engagement is cut on `main`, so `1.0.0` is what you pin, and your `usage_` leaf anchors `against:` on that exact string — `against: @ng-helpers/indexing@1.0.0`. Had you pinned a pre-release, `against:` would carry that string, pre-release suffix included.
|
||||||
|
|
||||||
|
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
type: usage
|
||||||
|
summary: What this repo's end-to-end suite calls from ng-e2e-helpers, the peer-dependency constraint it must respect, and the gaps it fills itself
|
||||||
|
against: ng-e2e-helpers@1.0.0-dev.1
|
||||||
|
---
|
||||||
|
|
||||||
|
# usage_ng-helpers — `@ng-helpers/indexing`'s end-to-end suite on `ng-e2e-helpers`
|
||||||
|
|
||||||
|
The consumer is this repository's end-to-end suite: one run that builds a small indexing application, serves it, gets real people into it through the real broker, and drives depositing and curating between them.
|
||||||
|
|
||||||
|
Everything generic — the wallet lifecycle, the broker crossing, per-run profiles, bounds, the report shape, the recognition of the known browser failure modes — comes from the engagement and is **used, never reimplemented**. What is specific to this repository is the page that carries our application and the name its runs mint, and nothing else.
|
||||||
|
|
||||||
|
## Consumed surface
|
||||||
|
|
||||||
|
**Bounds** — `within`, `armSuiteDeadline`, `closeQuietly`, `firstLine`.
|
||||||
|
|
||||||
|
**Measurement** — `measured`.
|
||||||
|
|
||||||
|
**Browser and profiles** — `launchWatchedContext`, `closeContext`, `newPage`, and the type `RunProfile`.
|
||||||
|
|
||||||
|
**Wallet** — `mintWalletProfile` and the type `WalletCredentials`. Each run mints its own.
|
||||||
|
|
||||||
|
**Broker crossing** — `setupBrokerPage`.
|
||||||
|
|
||||||
|
**Serving** — `serveOnEphemeralPort`, for the application bundle.
|
||||||
|
|
||||||
|
**Known failure modes** — `browserTrouble`, wired as the suite's `diagnose`.
|
||||||
|
|
||||||
|
**Report** — `declareSuite` and the type `Prerequisite`.
|
||||||
|
|
||||||
|
**Constants** — `BROKER_ROUND_TRIP_MS`, `NEW_PAGE_MS`.
|
||||||
|
|
||||||
|
Everything else the engagement offers is NOT consumed here: the screen inventory and its types, `completeBrokerLogin`, `emptyProfileContext`, `importWalletFile`, `exportWalletBytes`/`exportWalletFile`, `mintWalletBytes`, `mintWalletProfileKeepingContext`, `createWalletInContext`, `newRunProfile`, `isAlive`, `browserLost`, `lossDeclared`, `enclosingBound`, `frameTrouble`, the exported error classes, and the remaining `*_MS` constants. It is safely evolvable as far as this suite is concerned.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
**The browser types are DERIVED from the helpers, never imported from `playwright` here.** The engagement declares Playwright a peer dependency, and this package reaches it as symlinked files — so TypeScript resolves the helpers' `playwright` from where those files really live. Importing the driver in this repository as well produced two structurally different copies of `BrowserContext`, and a context this suite had opened could not be handed back to the helper that opens contexts. Taking the types from the calls that return them leaves exactly one set, and a version skew can no longer express itself as a type error in code that is correct.
|
||||||
|
|
||||||
|
**One wallet per run, minted, never carried.** The run's wallet name is stable and its identity is not; nothing survives a run, and no result depends on a previous one.
|
||||||
|
|
||||||
|
**Nothing generic is reimplemented here.** Where a helper exists, it is called. That is a standing rule for this suite, not a preference — the crossing alone has cost days of misdiagnosis upstream, and a local copy of it would not carry those lessons.
|
||||||
|
|
||||||
|
**Every wait is entered bounded.** No page or frame operation runs outside `within` or a helper that bounds it itself.
|
||||||
|
|
||||||
|
## Frictions
|
||||||
|
|
||||||
|
**Nothing bounds a call into the application iframe.** `frame.evaluate` carries no timeout of its own, so this suite wraps every bridge call itself. The engagement offers no way to obtain that bound, so each consumer re-derives the same wrapper — and the derivation is not free: the calls this suite reached for outside its own wrapper are exactly the ones that can still hang it. A bounded `evaluate` here would delete the wrapper and close the gap in one move.
|
||||||
|
|
||||||
|
**"Measured and bounded" is one intent and two calls.** Sizing a bound from its own measurement is the discipline the engagement itself prescribes, yet every step in this suite has to compose `measured(what, ms, (bound) => within(what, bound, task))` by hand. Two consumers writing the same three-line helper is the tell that the pair belongs on the engagement.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
type: usage
|
||||||
|
summary: The seven polyfill entries the indexing layer stands on, the constraints it holds itself to, and what it had to build for want of a published helper
|
||||||
|
against: "@ng-eventually/polyfill@1.0.0-dev.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
# usage_ng-helpers — `@ng-helpers/indexing` on `polyfill-surface`
|
||||||
|
|
||||||
|
The consumer is the indexing layer: an index is an ordinary public document, contributions reach it through its inbox, and its owner curates it. NextGraph has no indexing concept, so nothing of what a deposit *means* belongs upstream — the polyfill's inbox stays generic and carries opaque payloads, and this layer decides what they say.
|
||||||
|
|
||||||
|
The whole runtime dependency passes through **one file**, the adapter that builds our `NextGraphPort`. Everything else in the package is written against that port, so a change to the engagement breaks exactly one file and nothing else. The types are imported type-only, so they are literally the published ones rather than a copy that can drift.
|
||||||
|
|
||||||
|
## Consumed surface
|
||||||
|
|
||||||
|
**Placement** — `storeRegistry.createEntityDoc("public")`, for the index document itself; `storeRegistry.openDocumentInbox(doc)`, called once at creation.
|
||||||
|
|
||||||
|
**Reading** — `readUnion([doc])` and the type `UnionSubject`. Used both for the index document and for resolving a deposited reference.
|
||||||
|
|
||||||
|
**Writing** — `docs.sparqlUpdate(sessionId, update, anchor)`, with the document named ONCE as the anchor so the statement carries no `GRAPH <…>` wrapper. It is the only write this package makes, and it is always an `INSERT DATA` of literal triples.
|
||||||
|
|
||||||
|
**Inbox** — `inbox.postToDocument(doc, { payload })` for depositing, `inbox.readForDocument(doc)` for the owner draining it, and the shape of `Deposit` (`from` / `payload` / `ts`), which our `IncomingDeposit` mirrors.
|
||||||
|
|
||||||
|
**Types** — `Nuri`, `NuriLike`, `PrincipalId`, `UnionSubject`.
|
||||||
|
|
||||||
|
**Bootstrap, in the end-to-end application only** — `configure`, this package's `init` (for the `sessionId` its callback delivers), and `ensureIdentity`.
|
||||||
|
|
||||||
|
Everything else on the engagement is offered and NOT consumed: `watchShape`, `useShape`, `subscribeDoc`/`subscribeDocs`, `docs.docCreate`, `docs.sparqlQuery`, `storeRegistry.listMyEntityDocs`/`resolveScopeGraph`/`resolveWriteGraph`, `inbox.share`/`post`/`read`/`readSynced`/`readSyncedForDocument`/`processInbox`/`watch`, `ng`, `initNg`. It is safely evolvable as far as this layer is concerned.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
**One port is one identity.** The polyfill's session is one user's and no call takes an identifier, so an `Indexing` handle is one person's. Two users mean two handles — which is also what keeps our multi-actor tests honest: a depositor obtains the index NURI the way an application does, never through a shared variable.
|
||||||
|
|
||||||
|
**`sessionId` is relayed, never converted.** We carry it at the engagement's own `string | number` and hand it back untouched.
|
||||||
|
|
||||||
|
**The write is add-only, and structurally so.** There is no delete builder anywhere in this package, and the only statement it can compose is an anchored `INSERT DATA`. Our own tests execute that SPARQL against an engine that refuses anything else, so a removal is unrunnable rather than merely undetected. This constrains what we ask of the engagement: we need exactly one write primitive and no more.
|
||||||
|
|
||||||
|
**The inbox is opened at creation, from one place.** The engagement disclaims coalescing `openDocumentInbox` across pages, so we never open an index's inbox anywhere but in `createIndex`, under its owner, at the moment the document is created.
|
||||||
|
|
||||||
|
**Every payload out of an inbox is untrusted input.** Anyone may deposit anything, so nothing read from a deposit reaches a query before being checked; a non-reference is reported, never thrown on.
|
||||||
|
|
||||||
|
**An empty read is never treated as "empty".** Nothing in this layer reads `[]` from `readUnion` as "a valid index that happens to hold nothing" — the descriptor check refuses a document declaring no field, and that refusal aborts curation before a single write.
|
||||||
|
|
||||||
|
## Frictions
|
||||||
|
|
||||||
|
**The engagement does not say what `readUnion` does with a document it cannot read.** It says what it returns for a document it can, and it says that a rejection means "unknown, never absent" — but not whether an unreadable document inside the list comes back as a rejection or is swallowed into the result. We assume the worst (swallowed, therefore indistinguishable from empty) and code defensively around it. A sentence in `## Guarantees` settling this would replace a guess we are carrying in every read path.
|
||||||
|
|
||||||
|
**No published NURI type guard.** `## Guarantees` states plainly that no type guard is published, so this layer carries its own — and it needs one, because a NURI arrives here from an untrusted inbox deposit and must be checked before it can be written between angle brackets. The check we wrote is a guess at what the engagement considers a valid `Nuri`, and a wrong guess is either a rejected legitimate reference or an injected one.
|
||||||
|
|
||||||
|
**No published escaping helpers.** The engagement lists no `escapeIri`/`escapeLiteral`, and we compose SPARQL against `docs.sparqlUpdate` — so this package carries its own escaping rather than reach into the provider's internals. That is a security-relevant duplication of something the provider certainly already has: two implementations of the same rule, one of which is not the one the provider tests.
|
||||||
|
|
||||||
|
**No published way to write triples above the raw SPARQL primitive.** `docs.sparqlUpdate` is the level we had to align on, which is why the two frictions above exist at all. A published "add these triples to this document" would remove the query composition, the escaping and the NURI validation from this layer in one move.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Inter-repo contracts. `publish:` is this project's engagement toward its consumers —
|
||||||
|
# listing a leaf here IS the act of publishing it; an unlisted `contract_` leaf is a draft.
|
||||||
|
#
|
||||||
|
# This project is BOTH a provider and a consumer, and the two faces are isolated at document
|
||||||
|
# level: `indexing-layer` never mentions what we consume, and our `usage_` leaves never
|
||||||
|
# mention what we promise. What backs a published guarantee is internal doctrine.
|
||||||
|
#
|
||||||
|
# PROVIDES indexing-layer (concepts/indexing/indexing-layer/)
|
||||||
|
# consumer: the Festipod application, in its own repo. It has not declared a
|
||||||
|
# usage leaf, and we do not author one on its behalf — an interface with no
|
||||||
|
# declared consumer simply runs in the one-document mode.
|
||||||
|
#
|
||||||
|
# CONSUMES polyfill-surface and ng-e2e-helpers, both published by `ng-eventually-js`.
|
||||||
|
# We pull each engagement and author the `usage_ng-helpers.md` beside it.
|
||||||
|
#
|
||||||
|
# `pullFrom:` values are CANONICAL remote identities, because this file travels with the
|
||||||
|
# branch. Per-developer local access lives in `.project/contracts.local.yaml`, which is
|
||||||
|
# gitignored and must never be committed.
|
||||||
|
|
||||||
|
publish:
|
||||||
|
# paths are relative to `.project/`
|
||||||
|
indexing-layer: concepts/indexing/indexing-layer/contract_indexing-layer.md
|
||||||
|
|
||||||
|
consume:
|
||||||
|
- contract: polyfill-surface
|
||||||
|
type: git
|
||||||
|
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Reconnexion/ng-eventually.git/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md
|
||||||
|
ref: caps-p1a-and-virtual-user-boundary
|
||||||
|
into: concepts/indexing/polyfill-surface/
|
||||||
|
|
||||||
|
- contract: ng-e2e-helpers
|
||||||
|
type: git
|
||||||
|
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Reconnexion/ng-eventually.git/.project/concepts/e2e-harness/ng-e2e-helpers/contract_ng-e2e-helpers.md
|
||||||
|
ref: caps-p1a-and-virtual-user-boundary
|
||||||
|
into: concepts/indexing/ng-e2e-helpers/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## Project vocabulary (always loaded)
|
||||||
|
|
||||||
|
@.project/VOCABULARY.md
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* The contract between the application page and the suite that drives it.
|
||||||
|
*
|
||||||
|
* It is declared ONCE and imported by both sides — `indexing-app.ts` implements it,
|
||||||
|
* `run.ts` calls it — so a method that changes shape breaks the typecheck instead of
|
||||||
|
* failing at run time inside a browser, where the only symptom would be `undefined is
|
||||||
|
* not a function` three minutes into a broker crossing.
|
||||||
|
*
|
||||||
|
* Everything crossing `frame.evaluate` must be structured-cloneable, which is why every
|
||||||
|
* member below takes and returns plain strings, numbers and object literals. A `Nuri` is
|
||||||
|
* a template-literal string type upstream (`did:ng:${string}`), so it crosses as itself;
|
||||||
|
* it is declared `string` here because a value that has been through structured clone
|
||||||
|
* carries no proof of its shape, and pretending otherwise is how an unvalidated string
|
||||||
|
* ends up typed as a reference.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CurationReport, IndexEntry, UnionSubject } from "../src/index";
|
||||||
|
|
||||||
|
/** What the leak probe observed — see `run.ts`'s last journey. */
|
||||||
|
export interface BrokenInboxOutcome {
|
||||||
|
/** The message `createIndex` rejected with, or `null` if it did not reject. */
|
||||||
|
readonly rejected: string | null;
|
||||||
|
/** What `createIndex` returned, on the impossible branch where it did not reject. */
|
||||||
|
readonly returned: string | null;
|
||||||
|
/** The documents that appeared in this identity's public store despite the failure. */
|
||||||
|
readonly appeared: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The acts this application can perform — and ONLY acts an application can perform.
|
||||||
|
*
|
||||||
|
* There is no back door onto the library's internals here. The one method that is not
|
||||||
|
* something an application does (`createIndexWithBrokenInbox`) injects a failure and is
|
||||||
|
* named for it, because the alternative — leaving the question unanswered — is worse
|
||||||
|
* than a probe that says what it is.
|
||||||
|
*/
|
||||||
|
export interface IndexingBridge {
|
||||||
|
/** `connecting` → `ready`, or `failed`. */
|
||||||
|
status(): string;
|
||||||
|
/** Why the boot failed, or `null`. */
|
||||||
|
error(): string | null;
|
||||||
|
/** Who this page signed in as. */
|
||||||
|
whoami(): string;
|
||||||
|
/**
|
||||||
|
* The index this deployment was BUILT to contribute to, read off its own configuration.
|
||||||
|
*
|
||||||
|
* An index is an ordinary document; what makes it an index is that an application
|
||||||
|
* references its NURI in its own source (`src/indexing.ts`). This page is configured
|
||||||
|
* through its URL rather than through a compiled-in constant, which is the same thing
|
||||||
|
* one build step earlier — and it is how the reference reaches a SECOND identity
|
||||||
|
* without the suite handing it over through a variable no application would have.
|
||||||
|
*/
|
||||||
|
configuredIndex(): string | null;
|
||||||
|
|
||||||
|
/** Create an index in this identity's public store, indexing by `field`. */
|
||||||
|
createIndex(field: string): Promise<string>;
|
||||||
|
/** Publish a public document carrying one value for one predicate. */
|
||||||
|
publishObject(predicate: string, value: string): Promise<string>;
|
||||||
|
/** Hand the CONFIGURED index a reference to an object. Anyone may. */
|
||||||
|
referConfigured(object: string): Promise<void>;
|
||||||
|
/** Hand a NAMED index a reference — used where no identity boundary is crossed. */
|
||||||
|
referTo(index: string, object: string): Promise<void>;
|
||||||
|
/** Resolve the references this index received and add what can be added. Owner only. */
|
||||||
|
curate(index: string): Promise<CurationReport>;
|
||||||
|
/** The index's entries, ordered by value. */
|
||||||
|
read(index: string): Promise<IndexEntry[]>;
|
||||||
|
|
||||||
|
/** What a document literally holds, straight off `readUnion` — the write-form probe. */
|
||||||
|
readRaw(doc: string): Promise<UnionSubject[]>;
|
||||||
|
/** This identity's public documents. How an owner discovers a document it did not keep. */
|
||||||
|
listPublicDocs(): Promise<string[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `createIndex` with its inbox step made to fail — everything else real, against the
|
||||||
|
* real broker. Answers whether a half-created index is left behind.
|
||||||
|
*/
|
||||||
|
createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__indexing: IndexingBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* What is SPECIFIC to this repository in the end-to-end setup: the page that carries the
|
||||||
|
* application, and the name of the wallet its runs mint.
|
||||||
|
*
|
||||||
|
* Everything generic — the wallet lifecycle, the broker crossing, per-run profiles,
|
||||||
|
* bounds, the report shape, the recognition of the known browser failure modes — lives in
|
||||||
|
* `ng-e2e-helpers` and is used, never reimplemented. That package knows nothing about
|
||||||
|
* this one and must keep knowing nothing about it: it talks about NextGraph itself, so it
|
||||||
|
* outlives both the polyfill and this indexing layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
mintWalletProfile,
|
||||||
|
serveOnEphemeralPort,
|
||||||
|
type RunProfile,
|
||||||
|
type WalletCredentials,
|
||||||
|
} from "ng-e2e-helpers";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The throwaway credentials each run mints its own wallet with.
|
||||||
|
*
|
||||||
|
* A NAME, not an identity that survives: every run gets a profile of its own and mints
|
||||||
|
* this wallet into it, so two runs sharing the name share nothing else — which is what
|
||||||
|
* lets this suite run beside another repository's at the same time, against the same
|
||||||
|
* broker, without a lock. The password sits here in the clear because it opens a wallet
|
||||||
|
* that exists for the length of one run and is deleted with the profile holding it.
|
||||||
|
*/
|
||||||
|
export const WALLET: WalletCredentials = {
|
||||||
|
name: "ng-helpers-e2e",
|
||||||
|
password: "ng-helpers-e2e",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** This run's physical user, in a profile of its own. */
|
||||||
|
export function mintRunWallet(suite: string): Promise<RunProfile> {
|
||||||
|
return mintWalletProfile(suite, WALLET);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `bun build` is a local bundle; a minute is already many times what it takes. */
|
||||||
|
const BUILD_MS = 60_000;
|
||||||
|
|
||||||
|
const ENTRY = path.resolve(here, "indexing-app.ts");
|
||||||
|
const BUNDLE_OUT = path.resolve(here, ".dist", "indexing-app.js");
|
||||||
|
|
||||||
|
export function buildApp(): void {
|
||||||
|
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
||||||
|
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
||||||
|
stdio: "pipe",
|
||||||
|
cwd: path.resolve(here, ".."),
|
||||||
|
timeout: BUILD_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the application the way a deployment would.
|
||||||
|
*
|
||||||
|
* An unknown path 404s rather than answering with the page: a catch-all makes a request
|
||||||
|
* for a file nobody serves look like a perfectly good download, and hides exactly the
|
||||||
|
* kind of mistake a served asset can carry.
|
||||||
|
*/
|
||||||
|
export function serveApp(): Promise<{ url: string; close: () => void }> {
|
||||||
|
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
||||||
|
const html =
|
||||||
|
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">` +
|
||||||
|
`<title>ng-helpers indexing — e2e</title></head><body>` +
|
||||||
|
`<script type="module" src="/indexing-app.js"></script></body></html>`;
|
||||||
|
return serveOnEphemeralPort((req, res) => {
|
||||||
|
const route = (req.url ?? "/").split("?")[0];
|
||||||
|
if (route === "/indexing-app.js") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||||||
|
res.end(bundle);
|
||||||
|
} else if (route === "/" || route === "/index.html") {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||||
|
res.end(html);
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
res.end("not served");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* The application the end-to-end suite drives — written the way a consumer of
|
||||||
|
* `@ng-helpers/indexing` writes one, and nothing more.
|
||||||
|
*
|
||||||
|
* ── Why an application and not a bag of library calls ──────────────────────
|
||||||
|
* The 69 unit tests in `test/` run against a fake this repository wrote. They prove the
|
||||||
|
* indexing RULES are consistent; they cannot prove that NextGraph does what the fake
|
||||||
|
* pretends, because the fake is the thing being asked. This page closes that gap by
|
||||||
|
* putting the real broker underneath: it imports `@ng-eventually/polyfill` for real,
|
||||||
|
* crosses the real broker, and calls `indexing(polyfillPort(...))` exactly as an
|
||||||
|
* application would.
|
||||||
|
*
|
||||||
|
* It reaches nothing private. Every import below is a published entry — of the polyfill
|
||||||
|
* (`configure`, `ensureIdentity`, `init`, `readUnion`, `storeRegistry`) or of this
|
||||||
|
* package (`indexing`, `polyfillPort`). If something here is awkward, it is awkward for
|
||||||
|
* every consumer, which is the second reason to write it this way.
|
||||||
|
*
|
||||||
|
* ── The one thing here no application does ─────────────────────────────────
|
||||||
|
* `createIndexWithBrokenInbox` injects a failure into the inbox step of `createIndex`.
|
||||||
|
* That is a probe, it is named for what it is, and it exists because the question it
|
||||||
|
* answers — does a failed `openInbox` leave a document behind? — cannot be reached from
|
||||||
|
* outside: nothing a caller controls makes a real `openDocumentInbox` fail on demand.
|
||||||
|
* Everything around the injection is real, including the broker and the document.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
configure,
|
||||||
|
ensureIdentity,
|
||||||
|
init,
|
||||||
|
readUnion,
|
||||||
|
storeRegistry,
|
||||||
|
type Nuri,
|
||||||
|
type UnionSubject,
|
||||||
|
} from "@ng-eventually/polyfill";
|
||||||
|
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||||
|
|
||||||
|
import { indexing, polyfillPort } from "../src/index";
|
||||||
|
import type {
|
||||||
|
CurationReport,
|
||||||
|
IndexEntry,
|
||||||
|
Indexing,
|
||||||
|
NextGraphPort,
|
||||||
|
} from "../src/index";
|
||||||
|
import type { BrokenInboxOutcome, IndexingBridge } from "./bridge";
|
||||||
|
|
||||||
|
// ── bootstrap: the one polyfill-era call, then the SDK-shaped ones ──────────
|
||||||
|
//
|
||||||
|
// `sharedWallet` is declared because the access gate wants somewhere to point when it
|
||||||
|
// has to render, and never used: this suite always enters through the broker's redirect,
|
||||||
|
// where the wallet is already open in the run's profile. Nothing is served at that path.
|
||||||
|
configure({
|
||||||
|
ng: realNg,
|
||||||
|
useShape: () => undefined, // this application reads through `readUnion`, not the ORM
|
||||||
|
init: realInit,
|
||||||
|
sharedWallet: { fileUrl: "/wallet-never-served.ngw", password: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The library's `init`, not the injected one: it settles the identity BEFORE handing the
|
||||||
|
// page to the broker, so the round-trip leaves with `?ng-id=` in the address it carries.
|
||||||
|
// The callback is this application's own business — it keeps the session because
|
||||||
|
// `polyfillPort` takes a session id, exactly as the real SDK's primitives do.
|
||||||
|
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
|
||||||
|
init(
|
||||||
|
(event: { status: string; session?: { session_id: string } }) => {
|
||||||
|
if (event.status === "loggedin" && event.session) resolve(event.session);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── this application's state ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const state: { status: string; error: string | null; who: string } = {
|
||||||
|
status: "connecting",
|
||||||
|
error: null,
|
||||||
|
who: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
let api: Indexing | null = null;
|
||||||
|
let port: NextGraphPort | null = null;
|
||||||
|
|
||||||
|
/** The index this deployment contributes to, read off its own configuration. */
|
||||||
|
function configuredIndex(): string | null {
|
||||||
|
return new URLSearchParams(window.location.search).get("index");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot(): Promise<void> {
|
||||||
|
// One await, and it covers everything: the identity settles, the connection work runs,
|
||||||
|
// and the identity comes back. The application keeps it only to show it.
|
||||||
|
state.who = await ensureIdentity();
|
||||||
|
const session = await sessionReady;
|
||||||
|
port = polyfillPort({ sessionId: session.session_id });
|
||||||
|
api = indexing(port);
|
||||||
|
state.status = "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
void boot().catch((e: unknown) => {
|
||||||
|
state.status = "failed";
|
||||||
|
state.error = String((e as Error)?.message ?? e);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The library, once the page is up. Throws with the boot's own reason if it is not. */
|
||||||
|
function ready(): Indexing {
|
||||||
|
if (api === null) {
|
||||||
|
throw new Error(`[e2e] the application is not ready (${state.status}): ${state.error ?? "still connecting"}`);
|
||||||
|
}
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readyPort(): NextGraphPort {
|
||||||
|
if (port === null) {
|
||||||
|
throw new Error(`[e2e] the application is not ready (${state.status}): ${state.error ?? "still connecting"}`);
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a document to appear in this identity's public store.
|
||||||
|
*
|
||||||
|
* A store listing is a read like any other, and a document written a moment ago is not
|
||||||
|
* owed to be in it instantly. Polling is therefore what an owner would actually do, and
|
||||||
|
* it is bounded: an empty answer at the end is evidence, not a hang.
|
||||||
|
*/
|
||||||
|
async function publicDocsAfter(
|
||||||
|
before: ReadonlySet<string>,
|
||||||
|
budgetMs: number,
|
||||||
|
): Promise<readonly string[]> {
|
||||||
|
const deadline = Date.now() + budgetMs;
|
||||||
|
let appeared: readonly string[] = [];
|
||||||
|
for (;;) {
|
||||||
|
const now = await storeRegistry.listMyEntityDocs("public");
|
||||||
|
appeared = now.filter((d) => !before.has(d));
|
||||||
|
if (appeared.length > 0 || Date.now() >= deadline) return appeared;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the acts ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const bridge: IndexingBridge = {
|
||||||
|
status: () => state.status,
|
||||||
|
error: () => state.error,
|
||||||
|
whoami: () => state.who,
|
||||||
|
configuredIndex,
|
||||||
|
|
||||||
|
async createIndex(field: string): Promise<string> {
|
||||||
|
return ready().createIndex(field);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a public document carrying one value for one predicate.
|
||||||
|
*
|
||||||
|
* It goes through the SAME primitive the curator writes an entry with
|
||||||
|
* (`addLiteralProperty`), with the document as its own subject. That makes it the
|
||||||
|
* CONTROL for the write-form question: if this round-trips and an index entry does
|
||||||
|
* not, the difference is the foreign subject and nothing else.
|
||||||
|
*/
|
||||||
|
async publishObject(predicate: string, value: string): Promise<string> {
|
||||||
|
const p = readyPort();
|
||||||
|
const doc = await p.createPublicDocument();
|
||||||
|
await p.addLiteralProperty(doc, doc, predicate, value);
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
|
||||||
|
async referConfigured(object: string): Promise<void> {
|
||||||
|
const index = configuredIndex();
|
||||||
|
if (index === null) {
|
||||||
|
throw new Error("[e2e] this application was not configured with an index reference");
|
||||||
|
}
|
||||||
|
await ready().refer(index, object);
|
||||||
|
},
|
||||||
|
|
||||||
|
async referTo(index: string, object: string): Promise<void> {
|
||||||
|
await ready().refer(index, object);
|
||||||
|
},
|
||||||
|
|
||||||
|
async curate(index: string): Promise<CurationReport> {
|
||||||
|
return ready().curate(index);
|
||||||
|
},
|
||||||
|
|
||||||
|
async read(index: string): Promise<IndexEntry[]> {
|
||||||
|
return ready().read(index);
|
||||||
|
},
|
||||||
|
|
||||||
|
async readRaw(doc: string): Promise<UnionSubject[]> {
|
||||||
|
return readUnion([doc]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPublicDocs(): Promise<string[]> {
|
||||||
|
const docs: Nuri[] = await storeRegistry.listMyEntityDocs("public");
|
||||||
|
return [...docs];
|
||||||
|
},
|
||||||
|
|
||||||
|
async createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome> {
|
||||||
|
const p = readyPort();
|
||||||
|
const before = new Set<string>(await storeRegistry.listMyEntityDocs("public"));
|
||||||
|
|
||||||
|
// Everything real except the inbox step. The failure is injected at the exact moment
|
||||||
|
// the question is about: after the document exists and carries its descriptor, before
|
||||||
|
// anyone can deposit into it.
|
||||||
|
const broken = indexing({
|
||||||
|
...p,
|
||||||
|
openInbox: async (): Promise<void> => {
|
||||||
|
throw new Error("[e2e] injected: the inbox could not be opened");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let rejected: string | null = null;
|
||||||
|
let returned: string | null = null;
|
||||||
|
try {
|
||||||
|
returned = await broken.createIndex(field);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
rejected = String((e as Error)?.message ?? e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rejected, returned, appeared: await publicDocsAfter(before, 15_000) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__indexing = bridge;
|
||||||
+630
@@ -0,0 +1,630 @@
|
|||||||
|
/**
|
||||||
|
* `@ng-helpers/indexing` against the REAL broker.
|
||||||
|
*
|
||||||
|
* ── What this suite is for ─────────────────────────────────────────────────
|
||||||
|
* The unit suite proves the indexing rules are consistent with a fake this repository
|
||||||
|
* wrote. It cannot prove NextGraph behaves the way that fake pretends, because the fake
|
||||||
|
* is the very thing in question. Two claims in particular had never met a broker:
|
||||||
|
*
|
||||||
|
* 1. **The write form.** An entry is a triple whose SUBJECT is another document — the
|
||||||
|
* indexed object — written into the index document's anchored default graph. The
|
||||||
|
* polyfill's own suites only ever write a document's own subject into itself, so
|
||||||
|
* nothing had ever asked oxigraph whether a FOREIGN subject survives the round trip.
|
||||||
|
* `publishObject` here writes the self-subject form with the same primitive, which
|
||||||
|
* makes it the control: if one round-trips and the other does not, the difference is
|
||||||
|
* the foreign subject and nothing else.
|
||||||
|
*
|
||||||
|
* 2. **A half-created index.** `createIndex` creates a document, writes its descriptor,
|
||||||
|
* then opens its inbox. If the last step fails the caller gets an exception and no
|
||||||
|
* reference — but the document exists. The last journey injects that failure and
|
||||||
|
* asks the broker what was left behind.
|
||||||
|
*
|
||||||
|
* ── Two identities, and how the index reference reaches the second ─────────
|
||||||
|
* The whole point of an index is that STRANGERS contribute to it. So Bob must reach
|
||||||
|
* Alice's index — and he must reach it the way an application would, not through a
|
||||||
|
* variable in this file. An index is an ordinary document whose NURI an application
|
||||||
|
* references in its own source (`src/indexing.ts`), so Bob's page is CONFIGURED with it,
|
||||||
|
* through its URL: one build step earlier, that is a compiled-in constant. What must
|
||||||
|
* never happen — and does not happen here — is an inbox address crossing the identity
|
||||||
|
* boundary through a channel no deployment has.
|
||||||
|
*
|
||||||
|
* ── Reading a failure ──────────────────────────────────────────────────────
|
||||||
|
* A named deadline, or a message `ng-e2e-helpers` recognises as a browser or frame
|
||||||
|
* failure, is the HOST. A failed check carrying an unexpected value is this code. The
|
||||||
|
* report says which, and the run is repeated rather than anything being loosened.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BROKER_ROUND_TRIP_MS,
|
||||||
|
NEW_PAGE_MS,
|
||||||
|
armSuiteDeadline,
|
||||||
|
browserTrouble,
|
||||||
|
closeContext,
|
||||||
|
closeQuietly,
|
||||||
|
declareSuite,
|
||||||
|
firstLine,
|
||||||
|
launchWatchedContext,
|
||||||
|
measured,
|
||||||
|
newPage,
|
||||||
|
setupBrokerPage,
|
||||||
|
within,
|
||||||
|
type Prerequisite,
|
||||||
|
type RunProfile,
|
||||||
|
} from "ng-e2e-helpers";
|
||||||
|
|
||||||
|
import { ENTRY_VALUE, INDEX_FIELD } from "../src/index";
|
||||||
|
import { WALLET, buildApp, mintRunWallet, serveApp } from "./harness-page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser types, taken from the helpers that RETURN them rather than imported from
|
||||||
|
* `playwright` directly.
|
||||||
|
*
|
||||||
|
* `ng-e2e-helpers` declares Playwright a PEER dependency — the consumer owns the version,
|
||||||
|
* because browser binaries have to match the driver. Its files reach this repository as
|
||||||
|
* symlinks, so TypeScript resolves its `playwright` from where those files really live,
|
||||||
|
* and importing the driver here as well produced two structurally different copies of
|
||||||
|
* `BrowserContext`: a context this file had opened could not be handed back to the helper
|
||||||
|
* that opens contexts. Derived, there is exactly one set of these types — whichever copy
|
||||||
|
* the helpers speak — and a version skew can no longer express itself as a type error in
|
||||||
|
* code that is correct.
|
||||||
|
*/
|
||||||
|
type BrowserContext = Awaited<ReturnType<typeof launchWatchedContext>>;
|
||||||
|
type Page = Awaited<ReturnType<typeof newPage>>;
|
||||||
|
type Frame = Awaited<ReturnType<typeof setupBrokerPage>>;
|
||||||
|
|
||||||
|
// ── the domain this suite indexes by ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A date, so the suite exercises the case the package is built around: an index "by a
|
||||||
|
// date" is just an index whose field is a date predicate, and ISO-8601 sorts as a string.
|
||||||
|
const PUBLISHED_AT = "urn:ng-helpers-e2e:published-at";
|
||||||
|
/** A predicate an index does NOT curate on — for the object that carries nothing usable. */
|
||||||
|
const UNRELATED = "urn:ng-helpers-e2e:unrelated";
|
||||||
|
|
||||||
|
// ── bounds ─────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Sized to be generous rather than tight. A bound exists to turn a hang into a named
|
||||||
|
// failure; sized to the median it would instead fail on a slow-but-healthy broker, which
|
||||||
|
// is the one thing it must never do. The wall clocks of the three reported runs are the
|
||||||
|
// measurement these should be re-sized from.
|
||||||
|
|
||||||
|
/** The bridge appearing on the page — a bundle evaluating, no broker involved. */
|
||||||
|
const BRIDGE_UP_MS = 60_000;
|
||||||
|
/** `ensureIdentity` + the session: an identity settled and the connection work run. */
|
||||||
|
const READY_MS = 180_000;
|
||||||
|
/** One sign-in: a page, the broker round trip, and the application booting behind it. */
|
||||||
|
const SIGN_IN_MS = NEW_PAGE_MS + BROKER_ROUND_TRIP_MS + READY_MS;
|
||||||
|
/** One call across the bridge. The slowest here are curations, which round-trip per deposit. */
|
||||||
|
const BRIDGE_MS = 4 * 60_000;
|
||||||
|
/** One journey. The longest holds two sign-ins' worth of work behind it. */
|
||||||
|
const JOURNEY_MS = 10 * 60_000;
|
||||||
|
/** The whole run. A budget that cannot interrupt anything is not a budget. */
|
||||||
|
const SUITE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
// ── the report ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let actors: BrowserContext | null = null;
|
||||||
|
|
||||||
|
const { check, journey, finish } = declareSuite({
|
||||||
|
label: "ng-helpers indexing e2e",
|
||||||
|
journeyBound: JOURNEY_MS,
|
||||||
|
diagnose: async () => (actors === null ? null : browserTrouble("actors", actors)),
|
||||||
|
journeys: [
|
||||||
|
{
|
||||||
|
name: "Alice signs in and creates an index",
|
||||||
|
checks: [
|
||||||
|
"Alice signs in and the application knows who she is",
|
||||||
|
"creating an index answers with a document reference",
|
||||||
|
"the index document declares the field it indexes by",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bob signs in configured with Alice's index, and publishes an object",
|
||||||
|
checks: [
|
||||||
|
"Bob signs in, configured with the index his application contributes to",
|
||||||
|
"Bob publishes a public object carrying the indexed field",
|
||||||
|
"Bob's object reads back carrying the value he wrote",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bob hands the index a reference, and Alice curates it",
|
||||||
|
checks: [
|
||||||
|
"a stranger's deposit into the index's inbox is accepted",
|
||||||
|
"curation reports Bob's object as indexed",
|
||||||
|
"the indexed value was read off Bob's object, and never travelled in his deposit",
|
||||||
|
"the entry is stored under Bob's object's own reference as its subject",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "The index reads back, for its owner and for a stranger",
|
||||||
|
checks: [
|
||||||
|
"Alice reads exactly one entry, and it is Bob's object",
|
||||||
|
"Bob, who does not own the index, reads the same entry",
|
||||||
|
"curating a second time changes nothing, and the index still holds one entry",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "An object carrying nothing for the field is not indexed",
|
||||||
|
checks: [
|
||||||
|
"curation reports it skipped for want of the field, rather than indexed",
|
||||||
|
"the index still holds exactly one entry",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "An index whose inbox cannot be opened leaves a document behind",
|
||||||
|
checks: [
|
||||||
|
"createIndex refuses when the inbox cannot be opened",
|
||||||
|
"a document was nevertheless created in the owner's public store",
|
||||||
|
"the leaked document carries a descriptor but accepts no deposit",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "A hostile value crosses the round trip as one inert literal",
|
||||||
|
checks: [
|
||||||
|
"the object reads back the hostile value byte for byte",
|
||||||
|
"the index holds it as one entry, and its own descriptor is untouched",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A named step that is both measured and bounded — `evaluate` carries no timeout of its own. */
|
||||||
|
function step<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
|
||||||
|
return measured(what, ms, (bound) => within(what, bound, task));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── an actor ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Actor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly frame: Frame;
|
||||||
|
readonly page: Page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign an actor in, and wait for its application to be up.
|
||||||
|
*
|
||||||
|
* `?ng-id=` is the one channel that survives the broker round trip (the access gate's
|
||||||
|
* resolution order). `index` rides the same query string when the actor's deployment is
|
||||||
|
* built to contribute to one.
|
||||||
|
*/
|
||||||
|
async function signIn(
|
||||||
|
ctx: BrowserContext,
|
||||||
|
appUrl: string,
|
||||||
|
id: string,
|
||||||
|
index: string | null,
|
||||||
|
): Promise<Actor> {
|
||||||
|
const opened: { page: Page | null } = { page: null };
|
||||||
|
const query =
|
||||||
|
`?ng-id=${encodeURIComponent(id)}` +
|
||||||
|
(index === null ? "" : `&index=${encodeURIComponent(index)}`);
|
||||||
|
try {
|
||||||
|
return await measured(`${id}'s sign-in`, SIGN_IN_MS, (bound) =>
|
||||||
|
within(`${id} to sign in`, bound, async () => {
|
||||||
|
const page = await measured(`a page for ${id}`, NEW_PAGE_MS, () => newPage(id, ctx));
|
||||||
|
opened.page = page;
|
||||||
|
page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message));
|
||||||
|
page.on("console", (m) => {
|
||||||
|
if (m.type() === "error") console.error(`[${id} console]`, m.text());
|
||||||
|
});
|
||||||
|
const frame = await measured(`${id}'s broker round trip`, BROKER_ROUND_TRIP_MS, () =>
|
||||||
|
setupBrokerPage(page, `${appUrl}/${query}`, WALLET.password),
|
||||||
|
);
|
||||||
|
await waitReady(id, frame);
|
||||||
|
return { id, frame, page };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (opened.page !== null) {
|
||||||
|
await closeQuietly(`${id}'s abandoned sign-in page`, () => opened.page!.close());
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for the application to be up, and say why with ITS reason when it is not. */
|
||||||
|
async function waitReady(id: string, frame: Frame): Promise<void> {
|
||||||
|
await step(`${id}'s application bundle`, BRIDGE_UP_MS, () =>
|
||||||
|
frame.waitForFunction(() => window.__indexing !== undefined, undefined, {
|
||||||
|
timeout: BRIDGE_UP_MS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await step(`${id}'s identity and session`, READY_MS, () =>
|
||||||
|
frame.waitForFunction(() => window.__indexing.status() !== "connecting", undefined, {
|
||||||
|
timeout: READY_MS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const status = await frame.evaluate(() => window.__indexing.status());
|
||||||
|
if (status !== "ready") {
|
||||||
|
const why = await frame.evaluate(() => window.__indexing.error());
|
||||||
|
throw new Error(`[e2e] ${id}'s application did not start (${status}): ${why ?? "no reason given"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A journey cannot start without the actor it drives — reported as that, not discovered
|
||||||
|
* as a timeout on an innocent call.
|
||||||
|
*
|
||||||
|
* It takes a THUNK, not the actor: read eagerly, the value would be captured as it was
|
||||||
|
* before any sign-in happened, and every journey would report an actor that is standing
|
||||||
|
* right there as missing.
|
||||||
|
*/
|
||||||
|
function actorIsUp(id: string, actor: () => Actor | null): Prerequisite {
|
||||||
|
return () => (actor() === null ? `${id} never signed in` : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the run ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
armSuiteDeadline("ng-helpers indexing e2e", SUITE_MS, () =>
|
||||||
|
finish("the suite exceeded its wall clock"),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[e2e] building the application...");
|
||||||
|
buildApp();
|
||||||
|
|
||||||
|
// This run's own physical user, in a directory of its own — so another repository's
|
||||||
|
// suite can drive the same broker at the same time without either noticing.
|
||||||
|
console.log("[e2e] minting this run's wallet...");
|
||||||
|
const wallet: RunProfile = await mintRunWallet("the indexing suite (e2e/run.ts)");
|
||||||
|
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const ALICE = `alice-${stamp}`;
|
||||||
|
const BOB = `bob-${stamp}`;
|
||||||
|
|
||||||
|
let ctx: BrowserContext | null = null;
|
||||||
|
let closeServer: (() => void) | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const served = await serveApp();
|
||||||
|
closeServer = served.close;
|
||||||
|
console.log(`[e2e] application served at ${served.url}`);
|
||||||
|
|
||||||
|
ctx = await launchWatchedContext("actors", wallet.dir);
|
||||||
|
actors = ctx;
|
||||||
|
|
||||||
|
let alice: Actor | null = null;
|
||||||
|
let bob: Actor | null = null;
|
||||||
|
let index: string | null = null;
|
||||||
|
let bobsObject: string | null = null;
|
||||||
|
|
||||||
|
const aliceIsUp = actorIsUp(ALICE, () => alice);
|
||||||
|
const bobIsUp = actorIsUp(BOB, () => bob);
|
||||||
|
const indexExists: Prerequisite = () =>
|
||||||
|
index === null ? "Alice never created an index" : null;
|
||||||
|
|
||||||
|
await journey({
|
||||||
|
name: "Alice signs in and creates an index",
|
||||||
|
run: async () => {
|
||||||
|
alice = await signIn(ctx!, served.url, ALICE, null);
|
||||||
|
const who = await alice.frame.evaluate(() => window.__indexing.whoami());
|
||||||
|
check("Alice signs in and the application knows who she is", who.length > 0, `who=${who}`);
|
||||||
|
|
||||||
|
index = await step("Alice creating an index", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((f) => window.__indexing.createIndex(f), PUBLISHED_AT),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"creating an index answers with a document reference",
|
||||||
|
typeof index === "string" && index.startsWith("did:ng:"),
|
||||||
|
`index=${index}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The descriptor's round trip — and the first thing the fake could have been
|
||||||
|
// lying about: the index document is found by an EXACT match on its own NURI as
|
||||||
|
// a subject, so a broker that returns a subject shaped differently breaks every
|
||||||
|
// read of every index.
|
||||||
|
const raw = await step("Alice reading the index document", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
||||||
|
);
|
||||||
|
const self = raw.find((s) => s.subject === index);
|
||||||
|
const declared = self?.props[INDEX_FIELD] ?? [];
|
||||||
|
check(
|
||||||
|
"the index document declares the field it indexes by",
|
||||||
|
declared.length === 1 && declared[0] === PUBLISHED_AT && self?.graph === index,
|
||||||
|
`subjects=${raw.length} self=${self === undefined ? "(not found)" : "found"} ` +
|
||||||
|
`graph=${self?.graph} declared=${JSON.stringify(declared)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await journey({
|
||||||
|
name: "Bob signs in configured with Alice's index, and publishes an object",
|
||||||
|
needs: [indexExists],
|
||||||
|
run: async () => {
|
||||||
|
bob = await signIn(ctx!, served.url, BOB, index);
|
||||||
|
const configured = await bob.frame.evaluate(() => window.__indexing.configuredIndex());
|
||||||
|
check(
|
||||||
|
"Bob signs in, configured with the index his application contributes to",
|
||||||
|
configured === index,
|
||||||
|
`configured=${configured}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
bobsObject = await step("Bob publishing an object", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate(
|
||||||
|
([p, v]) => window.__indexing.publishObject(p!, v!),
|
||||||
|
[PUBLISHED_AT, "2026-08-17T09:00:00Z"],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"Bob publishes a public object carrying the indexed field",
|
||||||
|
typeof bobsObject === "string" && bobsObject.startsWith("did:ng:"),
|
||||||
|
`object=${bobsObject}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The CONTROL for the write form: the same primitive, the document as its own
|
||||||
|
// subject. This is the shape the polyfill's own suites already exercise.
|
||||||
|
const raw = await step("Bob reading his own object", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate((d) => window.__indexing.readRaw(d), bobsObject!),
|
||||||
|
);
|
||||||
|
const self = raw.find((s) => s.subject === bobsObject);
|
||||||
|
check(
|
||||||
|
"Bob's object reads back carrying the value he wrote",
|
||||||
|
(self?.props[PUBLISHED_AT] ?? []).includes("2026-08-17T09:00:00Z"),
|
||||||
|
`subjects=${raw.length} props=${JSON.stringify(self?.props ?? {})}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await journey({
|
||||||
|
name: "Bob hands the index a reference, and Alice curates it",
|
||||||
|
needs: [
|
||||||
|
aliceIsUp,
|
||||||
|
bobIsUp,
|
||||||
|
indexExists,
|
||||||
|
() => (bobsObject === null ? "Bob never published an object" : null),
|
||||||
|
],
|
||||||
|
run: async () => {
|
||||||
|
// Bob names his OWN object, and the index he was configured with. Nothing about
|
||||||
|
// the value travels: the deposit is the reference and nothing else.
|
||||||
|
await step("Bob depositing a reference", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), bobsObject!),
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = await step("Alice curating", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
|
||||||
|
);
|
||||||
|
// The deposit is proven ARRIVED, by the only person who can see it. That the post
|
||||||
|
// did not throw is a weaker claim entirely — it says the call returned, not that
|
||||||
|
// anything crossed the identity boundary — and asserting it would be asserting a
|
||||||
|
// constant. Alice reads her own inbox; one outcome means one deposit reached it.
|
||||||
|
check(
|
||||||
|
"a stranger's deposit into the index's inbox is accepted",
|
||||||
|
report.outcomes.length === 1,
|
||||||
|
`from=${BOB} outcomes=${report.outcomes.length}`,
|
||||||
|
);
|
||||||
|
const forBob = report.outcomes.find(
|
||||||
|
(o) => "object" in o && o.object === bobsObject,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"curation reports Bob's object as indexed",
|
||||||
|
forBob?.result === "indexed",
|
||||||
|
`outcomes=${JSON.stringify(report.outcomes)}`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"the indexed value was read off Bob's object, and never travelled in his deposit",
|
||||||
|
forBob?.result === "indexed" && forBob.value === "2026-08-17T09:00:00Z",
|
||||||
|
`value=${forBob !== undefined && "value" in forBob ? forBob.value : "(none)"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// THE WRITE FORM, answered. An entry is a triple whose subject is another
|
||||||
|
// document, written into this one's anchored default graph. "The write did not
|
||||||
|
// throw" is not the same claim as "oxigraph stored it": this reads it back.
|
||||||
|
const raw = await step("Alice reading the index document back", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
||||||
|
);
|
||||||
|
const entry = raw.find((s) => s.subject === bobsObject);
|
||||||
|
check(
|
||||||
|
"the entry is stored under Bob's object's own reference as its subject",
|
||||||
|
(entry?.props[ENTRY_VALUE] ?? []).includes("2026-08-17T09:00:00Z"),
|
||||||
|
`subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await journey({
|
||||||
|
name: "The index reads back, for its owner and for a stranger",
|
||||||
|
needs: [aliceIsUp, bobIsUp, indexExists],
|
||||||
|
run: async () => {
|
||||||
|
const mine = await step("Alice reading the index", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"Alice reads exactly one entry, and it is Bob's object",
|
||||||
|
mine.length === 1 && mine[0]?.object === bobsObject,
|
||||||
|
`entries=${JSON.stringify(mine)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A public index is read by whoever holds its reference — including someone who
|
||||||
|
// owns neither it nor anything in it. This is the act an application performs.
|
||||||
|
const theirs = await step("Bob reading the index he does not own", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"Bob, who does not own the index, reads the same entry",
|
||||||
|
theirs.length === 1 && theirs[0]?.object === bobsObject,
|
||||||
|
`entries=${JSON.stringify(theirs)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deposits are never retired, so every run sees every deposit again. Convergence
|
||||||
|
// is what makes that affordable.
|
||||||
|
const again = await step("Alice curating a second time", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
|
||||||
|
);
|
||||||
|
const still = await step("Alice reading the index again", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"curating a second time changes nothing, and the index still holds one entry",
|
||||||
|
again.outcomes.every((o) => o.result === "unchanged") && still.length === 1,
|
||||||
|
`outcomes=${JSON.stringify(again.outcomes)} entries=${still.length}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await journey({
|
||||||
|
name: "An object carrying nothing for the field is not indexed",
|
||||||
|
needs: [aliceIsUp, bobIsUp, indexExists],
|
||||||
|
run: async () => {
|
||||||
|
// PRESENT but carrying nothing for the field — which is a different answer from
|
||||||
|
// an object that cannot be read at all, and the reason this object carries a
|
||||||
|
// predicate rather than being empty: an empty document reads exactly like an
|
||||||
|
// unreadable one, and resolves as `unresolved`, not `skipped`.
|
||||||
|
const other = await step("Bob publishing an unrelated object", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate(
|
||||||
|
([p, v]) => window.__indexing.publishObject(p!, v!),
|
||||||
|
[UNRELATED, "nothing to index by"],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await step("Bob depositing the unrelated reference", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), other),
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = await step("Alice curating the unrelated reference", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
|
||||||
|
);
|
||||||
|
const forOther = report.outcomes.find((o) => "object" in o && o.object === other);
|
||||||
|
check(
|
||||||
|
"curation reports it skipped for want of the field, rather than indexed",
|
||||||
|
forOther?.result === "skipped" && forOther.reason === "no-field",
|
||||||
|
`outcome=${JSON.stringify(forOther)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = await step("Alice reading the index once more", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"the index still holds exactly one entry",
|
||||||
|
entries.length === 1,
|
||||||
|
`entries=${JSON.stringify(entries)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await journey({
|
||||||
|
name: "An index whose inbox cannot be opened leaves a document behind",
|
||||||
|
needs: [aliceIsUp],
|
||||||
|
run: async () => {
|
||||||
|
const outcome = await step("Alice creating an index whose inbox fails", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate(
|
||||||
|
(f) => window.__indexing.createIndexWithBrokenInbox(f),
|
||||||
|
PUBLISHED_AT,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"createIndex refuses when the inbox cannot be opened",
|
||||||
|
outcome.rejected !== null && outcome.returned === null,
|
||||||
|
`rejected=${outcome.rejected} returned=${outcome.returned}`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"a document was nevertheless created in the owner's public store",
|
||||||
|
outcome.appeared.length === 1,
|
||||||
|
`appeared=${JSON.stringify(outcome.appeared)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// What the leaked document IS: an index in every respect but the one that makes
|
||||||
|
// it usable. Alice found it in her own store — the only way anyone can, since
|
||||||
|
// `createIndex` threw its reference away.
|
||||||
|
const leaked = outcome.appeared[0];
|
||||||
|
if (leaked === undefined) {
|
||||||
|
check(
|
||||||
|
"the leaked document carries a descriptor but accepts no deposit",
|
||||||
|
false,
|
||||||
|
"no document appeared, so there was nothing to inspect",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = await step("Alice reading the leaked document", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), leaked),
|
||||||
|
);
|
||||||
|
const declares = (raw.find((s) => s.subject === leaked)?.props[INDEX_FIELD] ?? []).includes(
|
||||||
|
PUBLISHED_AT,
|
||||||
|
);
|
||||||
|
const refused = await step("Alice trying to deposit into it", BRIDGE_MS, async () => {
|
||||||
|
try {
|
||||||
|
await alice!.frame.evaluate(
|
||||||
|
([i, o]) => window.__indexing.referTo(i!, o!),
|
||||||
|
[leaked, bobsObject ?? leaked],
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return firstLine(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
"the leaked document carries a descriptor but accepts no deposit",
|
||||||
|
declares && refused !== null,
|
||||||
|
`declares=${declares} deposit=${refused ?? "(accepted)"}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// LAST, deliberately: if the escaping below turned out not to hold, the damage would
|
||||||
|
// be to this index, and every check above has already been taken.
|
||||||
|
await journey({
|
||||||
|
name: "A hostile value crosses the round trip as one inert literal",
|
||||||
|
needs: [aliceIsUp, bobIsUp, indexExists],
|
||||||
|
run: async () => {
|
||||||
|
// `src/sparql.ts` carries this package's OWN escaping, because the polyfill
|
||||||
|
// publishes none. Until now it had only ever been judged by a fake whose SPARQL
|
||||||
|
// reader was written from the same assumptions — a pair that agrees with itself
|
||||||
|
// proves nothing about oxigraph. This value closes every construct the escaping
|
||||||
|
// is responsible for: the literal's own quote, a backslash, the whitespace
|
||||||
|
// escapes, and a complete injected UPDATE that would empty the index if the
|
||||||
|
// quote ever escaped its literal.
|
||||||
|
const hostile =
|
||||||
|
'a "quoted" part, a \\ backslash, a\nnewline, a\ttab, ' +
|
||||||
|
'" } ; DROP ALL ; INSERT DATA { <urn:ng-helpers-e2e:pwned> <urn:ng-helpers-e2e:pwned> "';
|
||||||
|
|
||||||
|
const object = await step("Bob publishing a hostile value", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate(
|
||||||
|
([p, v]) => window.__indexing.publishObject(p!, v!),
|
||||||
|
[PUBLISHED_AT, hostile],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const raw = await step("Bob reading the hostile object", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate((d) => window.__indexing.readRaw(d), object),
|
||||||
|
);
|
||||||
|
const stored = raw.find((s) => s.subject === object)?.props[PUBLISHED_AT] ?? [];
|
||||||
|
check(
|
||||||
|
"the object reads back the hostile value byte for byte",
|
||||||
|
stored.length === 1 && stored[0] === hostile,
|
||||||
|
`stored=${JSON.stringify(stored)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await step("Bob depositing the hostile reference", BRIDGE_MS, () =>
|
||||||
|
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), object),
|
||||||
|
);
|
||||||
|
await step("Alice curating the hostile reference", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read the index document RAW: it must still declare its own field. An injected
|
||||||
|
// `DROP ALL` that had taken effect would show up exactly here, as a descriptor
|
||||||
|
// that is no longer there — and `read()` alone could not tell that apart from an
|
||||||
|
// ordinary failure.
|
||||||
|
const after = await step("Alice reading the index after the hostile entry", BRIDGE_MS, () =>
|
||||||
|
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
||||||
|
);
|
||||||
|
const entry = after.find((s) => s.subject === object)?.props[ENTRY_VALUE] ?? [];
|
||||||
|
const descriptor = after.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? [];
|
||||||
|
check(
|
||||||
|
"the index holds it as one entry, and its own descriptor is untouched",
|
||||||
|
entry.length === 1 && entry[0] === hostile && descriptor.includes(PUBLISHED_AT),
|
||||||
|
`entry=${JSON.stringify(entry)} descriptor=${JSON.stringify(descriptor)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (ctx !== null) await closeContext("actors", ctx);
|
||||||
|
if (closeServer !== null) {
|
||||||
|
await closeQuietly("the application server", async () => closeServer!());
|
||||||
|
}
|
||||||
|
wallet.discard();
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((e: unknown) => {
|
||||||
|
console.error("[e2e] fatal:", (e as Error)?.stack ?? e);
|
||||||
|
finish(firstLine(e));
|
||||||
|
});
|
||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ng-helpers/indexing",
|
"name": "@ng-helpers/indexing",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "An indexing layer built on top of NextGraph, via @ng-eventually/polyfill. An index is an ordinary public document; contributions reach it through its inbox; its owner curates it.",
|
"description": "An indexing layer built on top of NextGraph, via @ng-eventually/polyfill. An index is an ordinary public document; contributions reach it through its inbox; its owner curates it.",
|
||||||
@@ -13,11 +13,15 @@
|
|||||||
"@ng-eventually/polyfill": "file:../ng-eventually-js/packages/polyfill"
|
"@ng-eventually/polyfill": "file:../ng-eventually-js/packages/polyfill"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ng-org/web": "0.1.2-alpha.13",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"ng-e2e-helpers": "file:../ng-eventually-js/packages/ng-e2e-helpers",
|
||||||
|
"playwright": "1.61.1",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
|
"test:e2e": "bun run e2e/run.ts",
|
||||||
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,5 +12,5 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src", "test"]
|
"include": ["src", "test", "e2e"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user