Compare commits
2 Commits
75378fc5a4
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| aeb8c7d157 | |||
| f4050b95c0 |
@@ -4,3 +4,8 @@ dist/
|
||||
.DS_Store
|
||||
bun.lockb
|
||||
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",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"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.",
|
||||
@@ -13,11 +13,15 @@
|
||||
"@ng-eventually/polyfill": "file:../ng-eventually-js/packages/polyfill"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ng-org/web": "0.1.2-alpha.13",
|
||||
"@types/bun": "latest",
|
||||
"ng-e2e-helpers": "file:../ng-eventually-js/packages/ng-e2e-helpers",
|
||||
"playwright": "1.61.1",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"test:e2e": "bun run e2e/run.ts",
|
||||
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,5 +12,5 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
"include": ["src", "test", "e2e"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user