Compare commits
4 Commits
75378fc5a4
..
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce2113157 | |||
| 821ea997fe | |||
| 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,155 @@
|
|||||||
|
---
|
||||||
|
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;
|
||||||
|
- **supply `@ng-eventually/polyfill` itself.** This package declares it a *peer*, not a dependency: the application names it among its own dependencies and decides which copy it gets. That copy must be the very one the application's own code calls, because everything this package does passes through it — and that package requires exactly one instance of itself in an application, for reasons its own contract states.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
**Obtaining it.** This package is not published to npm, nor to any other package host, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. `@ng-eventually/polyfill` is distributed the same way. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and what you must provide alongside it.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**The tag is bare — `v1.0.1` — because this repository publishes exactly one engagement**, so there is nothing for a prefix to disambiguate. Should a second one ever ship here, tags take the package name from that point on (`indexing/v…`), because a bare tag stops saying which surface it froze the day two versions move independently. Bare tags already laid stay valid as history.
|
||||||
|
|
||||||
|
`1.0.0` was a baseline, not a claim of maturity: it was the number that made your pin mean something. Nothing was released before it. **It could not be installed, however**, and `1.0.1` supersedes it. `1.0.0` declared `@ng-eventually/polyfill` as a dependency resolved through a path that existed only in one working copy, so every attempt to install it from anywhere else failed outright — not on some operations but at the install itself, which is why no application ever ran it. `1.0.1` declares that package a peer, which the application supplies. Nothing exported moved, which is what makes this a patch and not a major: the only thing that changed for a caller is a requirement it could never have satisfied before, so there is no working arrangement for it to break.
|
||||||
|
|
||||||
|
**`1.0.0` is superseded, not withdrawn.** The tag stays where it is and keeps resolving, because no pinned reference is ever taken away from under you — this contract's policy holds even for a version that never worked. Nothing forces an upgrade; it is simply that an installation pinned there cannot have succeeded, so there is nothing to migrate.
|
||||||
|
|
||||||
|
This engagement is cut on `main`, so `1.0.1` is what you pin, and your `usage_` leaf anchors `against:` on that exact string — `against: @ng-helpers/indexing@1.0.1`. 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
|
||||||
@@ -80,12 +80,16 @@ Deliberately not settled. Each is implemented in its narrowest form and reported
|
|||||||
|
|
||||||
## Depends on
|
## Depends on
|
||||||
|
|
||||||
`@ng-eventually/polyfill`, by local path (`file:../ng-eventually-js/packages/polyfill`), which expects that repository to sit beside this one.
|
`@ng-eventually/polyfill`, declared as a **peer** dependency: an application using this package supplies it, so exactly one copy of it exists in that application. That is a requirement of the polyfill itself, which keeps its state in the package — two copies mean two subscription registries and two current identities, and nothing detects it.
|
||||||
|
|
||||||
|
For this repository's own tests and typecheck it is *also* a `devDependency` by local path (`file:../ng-eventually-js/packages/polyfill`), which expects that repository to sit beside this one. A dev dependency is not installed by a consumer, so this local path never reaches one. `ng-e2e-helpers` is a `devDependency` by local path on the same expectation.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun install
|
npm install # or: pnpm install
|
||||||
bunx tsc --noEmit -p tsconfig.json
|
bunx tsc --noEmit -p tsconfig.json
|
||||||
bun test
|
bun test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`bun install` does not work in this repository** (checked with bun 1.3.9): bun resolves a mandatory peer dependency against the npm registry whatever local path provides it, and `@ng-eventually/polyfill` is published to no registry, so the install stops on `GET https://registry.npmjs.org/@ng-eventually%2fpolyfill - 404`. `npm install` and `pnpm install` both resolve it from the sibling checkout. `bun test` itself is unaffected — it is only the installer that cannot express this.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
+8
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ng-helpers/indexing",
|
"name": "@ng-helpers/indexing",
|
||||||
"version": "0.0.0",
|
"version": "1.0.1",
|
||||||
"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.",
|
||||||
@@ -9,15 +9,20 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"peerDependencies": {
|
||||||
"@ng-eventually/polyfill": "file:../ng-eventually-js/packages/polyfill"
|
"@ng-eventually/polyfill": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ng-eventually/polyfill": "file:../ng-eventually-js/packages/polyfill",
|
||||||
|
"@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