feat: un index est un document ordinaire, et il ne fait que grandir
Nouveau dépôt, séparé de ng-eventually-js à dessein : NextGraph n'aura jamais
de notion d'index, à aucun niveau. Ce n'est donc pas un échafaudage en attente
d'un amont, c'est une construction au-dessus — et le polyfill ne doit rien
apprendre de l'indexation. Sa boîte de réception reste générique et transporte
des dépôts opaques ; ce qu'un dépôt VEUT DIRE se décide ici.
La frontière tient par un seul fichier : polyfill-adapter.ts est le seul import
runtime du polyfill, tout le reste est écrit contre NextGraphPort. Les six
entrées utilisées sont toutes publiées dans contract_polyfill-surface.
Un index est un document ordinaire du store public de son créateur. Ce qui en
fait un index, c'est qu'une application référence sa NURI dans son propre code.
Il déclare, sur son propre sujet, le champ qu'il indexe — un prédicat, les
objets étant du RDF. « Indexé par une date » n'est pas un genre d'index à part :
c'est un index dont le champ est un prédicat de date, et les entrées ressortent
dans l'ordre chronologique parce qu'ISO-8601 se trie comme une chaîne.
UN DÉPÔT EST UNE RÉFÉRENCE NUE, RIEN D'AUTRE. Pas d'opération, pas de référence
à l'index (l'adresse de la boîte l'identifie déjà), pas de copie de la valeur
indexée. Le curateur résout la référence et REGARDE ; ce que dit l'objet fait
foi, pas ce que dit le déposant. C'est la forme qu'utilise déjà l'amont, où une
SocialQueryRequest porte une référence et le destinataire compose son propre
SPARQL. Une charge utile portant une opération serait un droit d'écriture sur
le document d'autrui, puisque n'importe qui peut déposer.
Corollaire : aucune vérification de propriété, et il n'en faut aucune. Déposer
une référence n'obtient rien de plus que ce que le propriétaire aurait fait —
ce qui permet à un passant qui remarque une entrée manquante de relancer la
vérification.
UN INDEX NE FAIT QUE GRANDIR. Rien n'en est jamais retiré, par personne. C'est
cette limitation qui rend l'histoire des pannes triviale : la seule écriture
étant un ajout, une référence qui ne se résout pas — objet disparu, illisible,
ou broker muet — ne peut jamais signifier que « pas ajouté cette fois ». Rien
n'a à distinguer une absence d'un échec, donc rien ne peut se tromper là-dessus.
C'est le défaut corrigé en 8c8ade7 et e32b6d0, où une lecture qui ÉCHOUAIT
ressortait comme une absence.
Mais LE CHEMIN D'ÉCRITURE N'A JAMAIS ÉTÉ LE PROBLÈME. Trois tours de revue
adverse ont cassé la garantie cinq fois, sans jamais rien supprimer — toujours
en LECTURE :
- lire une entrée exigeait EXACTEMENT une valeur : un sujet en portant deux se
lisait comme ABSENT, et un second addLiteralProperty faisait disparaître une
entrée. Deux curations concurrentes produisent exactement cet état ;
- la même règle sur le champ déclaré était pire : un unique ajout d'un second
INDEX_FIELD rendait le descripteur illisible et emportait TOUTES les entrées,
définitivement ;
- un seul sujet non-NURI levait hors de entriesOf et rendait d'un coup toutes
les vraies entrées illisibles ;
- un champ nommé « constructor » renvoyait une fonction héritée de
Object.prototype et faisait planter la curation pour tous les dépôts restants ;
- et le correctif du deuxième point CORROMPAIT l'index à la place : « la plus
petite l'emporte » changeait le champ alors que les entrées déjà écrites
gardaient l'ancien, donc read() rendait une liste unique « triée par valeur »
mêlant deux propriétés. Une réponse fausse et silencieuse, pire qu'un arrêt.
Ce qui tient maintenant : une entrée existe dès UNE valeur, la plus petite
l'emporte, de façon déterministe. La lecture est tolérante entrée par entrée et
ne lit que les propriétés propres. Lire un index demande seulement « est-ce un
index ? » ; le champ n'est exigé que pour CURER, et une déclaration ambiguë
refuse bruyamment au lieu de choisir. Ce refus est définitif : c'est le prix
honnête de l'absence de suppression, et le message le dit au lieu de suggérer
un réessai.
La garde anti-suppression portait elle-même le défaut qu'elle dénonçait. Elle
listait des noms d'export, puis a scanné la source : quatre contournements
passaient encore (COPY DEFAULT TO GRAPH, un mot-clé caché derrière le retrait
des commentaires, DELETE{ sans espace, un littéral coupé en concaténations).
Un motif sur la SOURCE se contourne toujours. La vraie garde EXÉCUTE désormais
l'adaptateur contre un enregistreur et relit chaque requête émise — les quatre
y échouent. Le scan de source reste, dégradé en simple fil-piège.
Le double de test construisait props par affectation simple alors que l'amont
fait (props[p] ??= []).push(o) : il était plus permissif que la réalité, et un
test s'appuyait dessus pour affirmer un résultat que la production ne peut pas
produire. Il construit maintenant props à l'identique.
La leçon vaut d'être gardée : « rien ne supprime » est une affirmation sur le
chemin d'ÉCRITURE, et un invariant sur ce qu'un lecteur VOIT doit se vérifier
aussi sur le chemin de LECTURE.
Un échec reste un échec et reste VISIBLE : inoffensif n'est pas invisible. Toute
référence non résolue ressort en `unresolved` dans le rapport et est signalée ;
la règle « lecture vide = non résolu » vit dans resolution.ts, à part de l'I/O,
parce que dans l'adaptateur aucun test ne l'atteignait — et la supprimer laissait
la suite verte pendant qu'un échec était classé « l'objet n'a pas le champ ».
Questions ouvertes, documentées dans le README plutôt que tranchées : un objet
sans le champ déclaré, un objet à plusieurs valeurs, une entrée qui ne change
jamais après coup, quelle valeur garde une entrée disputée, comment un index se
remet d'une déclaration ambiguë, des dépôts jamais retirés.
59 tests, tsc --noEmit vert. Aucune exécution contre un vrai broker.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
bun.lockb
|
||||
bun.lock
|
||||
@@ -0,0 +1,86 @@
|
||||
# ng-helpers
|
||||
|
||||
Helpers built **on top of** NextGraph. The first — and for now the only — one is `@ng-helpers/indexing`: an index that an application can point at and read.
|
||||
|
||||
## Why this is not in the polyfill
|
||||
|
||||
NextGraph has no indexing concept, at any level, and will not grow one. This is not scaffolding standing in for something that will arrive upstream; it is a construction *above* NextGraph, and it stays in its own repository so that the two are never confused.
|
||||
|
||||
So the dependency runs one way and only one way: **this repo depends on `@ng-eventually/polyfill`, and the polyfill must never learn anything about indexing.** No index vocabulary, no index type, no inbox variant that knows what a deposit means. The polyfill's inbox stays generic and carries opaque deposits; what a deposit *means* is decided here.
|
||||
|
||||
That boundary is held by one file. `src/polyfill-adapter.ts` is the only place that imports the polyfill at runtime; everything else is written against `NextGraphPort` (`src/port.ts`), a small interface describing what this layer needs. Only entries listed in that package's published `contract_polyfill-surface` are used — nothing is reached for inside it.
|
||||
|
||||
## What an index is
|
||||
|
||||
**An ordinary document.** Nothing marks it as an index. What makes it one is that an application references its NURI in its own source.
|
||||
|
||||
- Any user may create one, and its creator owns it.
|
||||
- It lives in the creator's **public store**, so any reader can open it from the reference alone.
|
||||
- It declares, on its own subject, the **field** it indexes by — a predicate, since the objects are RDF. An index "by a date" is simply an index whose field is a date predicate: there is no separate kind of index. Entries come out in chronological order because ISO-8601 sorts as a string.
|
||||
- Each entry is a subject keyed by the indexed object's NURI, carrying that object's value for the field.
|
||||
|
||||
**Contributing is a deposit, not a write.** Creating an object that belongs in an index means depositing into the index document's inbox — `inbox.postToDocument`, which is exactly "reach this document's owner" and is open to anyone. Nobody but the owner ever writes the index.
|
||||
|
||||
**A deposit is a bare reference. Nothing else.** It states no claim and gives no instruction: no operation, no index reference (the inbox address already identifies the index), no copy of the indexed value. When the owner curates, it resolves the reference and opens the object itself — which it can, because indexing is limited to public data for now. What the object says is what goes in. This is the shape NextGraph already uses upstream, where a `SocialQueryRequest` carries a reference to an RDF definition and the recipient composes its own update; a payload carrying an operation would be a licence for anyone to rewrite someone else's document.
|
||||
|
||||
**Reading needs nothing new.** An application that knows the NURI calls `readUnion([indexNuri])` and gets the entries as subjects. `Indexing.read` is sugar over exactly that, dropping the index's own declaration subject.
|
||||
|
||||
## An index only ever grows
|
||||
|
||||
**Nothing is ever removed from an index — by anyone, including its owner.** This is a deliberate limitation, written down here rather than left to be discovered.
|
||||
|
||||
It is what makes the failure story trivial. Since the only write is an addition, a reference that does not resolve — the object is gone, or unreadable, or the broker simply did not answer — can only ever mean *not added this time*. It cannot damage what is already there, and a later deposit adds it. Nothing has to tell an absence from a failure, so nothing can get that wrong.
|
||||
|
||||
That distinction is worth stating, because getting it wrong is a defect this family of code has already produced: in `ng-eventually-js`, `resolveAccount` returned `null` on a failed lookup, so a read that *failed* came back as "this account does not exist" — a failure disguised as an absence, which silently broke document sharing (fixed in `8c8ade7` and `e32b6d0`).
|
||||
|
||||
`NextGraphPort` has no operation that removes, and nothing in `src/` can express a deletion — but **the write path was never where this went wrong.** Three rounds of adversarial review broke the guarantee five times, and not one of the breaks involved deleting anything. Every one was on the **read** path:
|
||||
|
||||
- reading an entry required *exactly one* value, so a subject carrying two read as **absent** — and a second `addLiteralProperty`, the only write this package has, made an entry vanish while both triples sat in the document. Two curation runs racing each other produce exactly that;
|
||||
- the same rule on the declared field was worse: one add-only write of a second `INDEX_FIELD` made the descriptor unreadable and took **every entry in the document** with it, permanently;
|
||||
- one subject that is not a NURI threw out of `entriesOf` and made every real entry unreadable at once;
|
||||
- a field named `constructor` or `toString` read back an inherited function from the plain object `readUnion` builds, crashing curation for every remaining deposit;
|
||||
- and the fix for the second of these silently **corrupted** the index instead: "smallest wins" switched the field while entries already written kept their old one, so `read()` returned a single list "ordered by value" whose values came from two different properties.
|
||||
|
||||
What holds now: entries take **at least one value, smallest wins**, deterministically. Reading is **per-entry tolerant** — a stray subject is skipped, never thrown on — and reads **own properties only**. Reading an index asks only *is this an index?*; the field is required only to **curate**, and an ambiguous one refuses loudly rather than picking, because a quiet wrong answer is worse than a loud stop. That refusal is permanent, which is the honest price of having no delete, and the message says so instead of suggesting a retry.
|
||||
|
||||
The lesson is worth keeping: **"nothing removes" is a claim about the write path, and an invariant about what a reader can *see* has to be checked on the read path too.**
|
||||
|
||||
**A failed resolve is still a failure, and still surfaces.** Harmless is not the same as invisible. Every reference that could not be resolved comes back as an `unresolved` outcome in the curation report and is warned about — a failure that looks exactly like a normal outcome teaches nobody anything.
|
||||
|
||||
## Open questions
|
||||
|
||||
Deliberately not settled. Each is implemented in its narrowest form and reported rather than generalised.
|
||||
|
||||
- **An object that carries nothing for the index's field.** Narrow behaviour: it is not added, and reported as `skipped: "no-field"`. There is no key to index it by, and inventing one — a placeholder, the deposit's timestamp — would put something in the index that the object does not say. Whether it should instead be indexed under an absent key, or refused louder, is open.
|
||||
- **An object that carries several values for the field.** Not added, reported as `skipped: "several-values"`. Which of them the entry would hold has not been decided.
|
||||
- **An entry never changes after it is made.** An already-indexed object is not even re-read, so an object whose field value changes later keeps its original value in the index. Refreshing it would be a write nobody asked for, and it is the same question as removal.
|
||||
- **Which value a raced entry should keep.** Two curation runs racing each other can leave an entry with two values; the smallest is chosen so that readers agree and the entry stays visible. That the entry must survive is settled; *which* of the two it should hold is not.
|
||||
- **How an index recovers from an ambiguous declaration.** Today it does not: curation refuses for good and the only way forward is a fresh index. Since nothing here removes anything, giving it a way back needs a mechanism that does not exist yet.
|
||||
- **Deposits are never retired.** Every curation run sees every deposit ever made. That is affordable because re-applying one is a no-op, but it is linear in the history. How a curator retires an applied deposit is open — `inbox.processInbox` may be the answer, but its semantics are not published.
|
||||
- **What an entry holds besides the object reference and the field value**, and **how several index kinds would coexist**, are both untouched.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What it is |
|
||||
| --- | --- |
|
||||
| `src/port.ts` | What this layer needs from NextGraph, and nothing more |
|
||||
| `src/polyfill-adapter.ts` | The only runtime import of `@ng-eventually/polyfill` |
|
||||
| `src/deposit.ts` | The deposit's shape: a bare reference |
|
||||
| `src/index-document.ts` | An index's declaration and its entries |
|
||||
| `src/curator.ts` | Resolving references and adding what is there |
|
||||
| `src/indexing.ts` | The public surface, bound to one identity |
|
||||
| `src/sparql.ts` | The one statement this package writes — no deletion exists |
|
||||
| `test/fake-nextgraph.ts` | An in-memory NextGraph enforcing the polyfill's published guarantees |
|
||||
| `test/adapter-write-path.test.ts` | Runs the real adapter against a recorder and reads back every query it emits |
|
||||
|
||||
## Depends on
|
||||
|
||||
`@ng-eventually/polyfill`, by local path (`file:../ng-eventually-js/packages/polyfill`), which expects that repository to sit beside this one.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
bun install
|
||||
bunx tsc --noEmit -p tsconfig.json
|
||||
bun test
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@ng-helpers/indexing",
|
||||
"version": "0.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.",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ng-eventually/polyfill": "file:../ng-eventually-js/packages/polyfill"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import type { NextGraphPort, Nuri, NuriLike, UnionSubject } from "./port";
|
||||
import { asNuri } from "./nuri";
|
||||
import { decodeReference } from "./deposit";
|
||||
import { descriptorOf, entriesOf, readIndexDocument, valuesOf } from "./index-document";
|
||||
import { ENTRY_VALUE } from "./vocabulary";
|
||||
|
||||
/**
|
||||
* Applying the references an index has received. Runs as the index's OWNER — only
|
||||
* the owner reads the inbox, and only the owner writes the document.
|
||||
*
|
||||
* ## The curator does not act on a claim, it resolves the reference and looks
|
||||
*
|
||||
* A deposit is a bare reference. For each one the curator opens the object
|
||||
* itself — which it can, because indexing is limited to PUBLIC data for now:
|
||||
*
|
||||
* - the object is **present** → it is added to the index, under the value it
|
||||
* holds for the index's field, unless it is already there;
|
||||
* - **anything else** → nothing at all happens.
|
||||
*
|
||||
* So there is no ownership check, and none is needed. A deposit is an invitation
|
||||
* to re-examine, not an instruction: the truth is what the object says, not what
|
||||
* the depositor says. A stranger who deposits a reference achieves exactly what
|
||||
* the owner would have — which is what lets a passer-by who notices a missing
|
||||
* entry nudge the index into re-checking it.
|
||||
*
|
||||
* ## AN INDEX ONLY EVER GROWS
|
||||
*
|
||||
* Nothing here removes an entry. Not "removal is guarded", not "removal needs
|
||||
* proof" — there is no removal, and `NextGraphPort` has no operation that could
|
||||
* express one.
|
||||
*
|
||||
* That is what makes the failure story trivial. Since the only write is an
|
||||
* addition, a read that comes back empty — whether the object is gone, or
|
||||
* unreadable, or the broker simply did not answer — can only ever mean "not added
|
||||
* this time". It cannot damage what is already there, and a later deposit adds
|
||||
* it. Nothing has to tell an absence from a failure, so nothing can get that
|
||||
* wrong. Compare the defect this avoids: a failed read resolving as an absence,
|
||||
* removed from `ng-eventually-js` in 8c8ade7 and e32b6d0 — "le silence n'est plus
|
||||
* possible que sur une absence VÉRIFIÉE".
|
||||
*
|
||||
* The cost is stated plainly, because someone will eventually need it: an entry
|
||||
* cannot be taken out of an index, by anyone, including the index's owner.
|
||||
*
|
||||
* ## A failure still has to be seen
|
||||
*
|
||||
* Harmless is not the same as invisible. Every reference that could not be
|
||||
* resolved comes back as an `unresolved` outcome AND is warned about, because a
|
||||
* failure that looks exactly like a normal outcome teaches nobody anything.
|
||||
*
|
||||
* ## Deposits are not consumed
|
||||
*
|
||||
* Nothing here retires an applied deposit, so every run sees every deposit again.
|
||||
* That is affordable only because a deposit carries no instruction: re-applying
|
||||
* one re-resolves the reference and lands on the same result. Curation is
|
||||
* convergent, and its outcome does not depend on the order references arrive in.
|
||||
*/
|
||||
|
||||
export type CurationOutcome =
|
||||
/** Present, and now in the index under this value. */
|
||||
| { readonly result: "indexed"; readonly object: Nuri; readonly value: string }
|
||||
/** Present, and already in the index. Nothing was written. */
|
||||
| { readonly result: "unchanged"; readonly object: Nuri }
|
||||
/** Resolved, and deliberately not added. */
|
||||
| { readonly result: "skipped"; readonly object: Nuri; readonly reason: SkipReason }
|
||||
/**
|
||||
* The reference could not be resolved — gone, unreadable, or the read failed;
|
||||
* this layer does not tell those apart. Nothing was written, and nothing that
|
||||
* was already in the index was touched. A later deposit will add it.
|
||||
*/
|
||||
| { readonly result: "unresolved"; readonly object: Nuri; readonly reason: string }
|
||||
/** A payload in the inbox that is not a reference at all. */
|
||||
| { readonly result: "foreign"; readonly reason: string };
|
||||
|
||||
export type SkipReason =
|
||||
/** Present, but carries nothing for the index's field. OPEN QUESTION — see README. */
|
||||
| "no-field"
|
||||
/** Present, but carries several values for the field: which one would the entry hold? */
|
||||
| "several-values"
|
||||
/** A reference to the index document itself. */
|
||||
| "self-reference";
|
||||
|
||||
export interface CurationReport {
|
||||
readonly index: Nuri;
|
||||
/**
|
||||
* One per deposit, in the order they were made. Check it for `unresolved`:
|
||||
* those references were not added and are worth a retry or a look.
|
||||
*/
|
||||
readonly outcomes: readonly CurationOutcome[];
|
||||
}
|
||||
|
||||
export async function curate(port: NextGraphPort, indexLike: NuriLike): Promise<CurationReport> {
|
||||
const index = asNuri(indexLike);
|
||||
|
||||
const subjects = await readIndexDocument(port, index);
|
||||
const descriptor = descriptorOf(subjects, index);
|
||||
|
||||
// What the index already holds, kept up to date as this run writes, so that the
|
||||
// same reference deposited twice produces one entry.
|
||||
const indexed = new Set<Nuri>(entriesOf(subjects, index).map((e) => e.object));
|
||||
|
||||
const deposits = await port.readDeposits(index);
|
||||
const outcomes: CurationOutcome[] = [];
|
||||
|
||||
for (const deposit of deposits) {
|
||||
const object = decodeReference(deposit.payload);
|
||||
if (object === null) {
|
||||
outcomes.push({ result: "foreign", reason: "payload is not a reference" });
|
||||
continue;
|
||||
}
|
||||
if (object === index) {
|
||||
outcomes.push({ result: "skipped", object, reason: "self-reference" });
|
||||
continue;
|
||||
}
|
||||
if (indexed.has(object)) {
|
||||
// Already there. Nothing is re-read and nothing is rewritten — an entry, once
|
||||
// made, is never touched again.
|
||||
outcomes.push({ result: "unchanged", object });
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolution = await port.resolveObject(object);
|
||||
if (resolution.state === "unresolved") {
|
||||
console.warn(
|
||||
`[ng-helpers/indexing] ${index}: reference not added — ${object}: ${resolution.reason}`,
|
||||
);
|
||||
outcomes.push({ result: "unresolved", object, reason: resolution.reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
const values = fieldValues(resolution.subjects, descriptor.field);
|
||||
if (values.length === 0) {
|
||||
// NARROW BEHAVIOUR, and an open question: an object carrying nothing for the
|
||||
// field is simply not added. There is no key to index it by, and inventing
|
||||
// one (a placeholder, the deposit time) would put something in the index that
|
||||
// the object does not say.
|
||||
outcomes.push({ result: "skipped", object, reason: "no-field" });
|
||||
continue;
|
||||
}
|
||||
const value = values[0];
|
||||
if (values.length > 1 || value === undefined) {
|
||||
outcomes.push({ result: "skipped", object, reason: "several-values" });
|
||||
continue;
|
||||
}
|
||||
|
||||
await port.addLiteralProperty(index, object, ENTRY_VALUE, value);
|
||||
indexed.add(object);
|
||||
outcomes.push({ result: "indexed", object, value });
|
||||
}
|
||||
|
||||
return { index, outcomes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value the object holds for the field, across all the subjects it carries.
|
||||
*
|
||||
* Goes through `valuesOf`, which reads OWN properties only. The field is named by
|
||||
* the index document, so `constructor` and `toString` are fields an index can
|
||||
* genuinely declare — and reading one off a plain object literal hands back an
|
||||
* inherited FUNCTION where a list of values belongs.
|
||||
*/
|
||||
function fieldValues(subjects: readonly UnionSubject[], field: string): readonly string[] {
|
||||
const values: string[] = [];
|
||||
for (const subject of subjects) {
|
||||
values.push(...valuesOf(subject, field));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Nuri } from "./port";
|
||||
import { isNuri } from "./nuri";
|
||||
|
||||
/**
|
||||
* What a depositor puts in an index document's inbox: **a bare reference, and
|
||||
* nothing else**.
|
||||
*
|
||||
* Not an instruction — not "add this row", not even "I published this". The
|
||||
* curator resolves the reference and looks; what it finds is what decides. This
|
||||
* is the shape NextGraph already uses upstream: a `SocialQueryRequest` carries
|
||||
* `definition_commit_body_ref` — a REFERENCE — and the recipient's
|
||||
* `inbox_processor` composes its own SPARQL. Since anyone may deposit into any
|
||||
* index, a payload that carried an operation would be a licence to rewrite
|
||||
* someone else's document.
|
||||
*
|
||||
* It carries no index reference either. The deposit is addressed to the index
|
||||
* document's inbox, and upstream an inbox belongs to exactly one repo — the
|
||||
* address IS the identification. The polyfill says the same of its own inbox:
|
||||
* "Tagging deposits with their document would be an invention consumers would
|
||||
* have to unlearn at migration."
|
||||
*
|
||||
* And it carries no indexed data. Indexing is limited to PUBLIC objects
|
||||
* precisely so that the curator can open the object itself; a value copied into
|
||||
* the deposit would let a depositor put something in the index that the object
|
||||
* does not say.
|
||||
*
|
||||
* What is left is the NURI. That is the whole payload.
|
||||
*/
|
||||
export type IndexDeposit = Nuri;
|
||||
|
||||
/**
|
||||
* Anyone may deposit anything into an index's inbox, so every payload is
|
||||
* untrusted input. Returns `null` for everything that is not a reference.
|
||||
*/
|
||||
export function decodeReference(payload: unknown): Nuri | null {
|
||||
return isNuri(payload) ? payload : null;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { NextGraphPort, Nuri, NuriLike, UnionSubject } from "./port";
|
||||
import { asNuri, isNuri } from "./nuri";
|
||||
import { ENTRY_VALUE, INDEX_FIELD } from "./vocabulary";
|
||||
|
||||
/**
|
||||
* An index is an ORDINARY document. Nothing in NextGraph marks it as one; what
|
||||
* makes it an index is that an application references its NURI in its own source,
|
||||
* and that it holds the two things below.
|
||||
*
|
||||
* - **A descriptor**, on the document's own NURI as subject: the FIELD this index
|
||||
* indexes by — a predicate, since the objects are RDF.
|
||||
* - **One entry per indexed object**: subject = the object's NURI, carrying the
|
||||
* value that object holds for the field.
|
||||
*
|
||||
* ## Why the field is declared IN the document
|
||||
*
|
||||
* The curator has to know which field to read off an object. The only other place
|
||||
* to keep it is the application's source next to the NURI — and then two
|
||||
* applications pointing at the same index could curate it on two different fields
|
||||
* and corrupt each other's entries. Declared in the document, the index answers
|
||||
* for itself and its creator's choice is the only one.
|
||||
*
|
||||
* The cost is one extra subject in `readUnion([indexNuri])`, told apart from the
|
||||
* entries by being the index's own NURI. `read` drops it; a reader going straight
|
||||
* to `readUnion` sees it.
|
||||
*/
|
||||
|
||||
export interface IndexDescriptor {
|
||||
/** The predicate an indexed object must carry. */
|
||||
readonly field: string;
|
||||
}
|
||||
|
||||
/** One row of an index, as an application reads it. */
|
||||
export interface IndexEntry {
|
||||
/** The indexed object — hand this straight back to `readUnion`. */
|
||||
readonly object: Nuri;
|
||||
/** What that object holds for the index's field. */
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export async function writeDescriptor(
|
||||
port: NextGraphPort,
|
||||
index: Nuri,
|
||||
descriptor: IndexDescriptor,
|
||||
): Promise<void> {
|
||||
await port.addLiteralProperty(index, index, INDEX_FIELD, descriptor.field);
|
||||
}
|
||||
|
||||
/**
|
||||
* The values a subject carries for a predicate.
|
||||
*
|
||||
* Reads OWN properties only, because `props` arrives as a plain object literal
|
||||
* (`readUnion` builds `const props: Record<string, string[]> = {}`), so it
|
||||
* inherits from `Object.prototype`. An index whose field is `constructor`,
|
||||
* `toString` or `valueOf` would otherwise find a FUNCTION where a list of values
|
||||
* belongs — verified: spreading it throws `TypeError: Spread syntax requires
|
||||
* ...iterable[Symbol.iterator] to be a function`, which aborted curation for
|
||||
* every remaining deposit and could not be undone, since the field cannot change.
|
||||
*
|
||||
* The non-string filter is the same caution: this data crosses a process boundary,
|
||||
* and a value that is not a string has no business being compared with `<`.
|
||||
*
|
||||
* The two guards are deliberately redundant — either alone stops the crash, as
|
||||
* mutation testing confirms (removing just one fails nothing; removing both fails
|
||||
* a test). They are kept because they answer different questions: `hasOwn` asks
|
||||
* whether the subject really carries this predicate, `Array.isArray` whether what
|
||||
* came back has the shape the type promises.
|
||||
*/
|
||||
export function valuesOf(
|
||||
subject: UnionSubject | undefined,
|
||||
predicate: string,
|
||||
): readonly string[] {
|
||||
const props = subject?.props;
|
||||
if (props === undefined || !Object.hasOwn(props, predicate)) return [];
|
||||
const values: unknown = props[predicate];
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values.filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest of several values, or `undefined` when there are none.
|
||||
*
|
||||
* The one tie-break rule this package has, used everywhere a document may carry
|
||||
* more values than expected. It is deterministic and order-independent, so every
|
||||
* reader of the same document reaches the same answer — which is the property that
|
||||
* matters, far more than WHICH value wins.
|
||||
*/
|
||||
function smallestOf(values: readonly string[]): string | undefined {
|
||||
let chosen: string | undefined;
|
||||
for (const value of values) {
|
||||
if (chosen === undefined || value < chosen) chosen = value;
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value of an ENTRY: it exists as soon as its subject carries AT LEAST ONE.
|
||||
*
|
||||
* This is what makes "an index only ever grows" true, and requiring exactly one
|
||||
* was a hole big enough to drive the whole guarantee through. Two values for one
|
||||
* object — which two curation runs racing each other produce, since each only ever
|
||||
* ADDS — made the entry read as ABSENT. The index could therefore SHRINK through
|
||||
* nothing but additions: the invariant defeated by the very operation meant to
|
||||
* uphold it, with no delete anywhere in sight.
|
||||
*/
|
||||
export function entryValue(subject: UnionSubject): string | undefined {
|
||||
return smallestOf(valuesOf(subject, ENTRY_VALUE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this document an index at all? Answering needs only that it declares SOME
|
||||
* field — not which one.
|
||||
*
|
||||
* Reading entries is deliberately separated from curating them, because the two
|
||||
* need different things and conflating them cost every entry in the document. An
|
||||
* entry already written is a fact; it does not become unreadable because the
|
||||
* declaration above it turned ambiguous.
|
||||
*/
|
||||
export function assertIndexDocument(subjects: readonly UnionSubject[], index: Nuri): void {
|
||||
const self = subjects.find((s) => s.subject === index);
|
||||
if (valuesOf(self, INDEX_FIELD).length > 0) return;
|
||||
throw new Error(
|
||||
`${index} declares no index field (${INDEX_FIELD}) — either it is not an index, ` +
|
||||
"or it could not be read: an unreadable document and an empty one are the same " +
|
||||
"empty result here. Nothing was written. Retry before concluding it is malformed.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The field to CURATE on — and here it must be unambiguous.
|
||||
*
|
||||
* Both earlier attempts at this were wrong, in opposite directions, and the
|
||||
* second was worse than the first:
|
||||
*
|
||||
* - demanding exactly one and throwing out of the READ path meant a single
|
||||
* add-only write of a second `INDEX_FIELD` made every entry in the document
|
||||
* unreadable, permanently, since nothing here deletes;
|
||||
* - "smallest wins" kept it readable but silently CORRUPTED it: entries already
|
||||
* written are never re-read, so they keep their old field's values while new
|
||||
* ones arrive under the new field, and `read()` returns a single list "ordered
|
||||
* by value" whose values come from two different properties. A quiet wrong
|
||||
* answer, which is worse than a loud failure.
|
||||
*
|
||||
* So: ambiguity refuses to CURATE, loudly, and leaves reading alone. Entries
|
||||
* already in the index stay visible and correct; nothing new is added until a
|
||||
* human looks. That the refusal is permanent is the honest price of having no
|
||||
* delete — the message says so instead of pretending a retry will help.
|
||||
*/
|
||||
export function descriptorOf(subjects: readonly UnionSubject[], index: Nuri): IndexDescriptor {
|
||||
const self = subjects.find((s) => s.subject === index);
|
||||
const declared = valuesOf(self, INDEX_FIELD);
|
||||
|
||||
if (declared.length > 1) {
|
||||
throw new Error(
|
||||
`${index} declares ${declared.length} index fields (${declared.map((f) => JSON.stringify(f)).join(", ")}) ` +
|
||||
"— refusing to curate rather than pick one, because entries already written under " +
|
||||
"the other field are never re-read, and mixing them would leave one list ordered " +
|
||||
"by two different properties. Reading the existing entries still works. This " +
|
||||
"cannot be undone (nothing here deletes): curate into a fresh index.",
|
||||
);
|
||||
}
|
||||
|
||||
const field = declared[0];
|
||||
if (field === undefined) {
|
||||
throw new Error(
|
||||
`${index} declares no index field (${INDEX_FIELD}) — either it is not an index, ` +
|
||||
"or it could not be read: an unreadable document and an empty one are the same " +
|
||||
"empty result here. Nothing was written. Retry before concluding it is malformed.",
|
||||
);
|
||||
}
|
||||
return { field };
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries, ordered by value. Ties are broken on the object NURI so that two
|
||||
* readers of the same index always see the same order.
|
||||
*
|
||||
* Values are compared AS STRINGS — this layer does not know what the field means.
|
||||
* An index whose field holds ISO-8601 dates therefore comes out in chronological
|
||||
* order, which is the whole reason to index by a date.
|
||||
*
|
||||
* A subject that is not a NURI is SKIPPED, never thrown on. An index document is
|
||||
* an ordinary document and its owner may put anything in it; a single stray
|
||||
* triple must not be able to make every real entry unreadable at once. Reading is
|
||||
* per-entry tolerant for the same reason the write path has no delete — what a
|
||||
* reader could already see has to keep being visible.
|
||||
*/
|
||||
export function entriesOf(subjects: readonly UnionSubject[], index: Nuri): IndexEntry[] {
|
||||
const entries: IndexEntry[] = [];
|
||||
for (const subject of subjects) {
|
||||
if (subject.subject === index) continue;
|
||||
if (!isNuri(subject.subject)) continue;
|
||||
const value = entryValue(subject);
|
||||
if (value === undefined) continue;
|
||||
entries.push({ object: asNuri(subject.subject), value });
|
||||
}
|
||||
entries.sort((a, b) => (a.value === b.value ? cmp(a.object, b.object) : cmp(a.value, b.value)));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function cmp(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function readIndexDocument(
|
||||
port: NextGraphPort,
|
||||
index: NuriLike,
|
||||
): Promise<readonly UnionSubject[]> {
|
||||
return port.readDocument(asNuri(index));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `@ng-helpers/indexing` — an index built on top of NextGraph.
|
||||
*
|
||||
* NextGraph has no indexing concept and will not grow one, so nothing here
|
||||
* belongs in `@ng-eventually/polyfill`: that package's inbox stays generic and
|
||||
* carries opaque deposits, and knows nothing of what a deposit means.
|
||||
*
|
||||
* 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 — see `curator.ts`.
|
||||
*/
|
||||
|
||||
// Wiring
|
||||
export { indexing } from "./indexing";
|
||||
export type { Indexing } from "./indexing";
|
||||
export { polyfillPort } from "./polyfill-adapter";
|
||||
export type { PolyfillPortOptions } from "./polyfill-adapter";
|
||||
export type {
|
||||
NextGraphPort,
|
||||
IncomingDeposit,
|
||||
ObjectResolution,
|
||||
Nuri,
|
||||
NuriLike,
|
||||
UnionSubject,
|
||||
} from "./port";
|
||||
|
||||
// What travels from a depositor to a curator: a bare reference
|
||||
export { decodeReference } from "./deposit";
|
||||
export type { IndexDeposit } from "./deposit";
|
||||
|
||||
// What an index holds, and what curating it reports
|
||||
export type { IndexDescriptor, IndexEntry } from "./index-document";
|
||||
export type { CurationOutcome, CurationReport, SkipReason } from "./curator";
|
||||
|
||||
// The IRIs written into an index document, for a reader going straight to `readUnion`
|
||||
export { ENTRY_VALUE, INDEX_FIELD } from "./vocabulary";
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import type { NextGraphPort, Nuri, NuriLike } from "./port";
|
||||
import { asNuri } from "./nuri";
|
||||
import { curate, type CurationReport } from "./curator";
|
||||
import {
|
||||
assertIndexDocument,
|
||||
entriesOf,
|
||||
readIndexDocument,
|
||||
writeDescriptor,
|
||||
type IndexEntry,
|
||||
} from "./index-document";
|
||||
|
||||
/**
|
||||
* Everything this package does, bound to one identity.
|
||||
*
|
||||
* A port carries an identity — the polyfill's session is one user's and no call
|
||||
* takes an identifier — so an `Indexing` is one person's handle. Two people mean
|
||||
* two handles, which is also what keeps a multi-actor test honest.
|
||||
*/
|
||||
export interface Indexing {
|
||||
/**
|
||||
* Creates an index, in THIS identity's public store; the creator owns it. Any
|
||||
* user may create one.
|
||||
*
|
||||
* `field` is the predicate an indexed object must carry, declared once, here.
|
||||
* An index "by a date" is just an index whose field is a date predicate — there
|
||||
* is no separate kind of index, and the entries of such an index come out in
|
||||
* chronological order because ISO-8601 sorts as a string.
|
||||
*
|
||||
* The returned NURI is what an application hardcodes in its own source: that
|
||||
* reference is the only thing that makes this ordinary document an index, and
|
||||
* the only way anyone reaches it.
|
||||
*/
|
||||
createIndex(field: string): Promise<Nuri>;
|
||||
|
||||
/**
|
||||
* Hands an index a reference to an object. Open to ANYONE — it is a deposit in
|
||||
* the index document's inbox, not a write.
|
||||
*
|
||||
* The reference is the whole message: it claims nothing and instructs nothing,
|
||||
* it just invites the index's owner to look. Call it when the object is
|
||||
* created, and again whenever anyone notices the index may not have it yet —
|
||||
* including a third party. Nothing lands in the index until its owner curates.
|
||||
*/
|
||||
refer(index: NuriLike, object: NuriLike): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resolves the references this index has received and adds what it can. Only
|
||||
* the index's OWNER gets anything: nobody else reads its inbox, and nobody else
|
||||
* may write it.
|
||||
*
|
||||
* Check the returned outcomes for `unresolved` — those references were not
|
||||
* added.
|
||||
*/
|
||||
curate(index: NuriLike): Promise<CurationReport>;
|
||||
|
||||
/**
|
||||
* The index's entries, ordered by value.
|
||||
*
|
||||
* Sugar only. Reading an index needs NOTHING new from NextGraph: an application
|
||||
* that knows the NURI can call `readUnion([indexNuri])` and get these entries as
|
||||
* subjects — one per indexed object, keyed by its NURI — plus the index's own
|
||||
* subject declaring its field, which this function drops.
|
||||
*/
|
||||
read(index: NuriLike): Promise<IndexEntry[]>;
|
||||
}
|
||||
|
||||
export function indexing(port: NextGraphPort): Indexing {
|
||||
return {
|
||||
async createIndex(field: string): Promise<Nuri> {
|
||||
// Refused at the door, because a field cannot be corrected afterwards:
|
||||
// nothing here deletes, so an index created on a useless field is useless
|
||||
// for good. An empty string is the sharp case — it would match no object.
|
||||
if (field.length === 0 || field.trim().length === 0) {
|
||||
throw new Error(
|
||||
"createIndex: the field must be the predicate an indexed object carries, " +
|
||||
"and it cannot be changed later — this package never removes anything.",
|
||||
);
|
||||
}
|
||||
const index = await port.createPublicDocument();
|
||||
await writeDescriptor(port, index, { field });
|
||||
// An index nobody can deposit into is not an index. Only its owner can open
|
||||
// its inbox, and this is the one moment the owner is here — so it is opened
|
||||
// at creation rather than left for a later call to remember.
|
||||
await port.openInbox(index);
|
||||
return index;
|
||||
},
|
||||
|
||||
async refer(index: NuriLike, object: NuriLike): Promise<void> {
|
||||
// The payload IS the reference. Nothing wraps it, nothing annotates it.
|
||||
await port.depositTo(asNuri(index), asNuri(object));
|
||||
},
|
||||
|
||||
curate(index: NuriLike): Promise<CurationReport> {
|
||||
return curate(port, index);
|
||||
},
|
||||
|
||||
async read(index: NuriLike): Promise<IndexEntry[]> {
|
||||
const nuri = asNuri(index);
|
||||
const subjects = await readIndexDocument(port, nuri);
|
||||
// Only "is this an index?", deliberately NOT "which field does it curate
|
||||
// on?". Reading entries never needed the field, and making it need one is
|
||||
// what let an ambiguous declaration make every entry unreadable for good.
|
||||
assertIndexDocument(subjects, nuri);
|
||||
return entriesOf(subjects, nuri);
|
||||
},
|
||||
};
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { Nuri, NuriLike } from "./port";
|
||||
import { isIriSafe } from "./sparql";
|
||||
|
||||
const NURI_PREFIX = "did:ng:";
|
||||
|
||||
/**
|
||||
* `@ng-eventually/polyfill` publishes the `Nuri` type but NO type guard
|
||||
* (`contract_polyfill-surface`: "No type guard is published"), so this layer
|
||||
* carries its own rather than reaching into that package.
|
||||
*
|
||||
* A NURI reaches this layer from an inbox deposit, so it is untrusted input: it
|
||||
* is checked to be writable inside `<...>` before it is ever put in a query.
|
||||
*/
|
||||
export function isNuri(value: unknown): value is Nuri {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.startsWith(NURI_PREFIX) &&
|
||||
value.length > NURI_PREFIX.length &&
|
||||
isIriSafe(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function asNuri(value: NuriLike): Nuri {
|
||||
if (!isNuri(value)) {
|
||||
throw new Error(`not a NURI: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { docs, inbox, readUnion, storeRegistry } from "@ng-eventually/polyfill";
|
||||
import type {
|
||||
IncomingDeposit,
|
||||
NextGraphPort,
|
||||
NuriLike,
|
||||
ObjectResolution,
|
||||
UnionSubject,
|
||||
} from "./port";
|
||||
import { asNuri } from "./nuri";
|
||||
import { buildInsertTriple } from "./sparql";
|
||||
import { resolutionFromFailure, resolutionFromRead } from "./resolution";
|
||||
|
||||
/**
|
||||
* The ONLY place in this package that touches `@ng-eventually/polyfill` at
|
||||
* runtime. Everything else is written against `NextGraphPort`, so the indexing
|
||||
* rules never learn a polyfill call — and the polyfill never learns anything
|
||||
* about indexing.
|
||||
*
|
||||
* Only entries listed in `contract_polyfill-surface` are used here; nothing is
|
||||
* reached for inside that package. Because this file compiles against the real
|
||||
* module, a change to the published surface breaks THIS file and nothing else.
|
||||
*/
|
||||
export interface PolyfillPortOptions {
|
||||
/**
|
||||
* The polyfill's session id, as handed to the callback of its own `init(...)`.
|
||||
* The published write primitive (`docs.sparqlUpdate`) takes it; the polyfill
|
||||
* publishes no higher-level way to write triples into a document, so this is
|
||||
* the level to align on.
|
||||
*
|
||||
* Its type is upstream's own: it is relayed, never converted.
|
||||
*/
|
||||
readonly sessionId: string | number;
|
||||
}
|
||||
|
||||
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
|
||||
const { sessionId } = options;
|
||||
|
||||
return {
|
||||
async createPublicDocument() {
|
||||
// An index lives in its creator's public store, so that any reader can open
|
||||
// it from the reference alone.
|
||||
return storeRegistry.createEntityDoc("public");
|
||||
},
|
||||
|
||||
async resolveObject(doc: NuriLike): Promise<ObjectResolution> {
|
||||
// The decision itself lives in `resolution.ts`, where it is unit-tested:
|
||||
// here it would be reachable only through a live broker, and an untested
|
||||
// "empty means unresolved" quietly becomes "empty means the object has no
|
||||
// field" — a failure filed as a fact about the object.
|
||||
let subjects: readonly UnionSubject[];
|
||||
try {
|
||||
subjects = await readUnion([asNuri(doc)]);
|
||||
} catch (error) {
|
||||
return resolutionFromFailure(error);
|
||||
}
|
||||
return resolutionFromRead(subjects);
|
||||
},
|
||||
|
||||
async readDocument(doc: NuriLike): Promise<readonly UnionSubject[]> {
|
||||
return readUnion([asNuri(doc)]);
|
||||
},
|
||||
|
||||
async addLiteralProperty(
|
||||
doc: NuriLike,
|
||||
subject: string,
|
||||
predicate: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const graph = asNuri(doc);
|
||||
// An INSERT and nothing else. There is deliberately no delete anywhere in
|
||||
// this package, so no failure here can leave an index short of an entry.
|
||||
await docs.sparqlUpdate(sessionId, buildInsertTriple(graph, subject, predicate, value), graph);
|
||||
},
|
||||
|
||||
async openInbox(doc: NuriLike): Promise<void> {
|
||||
// Without this nobody can deposit: `postToDocument` resolves the document's
|
||||
// address and throws when its owner never opened one.
|
||||
await storeRegistry.openDocumentInbox(asNuri(doc));
|
||||
},
|
||||
|
||||
async depositTo(doc: NuriLike, payload: unknown): Promise<void> {
|
||||
// `postToDocument` is exactly "reach this document's owner", and is open to
|
||||
// anyone — which is what lets a stranger contribute to someone else's index.
|
||||
await inbox.postToDocument(asNuri(doc), { payload });
|
||||
},
|
||||
|
||||
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
|
||||
return inbox.readForDocument(asNuri(doc));
|
||||
},
|
||||
};
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* What this layer needs from NextGraph, and nothing more.
|
||||
*
|
||||
* Every member below is backed by published entries of `@ng-eventually/polyfill`
|
||||
* (see that package's `contract_polyfill-surface`). The mapping lives in
|
||||
* `polyfill-adapter.ts`; the rest of this package never imports the polyfill at
|
||||
* runtime, so the indexing rules stay testable against an in-memory port.
|
||||
*
|
||||
* The types are imported *from* the polyfill (type-only, erased at build) so they
|
||||
* are literally the published ones rather than a copy that could drift.
|
||||
*/
|
||||
import type { Nuri, NuriLike, PrincipalId, UnionSubject } from "@ng-eventually/polyfill";
|
||||
|
||||
export type { Nuri, NuriLike, PrincipalId, UnionSubject };
|
||||
|
||||
/** One deposit read out of a document's inbox. Mirrors the published `Deposit`. */
|
||||
export interface IncomingDeposit {
|
||||
/** The depositor, when identified; `null` for an anonymous deposit. */
|
||||
readonly from: PrincipalId | null;
|
||||
/** Opaque to the polyfill — this layer decodes it (see `deposit.ts`). */
|
||||
readonly payload: unknown;
|
||||
/** Deposit timestamp (ms epoch). */
|
||||
readonly ts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to "what is at this reference?" — TWO states, deliberately.
|
||||
*
|
||||
* There is no `absent`. An index never removes anything, so nothing this layer
|
||||
* does could ever hinge on telling "the object is gone" from "I could not read
|
||||
* it". Both mean the same thing here: *not added this time*. Collapsing them is
|
||||
* safe only because of that, and it is the point — the alternative would be a
|
||||
* label a broker failure could wear, and an index emptied one reference at a time
|
||||
* by a network problem.
|
||||
*
|
||||
* `unresolved` is still a failure, and the curator reports every one of them.
|
||||
*/
|
||||
export type ObjectResolution =
|
||||
/** The read answered, and there is something there. */
|
||||
| { readonly state: "present"; readonly subjects: readonly UnionSubject[] }
|
||||
/** Nothing usable came back — gone, unreadable, or the read failed. Not distinguished. */
|
||||
| { readonly state: "unresolved"; readonly reason: string };
|
||||
|
||||
/**
|
||||
* A port is bound to ONE identity: the polyfill's session is one user's, and no
|
||||
* call takes an identifier. Two users mean two ports.
|
||||
*/
|
||||
export interface NextGraphPort {
|
||||
/**
|
||||
* A new document in this identity's PUBLIC store, owned by this identity.
|
||||
* Backs onto `storeRegistry.createEntityDoc("public")`.
|
||||
*/
|
||||
createPublicDocument(): Promise<Nuri>;
|
||||
|
||||
/** Resolves a reference deposited into an index: the two-state answer above. */
|
||||
resolveObject(doc: NuriLike): Promise<ObjectResolution>;
|
||||
|
||||
/**
|
||||
* Every subject an index document holds. Backs onto `readUnion([doc])`.
|
||||
*
|
||||
* CAUTION — an empty result is NOT proof the document is empty, and this
|
||||
* interface does not pretend otherwise. `readUnion` swallows a failing document
|
||||
* into `[]` (its `readDoc` is `try {…} catch { return [] }`) and drops documents
|
||||
* whose cap this user does not hold, so "could not read" and "holds nothing"
|
||||
* arrive here indistinguishable. It may also reject outright.
|
||||
*
|
||||
* So a caller must never read `[]` as "a valid index that happens to be empty".
|
||||
* Nothing here does: `descriptorOf` refuses a document that declares no field —
|
||||
* which is exactly what an unreadable one looks like — and that refusal aborts
|
||||
* curation before a single write.
|
||||
*/
|
||||
readDocument(doc: NuriLike): Promise<readonly UnionSubject[]>;
|
||||
|
||||
/**
|
||||
* Adds ONE value for `predicate` on `subject`, inside `doc`. Only the
|
||||
* document's OWNER may — holding a read key never grants a write.
|
||||
*
|
||||
* There is no counterpart that removes, and no way to pass "no value": this
|
||||
* package must not be able to take anything out of an index, and making that
|
||||
* structural beats leaving it to whoever edits the curator next. Backs onto
|
||||
* `docs.sparqlUpdate` with an `INSERT DATA`.
|
||||
*/
|
||||
addLiteralProperty(
|
||||
doc: NuriLike,
|
||||
subject: string,
|
||||
predicate: string,
|
||||
value: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Opens an inbox on this document so that others have somewhere to deposit.
|
||||
* OWNER only, and REQUIRED: until it is called, `depositTo` throws because the
|
||||
* document has no address. Backs onto `storeRegistry.openDocumentInbox(doc)`.
|
||||
*
|
||||
* Returns nothing on purpose — an application names a document or a person,
|
||||
* never an inbox.
|
||||
*/
|
||||
openInbox(doc: NuriLike): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deposit into the document's inbox — i.e. reach its owner. Open to ANYONE;
|
||||
* that is what lets a stranger hand a reference to an index they do not own.
|
||||
* Backs onto `inbox.postToDocument(doc, { payload })`.
|
||||
*
|
||||
* Throws when the document's owner never opened an inbox: a deposit that
|
||||
* vanishes silently is worse than a refusal.
|
||||
*/
|
||||
depositTo(doc: NuriLike, payload: unknown): Promise<void>;
|
||||
|
||||
/**
|
||||
* The deposits made to this document's inbox, oldest first.
|
||||
*
|
||||
* THROWS for anyone but the document's owner — you may deposit into anyone's
|
||||
* inbox, you may only READ your own. Backs onto `inbox.readForDocument(doc)`.
|
||||
*/
|
||||
readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ObjectResolution, UnionSubject } from "./port";
|
||||
|
||||
/**
|
||||
* Why an empty read is `unresolved` and never `present`.
|
||||
*
|
||||
* `readUnion` cannot tell an absent object from one it failed to read, and does
|
||||
* not claim to: its per-document read is wrapped in `try {…} catch { return [] }`
|
||||
* (`readDoc`, in `@ng-eventually/polyfill`'s `src/surface/read-model.ts`), and
|
||||
* documents whose cap this user does not hold are dropped from the batch before
|
||||
* it reads at all. An empty result means absent OR unreadable OR failed, with
|
||||
* nothing to separate them.
|
||||
*
|
||||
* That is not a gap this package has to close, because nothing it does depends on
|
||||
* the answer: an index only ever grows, so every reading leads to the same act —
|
||||
* do not add, and say so.
|
||||
*
|
||||
* The rule lives here, apart from the I/O, for one reason: in the adapter it was
|
||||
* unreachable by any test, and dropping it there left the suite green while a
|
||||
* FAILED read got reported as `skipped: "no-field"` — a failure filed as a
|
||||
* property of the object. That is exactly the misclassification this layer exists
|
||||
* to avoid, so the rule is now a pure function with its own tests.
|
||||
*/
|
||||
export const EMPTY_READ =
|
||||
"the read came back empty — the object is absent, unreadable, or the read failed; " +
|
||||
"the polyfill does not distinguish them, and this layer does not need it to";
|
||||
|
||||
/** Turns what a read returned into the two-state answer the curator acts on. */
|
||||
export function resolutionFromRead(subjects: readonly UnionSubject[]): ObjectResolution {
|
||||
if (subjects.length === 0) return { state: "unresolved", reason: EMPTY_READ };
|
||||
return { state: "present", subjects };
|
||||
}
|
||||
|
||||
/** Turns a read that threw into the same two-state answer. Never `present`. */
|
||||
export function resolutionFromFailure(error: unknown): ObjectResolution {
|
||||
return { state: "unresolved", reason: String(error) };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The SPARQL this layer writes — and it writes only one kind of statement.
|
||||
*
|
||||
* There is NO delete builder, and there must not be one: an index never removes
|
||||
* an entry, and the surest way to keep that true is for this package to be unable
|
||||
* to express a removal at all.
|
||||
*
|
||||
* `@ng-eventually/polyfill` has escaping helpers of its own but does NOT publish
|
||||
* them — `contract_polyfill-surface` lists no `escapeIri` / `escapeLiteral` — so
|
||||
* this package carries its own rather than reaching into that package's internals.
|
||||
*
|
||||
* Pure string builders, on purpose: their exact output is asserted in the unit
|
||||
* tests, and they are executed against a broker only through `polyfillPort`.
|
||||
*/
|
||||
|
||||
/** Characters that would close an IRI written between angle brackets. */
|
||||
const IRI_DELIMITERS = new Set(['"', "<", ">", "\\", "^", "`", "{", "|", "}"]);
|
||||
|
||||
/** True when the character may not appear inside `<...>`: a control, a space, or a delimiter. */
|
||||
function breaksIri(character: string): boolean {
|
||||
const code = character.codePointAt(0);
|
||||
if (code === undefined) return false;
|
||||
return code <= 0x20 || code === 0x7f || IRI_DELIMITERS.has(character);
|
||||
}
|
||||
|
||||
/** True when the whole string can be written between angle brackets as-is. */
|
||||
export function isIriSafe(value: string): boolean {
|
||||
for (const character of value) {
|
||||
if (breaksIri(character)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Neutralises anything that could close a SPARQL string literal. */
|
||||
export function escapeLiteral(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\t/g, "\\t");
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encodes anything that could close an IRI written between angle
|
||||
* brackets. Printable non-ASCII is left alone — it is legal in an IRI, and
|
||||
* encoding it would corrupt it.
|
||||
*/
|
||||
export function escapeIri(value: string): string {
|
||||
let out = "";
|
||||
for (const character of value) {
|
||||
if (!breaksIri(character)) {
|
||||
out += character;
|
||||
continue;
|
||||
}
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
out += `%${code.toString(16).toUpperCase().padStart(2, "0")}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One triple, added to `graph`. The only statement this package ever writes. */
|
||||
export function buildInsertTriple(
|
||||
graph: string,
|
||||
subject: string,
|
||||
predicate: string,
|
||||
value: string,
|
||||
): string {
|
||||
return (
|
||||
`INSERT DATA { GRAPH <${escapeIri(graph)}> ` +
|
||||
`{ <${escapeIri(subject)}> <${escapeIri(predicate)}> "${escapeLiteral(value)}" } }`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The IRIs this layer writes into an index document.
|
||||
*
|
||||
* `urn:ng-eventually:` is RESERVED by the polyfill — triples whose subject falls
|
||||
* under it are dropped on read. This layer stays out of it entirely and uses its
|
||||
* own `urn:ng-helpers:` namespace, for PREDICATES only. Subjects in an index
|
||||
* document are always `did:ng:` NURIs, so nothing here is ever filtered away.
|
||||
*/
|
||||
|
||||
/**
|
||||
* On the index document's own subject: the predicate an indexed object must
|
||||
* carry, declared once by whoever creates the index.
|
||||
*/
|
||||
export const INDEX_FIELD = "urn:ng-helpers:index:field";
|
||||
|
||||
/** On an entry (subject = the indexed object's NURI): the value of that field. */
|
||||
export const ENTRY_VALUE = "urn:ng-helpers:index:value";
|
||||
@@ -0,0 +1,122 @@
|
||||
import { expect, mock, test } from "bun:test";
|
||||
|
||||
/**
|
||||
* What the REAL adapter actually sends to the broker.
|
||||
*
|
||||
* Until now nothing executed `polyfill-adapter.ts`: every behavioural test ran
|
||||
* against the in-memory port, and the only thing standing between this package
|
||||
* and a destructive statement was a regex over its own source. An adversarial
|
||||
* review walked through that regex four separate ways — `COPY DEFAULT TO GRAPH`,
|
||||
* a keyword hidden behind the comment-stripper, `DELETE{` with no space, and a
|
||||
* literal split across concatenated lines — each time with the full suite green.
|
||||
*
|
||||
* A pattern over source can always be out-written. So this file stops describing
|
||||
* the code and starts EXERCISING it: the polyfill is replaced by a recorder, the
|
||||
* adapter is driven through its write path, and every query it emits is read back.
|
||||
* Whatever the source looks like, what leaves the adapter is checked.
|
||||
*/
|
||||
|
||||
const emitted: string[] = [];
|
||||
|
||||
mock.module("@ng-eventually/polyfill", () => ({
|
||||
docs: {
|
||||
async sparqlUpdate(_sessionId: string | number, query: string) {
|
||||
emitted.push(query);
|
||||
return [];
|
||||
},
|
||||
async sparqlQuery() {
|
||||
return { results: { bindings: [] } };
|
||||
},
|
||||
async docCreate() {
|
||||
return "did:ng:o:created";
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
async postToDocument() {},
|
||||
async readForDocument() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
async readUnion() {
|
||||
return [];
|
||||
},
|
||||
storeRegistry: {
|
||||
async createEntityDoc() {
|
||||
return "did:ng:o:index";
|
||||
},
|
||||
async openDocumentInbox() {
|
||||
return "did:ng:o:inbox";
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
/** Every SPARQL 1.1 form that can destroy or displace data. */
|
||||
const DESTRUCTIVE = /\b(DELETE|DROP|CLEAR|MOVE|COPY|ADD|LOAD|MODIFY|WITH|SILENT)\b/i;
|
||||
|
||||
async function capture(run: (port: Awaited<ReturnType<typeof makePort>>) => Promise<void>) {
|
||||
emitted.length = 0;
|
||||
await run(await makePort());
|
||||
return [...emitted];
|
||||
}
|
||||
|
||||
async function makePort() {
|
||||
const { polyfillPort } = await import("../src/polyfill-adapter");
|
||||
return polyfillPort({ sessionId: "session-under-test" });
|
||||
}
|
||||
|
||||
test("the adapter's only write emits one INSERT DATA and nothing else", async () => {
|
||||
const queries = await capture(async (port) => {
|
||||
await port.addLiteralProperty(
|
||||
"did:ng:o:index",
|
||||
"did:ng:o:object",
|
||||
"urn:ng-helpers:index:value",
|
||||
"2026-01-02",
|
||||
);
|
||||
});
|
||||
|
||||
expect(queries).toEqual([
|
||||
"INSERT DATA { GRAPH <did:ng:o:index> " +
|
||||
'{ <did:ng:o:object> <urn:ng-helpers:index:value> "2026-01-02" } }',
|
||||
]);
|
||||
for (const query of queries) {
|
||||
expect(query).not.toMatch(DESTRUCTIVE);
|
||||
}
|
||||
});
|
||||
|
||||
test("creating an index emits only INSERTs, whatever else it does", async () => {
|
||||
const { indexing } = await import("../src/indexing");
|
||||
const queries = await capture(async (port) => {
|
||||
await indexing(port).createIndex("http://schema.org/datePublished");
|
||||
});
|
||||
|
||||
expect(queries.length).toBeGreaterThan(0); // not vacuously true
|
||||
for (const query of queries) {
|
||||
expect(query.startsWith("INSERT DATA")).toBe(true);
|
||||
expect(query).not.toMatch(DESTRUCTIVE);
|
||||
}
|
||||
});
|
||||
|
||||
test("a hostile value cannot smuggle a second statement past the broker", async () => {
|
||||
const queries = await capture(async (port) => {
|
||||
await port.addLiteralProperty(
|
||||
"did:ng:o:index",
|
||||
"did:ng:o:object",
|
||||
"urn:ng-helpers:index:value",
|
||||
'" } } ; DROP GRAPH <did:ng:o:index> ; INSERT DATA { GRAPH <urn:evil> { <a> <b> "c',
|
||||
);
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(1);
|
||||
const query = queries[0] ?? "";
|
||||
// The payload survives as inert text inside the literal — what matters is that
|
||||
// it never becomes a statement. Exactly two quotes are real delimiters.
|
||||
let unescaped = 0;
|
||||
for (let i = 0; i < query.length; i += 1) {
|
||||
if (query[i] !== '"') continue;
|
||||
let backslashes = 0;
|
||||
for (let j = i - 1; j >= 0 && query[j] === "\\"; j -= 1) backslashes += 1;
|
||||
if (backslashes % 2 === 0) unescaped += 1;
|
||||
}
|
||||
expect(unescaped).toBe(2);
|
||||
expect(query.startsWith("INSERT DATA { GRAPH <did:ng:o:index> ")).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import type {
|
||||
IncomingDeposit,
|
||||
NextGraphPort,
|
||||
Nuri,
|
||||
NuriLike,
|
||||
ObjectResolution,
|
||||
UnionSubject,
|
||||
} from "../src/port";
|
||||
import { asNuri } from "../src/nuri";
|
||||
|
||||
/**
|
||||
* An in-memory NextGraph, standing in for `@ng-eventually/polyfill` behind
|
||||
* `NextGraphPort`.
|
||||
*
|
||||
* Every rule below is one the polyfill actually enforces, annotated with where it
|
||||
* comes from — the published contract, or the code implementing it. The point is
|
||||
* that no test here can reach a state the real system does not produce:
|
||||
*
|
||||
* - only a document's owner writes to it, and a read key never grants a write
|
||||
* (`contract_polyfill-surface`, "Guarantees");
|
||||
* - anyone may DEPOSIT into a document's inbox, but reading one THROWS for anyone
|
||||
* but its owner (`inbox.read`'s `assertOwnInbox`: "you may DEPOSIT into anyone's
|
||||
* inbox; you may only READ your own");
|
||||
* - depositing into a document whose owner never opened an inbox THROWS
|
||||
* (`inbox.postToDocument`), rather than silently going nowhere;
|
||||
* - opening an inbox on a document is refused to anyone but its owner
|
||||
* (`openDocumentInbox`: doing so publishes the document's address);
|
||||
* - a document in a public store is readable by whoever knows its NURI;
|
||||
* - a document that cannot be read REJECTS, and a rejection means "unknown",
|
||||
* never "absent".
|
||||
*/
|
||||
|
||||
type Properties = Map<string, string[]>;
|
||||
|
||||
interface StoredDocument {
|
||||
readonly nuri: Nuri;
|
||||
readonly owner: string;
|
||||
readonly subjects: Map<string, Properties>;
|
||||
/** `undefined` until the owner opens one — the state `postToDocument` refuses. */
|
||||
deposits: IncomingDeposit[] | undefined;
|
||||
}
|
||||
|
||||
export class FakeNextGraph {
|
||||
readonly #documents = new Map<string, StoredDocument>();
|
||||
/** Documents the broker currently cannot answer about. See `breakReadsOf`. */
|
||||
readonly #unreachable = new Map<string, string>();
|
||||
#documentCount = 0;
|
||||
#clock = 0;
|
||||
|
||||
/** A handle bound to one identity, exactly as a polyfill session is one user's. */
|
||||
portFor(user: string): NextGraphPort {
|
||||
const network = this;
|
||||
return {
|
||||
async createPublicDocument(): Promise<Nuri> {
|
||||
return network.#createDocument(user);
|
||||
},
|
||||
|
||||
async resolveObject(doc: NuriLike): Promise<ObjectResolution> {
|
||||
return network.#resolve(asNuri(doc));
|
||||
},
|
||||
|
||||
async readDocument(doc: NuriLike): Promise<readonly UnionSubject[]> {
|
||||
return network.#read(asNuri(doc));
|
||||
},
|
||||
|
||||
async addLiteralProperty(
|
||||
doc: NuriLike,
|
||||
subject: string,
|
||||
predicate: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
network.#add(user, asNuri(doc), subject, predicate, value);
|
||||
},
|
||||
|
||||
async openInbox(doc: NuriLike): Promise<void> {
|
||||
network.#openInbox(user, asNuri(doc));
|
||||
},
|
||||
|
||||
async depositTo(doc: NuriLike, payload: unknown): Promise<void> {
|
||||
network.#deposit(user, asNuri(doc), payload);
|
||||
},
|
||||
|
||||
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
|
||||
return network.#readDeposits(user, asNuri(doc));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the broker unable to answer about a document — a transient failure, the
|
||||
* state a curation run must survive without damaging the index. Reads of it
|
||||
* REJECT, which is what the real surface does when it could not find out.
|
||||
*/
|
||||
breakReadsOf(doc: NuriLike, reason: string): void {
|
||||
this.#unreachable.set(asNuri(doc), reason);
|
||||
}
|
||||
|
||||
/** The broker can answer about this document again. */
|
||||
healReadsOf(doc: NuriLike): void {
|
||||
this.#unreachable.delete(asNuri(doc));
|
||||
}
|
||||
|
||||
/** A NURI shaped like any other, that no document was ever created for. */
|
||||
neverCreatedNuri(): Nuri {
|
||||
return "did:ng:o:doc-never-created" as Nuri;
|
||||
}
|
||||
|
||||
/**
|
||||
* The document's owner REPLACING a value in their own document, through their
|
||||
* own application — NOT through this package's port, which deliberately cannot
|
||||
* delete anything.
|
||||
*
|
||||
* This is a real capability and it has to be modelled: an indexed object is an
|
||||
* ordinary document whose owner keeps editing it, and an index tested only
|
||||
* against frozen objects would be tested against a world that does not exist.
|
||||
*/
|
||||
ownerReplacesValue(doc: NuriLike, subject: string, predicate: string, value: string): void {
|
||||
const stored = this.#require(asNuri(doc));
|
||||
const properties = stored.subjects.get(subject);
|
||||
if (properties === undefined) throw new Error(`${String(doc)} has no subject ${subject}`);
|
||||
properties.set(predicate, [value]);
|
||||
}
|
||||
|
||||
#createDocument(owner: string): Nuri {
|
||||
this.#documentCount += 1;
|
||||
const nuri = `did:ng:o:doc-${this.#documentCount}` as Nuri;
|
||||
this.#documents.set(nuri, { nuri, owner, subjects: new Map(), deposits: undefined });
|
||||
return nuri;
|
||||
}
|
||||
|
||||
#require(doc: Nuri): StoredDocument {
|
||||
const broken = this.#unreachable.get(doc);
|
||||
// "A rejection means 'unknown', never 'absent'."
|
||||
if (broken !== undefined) throw new Error(`cannot reach ${doc}: ${broken}`);
|
||||
const stored = this.#documents.get(doc);
|
||||
if (stored === undefined) throw new Error(`cannot open ${doc}`);
|
||||
return stored;
|
||||
}
|
||||
|
||||
#read(doc: Nuri): UnionSubject[] {
|
||||
const stored = this.#require(doc);
|
||||
const out: UnionSubject[] = [];
|
||||
for (const [subject, properties] of stored.subjects) {
|
||||
// Built EXACTLY as `readUnion` builds it — a plain object literal filled by
|
||||
// `(props[p] ??= []).push(o)` (`read-model.ts`). Neither detail is cosmetic:
|
||||
// the literal inherits from `Object.prototype`, and `??=` does NOT assign
|
||||
// over an inherited truthy member, so a predicate named `constructor` or
|
||||
// `toString` leaves `.push` undefined and the read THROWS. Assigning plainly
|
||||
// here made this double more forgiving than the real thing, and a test
|
||||
// written against it asserted an outcome production can never produce.
|
||||
const props: Record<string, string[]> = {};
|
||||
for (const [predicate, values] of properties) {
|
||||
for (const value of values) {
|
||||
(props[predicate] ??= []).push(value);
|
||||
}
|
||||
}
|
||||
out.push({ subject, graph: doc, props });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two states, like the port: something is there, or nothing usable came back.
|
||||
* A document that cannot be reached and a document that holds nothing both come
|
||||
* back `unresolved` — this layer never has to tell them apart, and the reason
|
||||
* string is the only difference.
|
||||
*/
|
||||
#resolve(doc: Nuri): ObjectResolution {
|
||||
let subjects: UnionSubject[];
|
||||
try {
|
||||
subjects = this.#read(doc);
|
||||
} catch (error) {
|
||||
return { state: "unresolved", reason: String(error) };
|
||||
}
|
||||
if (subjects.length === 0) return { state: "unresolved", reason: `${doc} holds nothing` };
|
||||
return { state: "present", subjects };
|
||||
}
|
||||
|
||||
#add(user: string, doc: Nuri, subject: string, predicate: string, value: string): void {
|
||||
const stored = this.#require(doc);
|
||||
if (stored.owner !== user) {
|
||||
throw new Error(
|
||||
`${user} may not write ${doc}: only a document's owner writes to it, and ` +
|
||||
"holding its read key never grants a write",
|
||||
);
|
||||
}
|
||||
let properties = stored.subjects.get(subject);
|
||||
if (properties === undefined) {
|
||||
properties = new Map();
|
||||
stored.subjects.set(subject, properties);
|
||||
}
|
||||
const values = properties.get(predicate);
|
||||
if (values === undefined) {
|
||||
properties.set(predicate, [value]);
|
||||
return;
|
||||
}
|
||||
// An INSERT of a triple already present is a no-op in RDF: a graph is a set.
|
||||
if (!values.includes(value)) values.push(value);
|
||||
}
|
||||
|
||||
#openInbox(user: string, doc: Nuri): void {
|
||||
const stored = this.#require(doc);
|
||||
if (stored.owner !== user) {
|
||||
throw new Error(
|
||||
`${user} may not open an inbox on ${doc}: opening one publishes the ` +
|
||||
"document's address, so a non-owner would route the owner's deposits to itself",
|
||||
);
|
||||
}
|
||||
stored.deposits ??= [];
|
||||
}
|
||||
|
||||
#deposit(user: string, doc: Nuri, payload: unknown): void {
|
||||
const stored = this.#require(doc);
|
||||
if (stored.deposits === undefined) {
|
||||
throw new Error(`${doc} has no inbox — its owner never opened one`);
|
||||
}
|
||||
this.#clock += 1;
|
||||
stored.deposits.push({ from: user, payload, ts: this.#clock });
|
||||
}
|
||||
|
||||
#readDeposits(user: string, doc: Nuri): readonly IncomingDeposit[] {
|
||||
const stored = this.#require(doc);
|
||||
// No inbox → there is no address to read, which is a state and not an error.
|
||||
if (stored.deposits === undefined) return [];
|
||||
if (stored.owner !== user) {
|
||||
throw new Error(
|
||||
`${user} may not read the inbox of ${doc}: you may DEPOSIT into anyone's ` +
|
||||
"inbox, you may only READ your own",
|
||||
);
|
||||
}
|
||||
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A public document holding one business entity with a value for `field` — what
|
||||
* an application creates and then refers to an index.
|
||||
*
|
||||
* Built through the very same port an application has: nothing here reaches
|
||||
* behind the surface to plant a state a real caller could not produce.
|
||||
*/
|
||||
export async function publishObject(
|
||||
port: NextGraphPort,
|
||||
field: string,
|
||||
value: string,
|
||||
): Promise<Nuri> {
|
||||
const object = await port.createPublicDocument();
|
||||
await port.addLiteralProperty(object, object, field, value);
|
||||
return object;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { indexing, type Indexing } from "../src/indexing";
|
||||
import type { Nuri } from "../src/port";
|
||||
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
|
||||
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
|
||||
|
||||
/**
|
||||
* Each actor gets their own handle, and they share no variable carrying business
|
||||
* data. The ONE value that crosses between them is the index's NURI — and that
|
||||
* crossing is the mechanism this design names: an application references the
|
||||
* index's NURI in its own source. `hardcodedInAppSource` marks every such
|
||||
* crossing, so anything else moving between actors would stand out.
|
||||
*/
|
||||
const PUBLISHED_AT = "http://schema.org/datePublished";
|
||||
const NAME = "http://schema.org/name";
|
||||
|
||||
function hardcodedInAppSource(nuri: Nuri): Nuri {
|
||||
return nuri;
|
||||
}
|
||||
|
||||
type Port = ReturnType<FakeNextGraph["portFor"]>;
|
||||
|
||||
function world(): {
|
||||
network: FakeNextGraph;
|
||||
alice: Indexing;
|
||||
bob: Indexing;
|
||||
carol: Indexing;
|
||||
ports: { alice: Port; bob: Port; carol: Port };
|
||||
} {
|
||||
const network = new FakeNextGraph();
|
||||
const ports = {
|
||||
alice: network.portFor("alice"),
|
||||
bob: network.portFor("bob"),
|
||||
carol: network.portFor("carol"),
|
||||
};
|
||||
return {
|
||||
network,
|
||||
alice: indexing(ports.alice),
|
||||
bob: indexing(ports.bob),
|
||||
carol: indexing(ports.carol),
|
||||
ports,
|
||||
};
|
||||
}
|
||||
|
||||
// --- creating an index ----------------------------------------------------
|
||||
|
||||
test("any user creates an index in their public store, and it declares its field", async () => {
|
||||
const { alice, ports } = world();
|
||||
|
||||
const index = await alice.createIndex(PUBLISHED_AT);
|
||||
|
||||
// An ordinary document: what makes it an index is the field it declares, which
|
||||
// a reader going straight to `readUnion` sees on the index's own subject.
|
||||
const subjects = await ports.alice.readDocument(index);
|
||||
const self = subjects.find((s) => s.subject === index);
|
||||
expect(self?.props[INDEX_FIELD]).toEqual([PUBLISHED_AT]);
|
||||
|
||||
expect(await alice.read(index)).toEqual([]);
|
||||
});
|
||||
|
||||
test("reading a document that declares no index field is refused, not answered empty", async () => {
|
||||
const { alice, ports } = world();
|
||||
const ordinary = await ports.alice.createPublicDocument();
|
||||
await expect(alice.read(ordinary)).rejects.toThrow(/declares no index field/);
|
||||
});
|
||||
|
||||
// --- the whole loop, across three people ----------------------------------
|
||||
|
||||
test("a stranger refers an object, the owner curates, and anyone reads the result", async () => {
|
||||
const { alice, bob, carol, ports } = world();
|
||||
|
||||
// Alice creates the index and its NURI goes into the application's source.
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
|
||||
// Bob, who owns nothing of Alice's, creates his own public object and hands the
|
||||
// index a reference to it. He needs no permission and gets no write.
|
||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||
await bob.refer(indexNuri, article);
|
||||
|
||||
// Nothing is in the index until its owner acts.
|
||||
expect(await carol.read(indexNuri)).toEqual([]);
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-03-04" }]);
|
||||
|
||||
// Carol knows only the NURI from the application's source, and gets the entry.
|
||||
const entries = await carol.read(indexNuri);
|
||||
expect(entries).toEqual([{ object: article, value: "2026-03-04" }]);
|
||||
|
||||
// The entry is a usable reference: Carol opens the object straight from it,
|
||||
// holding nothing but what she read out of the index.
|
||||
const first = entries[0];
|
||||
expect(first).toBeDefined();
|
||||
const opened = await ports.carol.readDocument(first!.object);
|
||||
expect(opened[0]?.props[PUBLISHED_AT]).toEqual(["2026-03-04"]);
|
||||
});
|
||||
|
||||
test("an entry is a subject keyed by the object's NURI, so reading needs nothing new", async () => {
|
||||
const { alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||
await bob.refer(indexNuri, article);
|
||||
await alice.curate(indexNuri);
|
||||
|
||||
// What `readUnion([indexNuri])` hands an application that never loaded this
|
||||
// package: the index's own subject, plus one subject per indexed object.
|
||||
const subjects = await ports.bob.readDocument(indexNuri);
|
||||
const entry = subjects.find((s) => s.subject === article);
|
||||
expect(entry?.props[ENTRY_VALUE]).toEqual(["2026-03-04"]);
|
||||
expect(subjects.map((s) => s.subject).sort()).toEqual([article, indexNuri].sort());
|
||||
});
|
||||
|
||||
// --- only the owner curates ----------------------------------------------
|
||||
|
||||
test("nobody but the index's owner can curate it: the inbox is refused to others", async () => {
|
||||
const { alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||
await bob.refer(indexNuri, article);
|
||||
|
||||
await expect(bob.curate(indexNuri)).rejects.toThrow(/may only READ your own/);
|
||||
expect(await alice.read(indexNuri)).toEqual([]);
|
||||
});
|
||||
|
||||
test("nobody but the owner writes an index, whatever they know about it", async () => {
|
||||
const { alice, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
|
||||
await expect(
|
||||
ports.bob.addLiteralProperty(indexNuri, "did:ng:o:forged", ENTRY_VALUE, "2999-01-01"),
|
||||
).rejects.toThrow(/only a document's owner writes to it/);
|
||||
await expect(ports.bob.openInbox(indexNuri)).rejects.toThrow(/may not open an inbox/);
|
||||
});
|
||||
|
||||
test("an index whose owner never opened an inbox refuses a deposit rather than losing it", async () => {
|
||||
const { bob, ports } = world();
|
||||
// A public document that was never made into an index: no inbox was opened.
|
||||
const notAnIndex = hardcodedInAppSource(await ports.alice.createPublicDocument());
|
||||
await expect(bob.refer(notAnIndex, "did:ng:o:doc-9")).rejects.toThrow(/has no inbox/);
|
||||
});
|
||||
|
||||
// --- adding is idempotent -------------------------------------------------
|
||||
|
||||
test("the same reference deposited twice produces one entry", async () => {
|
||||
const { alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||
|
||||
await bob.refer(indexNuri, article);
|
||||
await bob.refer(indexNuri, article);
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
expect(report.outcomes).toEqual([
|
||||
{ result: "indexed", object: article, value: "2026-03-04" },
|
||||
{ result: "unchanged", object: article },
|
||||
]);
|
||||
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-03-04" }]);
|
||||
});
|
||||
|
||||
test("curating twice changes nothing the second time — deposits are not consumed", async () => {
|
||||
const { alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||
await bob.refer(indexNuri, article);
|
||||
|
||||
await alice.curate(indexNuri);
|
||||
const before = await alice.read(indexNuri);
|
||||
|
||||
const second = await alice.curate(indexNuri);
|
||||
expect(second.outcomes).toEqual([{ result: "unchanged", object: article }]);
|
||||
expect(await alice.read(indexNuri)).toEqual(before);
|
||||
});
|
||||
|
||||
// --- a read that cannot answer must never cost the index anything ---------
|
||||
|
||||
test("a reference the broker cannot resolve is reported, and adds nothing", async () => {
|
||||
const { network, alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
|
||||
const first = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
||||
await bob.refer(indexNuri, first);
|
||||
await alice.curate(indexNuri);
|
||||
|
||||
const second = await publishObject(ports.bob, PUBLISHED_AT, "2026-02-02");
|
||||
await bob.refer(indexNuri, second);
|
||||
network.breakReadsOf(second, "broker unreachable");
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
const unresolved = report.outcomes.filter((o) => o.result === "unresolved");
|
||||
expect(unresolved).toHaveLength(1);
|
||||
expect(unresolved[0]).toMatchObject({ object: second });
|
||||
|
||||
// THE POINT: the entry that was already there is untouched.
|
||||
expect(await alice.read(indexNuri)).toEqual([{ object: first, value: "2026-01-01" }]);
|
||||
});
|
||||
|
||||
test("an already-indexed object survives its own reads failing, and is not even re-read", async () => {
|
||||
const { network, alice, bob, carol, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
||||
await bob.refer(indexNuri, article);
|
||||
await alice.curate(indexNuri);
|
||||
|
||||
// A passer-by nudges the index about an entry she found IN IT. Carol obtains
|
||||
// the reference the only way she could in a real application — by reading the
|
||||
// index whose NURI her app hardcodes — rather than being handed it by the test.
|
||||
const seen = await carol.read(indexNuri);
|
||||
const noticed = seen[0];
|
||||
expect(noticed).toBeDefined();
|
||||
|
||||
// …and only then does the object become unreachable.
|
||||
network.breakReadsOf(article, "broker unreachable");
|
||||
await carol.refer(indexNuri, noticed!.object);
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
expect(report.outcomes.every((o) => o.result === "unchanged")).toBe(true);
|
||||
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-01-01" }]);
|
||||
});
|
||||
|
||||
test("a failed resolve is self-correcting: the next curation adds what it could not", async () => {
|
||||
const { network, alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-05-06");
|
||||
await bob.refer(indexNuri, article);
|
||||
|
||||
network.breakReadsOf(article, "broker unreachable");
|
||||
expect((await alice.curate(indexNuri)).outcomes[0]?.result).toBe("unresolved");
|
||||
expect(await alice.read(indexNuri)).toEqual([]);
|
||||
|
||||
// The deposit is still there, so nothing has to be re-deposited.
|
||||
network.healReadsOf(article);
|
||||
expect((await alice.curate(indexNuri)).outcomes[0]).toEqual({
|
||||
result: "indexed",
|
||||
object: article,
|
||||
value: "2026-05-06",
|
||||
});
|
||||
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-05-06" }]);
|
||||
});
|
||||
|
||||
test("a reference to something that was never created is reported, not silently dropped", async () => {
|
||||
const { network, alice, bob } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
await bob.refer(indexNuri, network.neverCreatedNuri());
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
expect(report.outcomes).toHaveLength(1);
|
||||
expect(report.outcomes[0]?.result).toBe("unresolved");
|
||||
expect(await alice.read(indexNuri)).toEqual([]);
|
||||
});
|
||||
|
||||
// --- an object that does not fit the index --------------------------------
|
||||
|
||||
test("an object carrying nothing for the index's field is not added", async () => {
|
||||
const { alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
// Exists, is public, is readable — but says nothing about the field this index
|
||||
// is built on. OPEN QUESTION: this is the narrow behaviour, not a settled policy.
|
||||
const object = await publishObject(ports.bob, NAME, "an object with no date");
|
||||
await bob.refer(indexNuri, object);
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "no-field" }]);
|
||||
expect(await alice.read(indexNuri)).toEqual([]);
|
||||
});
|
||||
|
||||
test("an object carrying several values for the field is not added", async () => {
|
||||
const { alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
||||
await ports.bob.addLiteralProperty(object, object, PUBLISHED_AT, "2026-09-09");
|
||||
await bob.refer(indexNuri, object);
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "several-values" }]);
|
||||
expect(await alice.read(indexNuri)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a payload that is not a reference is reported as foreign and changes nothing", async () => {
|
||||
const { alice, bob, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||
await bob.refer(indexNuri, article);
|
||||
// Anyone may deposit anything into an inbox, so untrusted payloads do arrive.
|
||||
await ports.bob.depositTo(indexNuri, { drop: "everything" });
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
expect(report.outcomes).toEqual([
|
||||
{ result: "indexed", object: article, value: "2026-03-04" },
|
||||
{ result: "foreign", reason: "payload is not a reference" },
|
||||
]);
|
||||
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-03-04" }]);
|
||||
});
|
||||
|
||||
test("an index referred to itself is skipped, so its declaration cannot become an entry", async () => {
|
||||
const { alice, bob } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
await bob.refer(indexNuri, indexNuri);
|
||||
|
||||
const report = await alice.curate(indexNuri);
|
||||
expect(report.outcomes).toEqual([
|
||||
{ result: "skipped", object: indexNuri, reason: "self-reference" },
|
||||
]);
|
||||
expect(await alice.read(indexNuri)).toEqual([]);
|
||||
});
|
||||
|
||||
// --- indexing by a date is an instance of indexing by a field -------------
|
||||
|
||||
test("an index whose field is a date reads back in chronological order", async () => {
|
||||
const { alice, bob, carol, ports } = world();
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
|
||||
const march = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||
const january = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-31");
|
||||
const december = await publishObject(ports.bob, PUBLISHED_AT, "2025-12-25");
|
||||
|
||||
// Referred out of order, on purpose.
|
||||
await bob.refer(indexNuri, march);
|
||||
await bob.refer(indexNuri, december);
|
||||
await bob.refer(indexNuri, january);
|
||||
await alice.curate(indexNuri);
|
||||
|
||||
expect((await carol.read(indexNuri)).map((e) => e.value)).toEqual([
|
||||
"2025-12-25",
|
||||
"2026-01-31",
|
||||
"2026-03-04",
|
||||
]);
|
||||
});
|
||||
|
||||
test("two indexes over the same objects, on different fields, do not interfere", async () => {
|
||||
const { alice, bob, ports } = world();
|
||||
const byDate = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const byName = hardcodedInAppSource(await alice.createIndex(NAME));
|
||||
|
||||
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||
await ports.bob.addLiteralProperty(object, object, NAME, "Anemone");
|
||||
|
||||
await bob.refer(byDate, object);
|
||||
await bob.refer(byName, object);
|
||||
await alice.curate(byDate);
|
||||
await alice.curate(byName);
|
||||
|
||||
expect(await alice.read(byDate)).toEqual([{ object, value: "2026-03-04" }]);
|
||||
expect(await alice.read(byName)).toEqual([{ object, value: "Anemone" }]);
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { expect, mock, test } from "bun:test";
|
||||
import { indexing } from "../src/indexing";
|
||||
import { entriesOf, entryValue } from "../src/index-document";
|
||||
import { resolutionFromFailure, resolutionFromRead } from "../src/resolution";
|
||||
import type { Nuri, UnionSubject } from "../src/port";
|
||||
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
|
||||
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
|
||||
|
||||
/**
|
||||
* The invariant this package is built around — an index only ever grows — and the
|
||||
* hole that was in it.
|
||||
*
|
||||
* `entriesOf` used to require EXACTLY ONE value per entry, so a subject carrying
|
||||
* two read as absent. An index could therefore SHRINK through nothing but
|
||||
* additions: no delete involved, the guarantee defeated by the one operation
|
||||
* meant to uphold it. These tests pin the fix at both levels.
|
||||
*/
|
||||
|
||||
const FIELD = "http://schema.org/datePublished";
|
||||
|
||||
function subject(iri: string, values: string[]): UnionSubject {
|
||||
return { subject: iri, graph: "did:ng:o:index" as Nuri, props: { [ENTRY_VALUE]: values } };
|
||||
}
|
||||
|
||||
test("an entry with several values still reads as one entry, deterministically", () => {
|
||||
const s = subject("did:ng:o:a", ["2026-02-02", "2026-01-01"]);
|
||||
expect(entryValue(s)).toBe("2026-01-01");
|
||||
// Order of arrival must not change the answer: two readers must agree.
|
||||
expect(entryValue(subject("did:ng:o:a", ["2026-01-01", "2026-02-02"]))).toBe("2026-01-01");
|
||||
});
|
||||
|
||||
test("a subject with no value at all is not an entry", () => {
|
||||
expect(entryValue({ subject: "did:ng:o:a", graph: "did:ng:o:i" as Nuri, props: {} })).toBeUndefined();
|
||||
expect(entryValue(subject("did:ng:o:a", []))).toBeUndefined();
|
||||
});
|
||||
|
||||
test("entriesOf keeps a multi-valued entry instead of dropping it", () => {
|
||||
const index = "did:ng:o:index" as Nuri;
|
||||
const entries = entriesOf([subject("did:ng:o:a", ["2026-02-02", "2026-01-01"])], index);
|
||||
expect(entries).toEqual([{ object: "did:ng:o:a" as Nuri, value: "2026-01-01" }]);
|
||||
});
|
||||
|
||||
test("one stray non-NURI subject cannot make every real entry unreadable", () => {
|
||||
const index = "did:ng:o:index" as Nuri;
|
||||
// An index document is an ordinary document; its owner may put anything in it.
|
||||
// This used to THROW out of `entriesOf`, losing the whole index to one triple.
|
||||
const entries = entriesOf(
|
||||
[
|
||||
subject("http://example.org/not-a-nuri", ["2026-02-02"]),
|
||||
subject("did:ng:o:real", ["2026-01-01"]),
|
||||
],
|
||||
index,
|
||||
);
|
||||
expect(entries).toEqual([{ object: "did:ng:o:real" as Nuri, value: "2026-01-01" }]);
|
||||
});
|
||||
|
||||
test("an entry whose value is the empty string is still an entry", () => {
|
||||
const index = "did:ng:o:index" as Nuri;
|
||||
expect(entriesOf([subject("did:ng:o:a", [""])], index)).toEqual([
|
||||
{ object: "did:ng:o:a" as Nuri, value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("adding a second value to an entry cannot make it disappear", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const ownerPort = network.portFor("alice");
|
||||
const owner = indexing(ownerPort);
|
||||
const index = await owner.createIndex(FIELD);
|
||||
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
||||
await indexing(network.portFor("bob")).refer(index, article);
|
||||
await owner.curate(index);
|
||||
|
||||
// A pure ADD — the only write this package has. Before the fix this emptied
|
||||
// `read()` while both triples sat in the document.
|
||||
await ownerPort.addLiteralProperty(index, article, ENTRY_VALUE, "2026-02-02");
|
||||
|
||||
expect(await owner.read(index)).toEqual([{ object: article, value: "2026-01-01" }]);
|
||||
});
|
||||
|
||||
test("a raced double-add settles, and does not make every later run re-add", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const ownerPort = network.portFor("alice");
|
||||
const owner = indexing(ownerPort);
|
||||
const index = await owner.createIndex(FIELD);
|
||||
const bobPort = network.portFor("bob");
|
||||
const article = await publishObject(bobPort, FIELD, "2026-01-01");
|
||||
await indexing(bobPort).refer(index, article);
|
||||
await owner.curate(index);
|
||||
|
||||
// What two curation runs racing each other leave behind: the object's owner
|
||||
// edited it between their reads, so each added its own value.
|
||||
network.ownerReplacesValue(article, article, FIELD, "2026-02-02");
|
||||
await ownerPort.addLiteralProperty(index, article, ENTRY_VALUE, "2026-02-02");
|
||||
|
||||
// The entry is still there, and the curator recognises it as already indexed —
|
||||
// before the fix it was invisible, so every run added yet another value.
|
||||
const report = await owner.curate(index);
|
||||
expect(report.outcomes).toEqual([{ result: "unchanged", object: article }]);
|
||||
expect(await owner.read(index)).toEqual([{ object: article, value: "2026-01-01" }]);
|
||||
});
|
||||
|
||||
// --- the descriptor follows the SAME rule, for the same reason ------------
|
||||
|
||||
test("a second declared field stops curation LOUDLY and costs no entry", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const ownerPort = network.portFor("alice");
|
||||
const owner = indexing(ownerPort);
|
||||
const bobPort = network.portFor("bob");
|
||||
const index = await owner.createIndex(FIELD);
|
||||
for (const date of ["2026-01-01", "2026-02-02", "2026-03-03"]) {
|
||||
await indexing(bobPort).refer(index, await publishObject(bobPort, FIELD, date));
|
||||
}
|
||||
await owner.curate(index);
|
||||
expect(await owner.read(index)).toHaveLength(3);
|
||||
|
||||
// One add-only write through the published surface — and the SMALLER string, the
|
||||
// direction in which "smallest wins" would have switched the index onto it.
|
||||
await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, "http://schema.org/aaa");
|
||||
|
||||
// Reading is untouched: an entry already written is a fact, and does not become
|
||||
// unreadable because the declaration above it turned ambiguous.
|
||||
expect(await owner.read(index)).toHaveLength(3);
|
||||
// Curating refuses, and says why instead of quietly picking one.
|
||||
await expect(owner.curate(index)).rejects.toThrow(/declares 2 index fields/);
|
||||
});
|
||||
|
||||
test("a mixed-field index is never produced: curation refuses before adding anything", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const ownerPort = network.portFor("alice");
|
||||
const owner = indexing(ownerPort);
|
||||
const bobPort = network.portFor("bob");
|
||||
const NAME = "http://schema.org/name";
|
||||
|
||||
const index = await owner.createIndex(NAME);
|
||||
const first = await publishObject(bobPort, NAME, "Anemone");
|
||||
await indexing(bobPort).refer(index, first);
|
||||
await owner.curate(index);
|
||||
|
||||
// "…/datePublished" < "…/name", so under "smallest wins" the new field took over
|
||||
// while `first` kept its old value forever — one list ordered by two properties.
|
||||
await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, FIELD);
|
||||
const second = await publishObject(bobPort, FIELD, "2026-02-02");
|
||||
await indexing(bobPort).refer(index, second);
|
||||
|
||||
await expect(owner.curate(index)).rejects.toThrow(/refusing to curate rather than pick one/);
|
||||
expect(await owner.read(index)).toEqual([{ object: first, value: "Anemone" }]);
|
||||
});
|
||||
|
||||
test("an index declaring no field at all is still refused", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const ownerPort = network.portFor("alice");
|
||||
const ordinary = await ownerPort.createPublicDocument();
|
||||
await expect(indexing(ownerPort).read(ordinary)).rejects.toThrow(/declares no index field/);
|
||||
});
|
||||
|
||||
test("a field that could never match an object is refused at creation", async () => {
|
||||
const owner = indexing(new FakeNextGraph().portFor("alice"));
|
||||
// It cannot be corrected later — nothing here deletes — so it is refused now.
|
||||
await expect(owner.createIndex("")).rejects.toThrow(/cannot be changed later/);
|
||||
await expect(owner.createIndex(" ")).rejects.toThrow(/cannot be changed later/);
|
||||
});
|
||||
|
||||
// --- a field named like an Object.prototype member ------------------------
|
||||
|
||||
test("a field colliding with Object.prototype neither crashes nor is silently mis-read", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const owner = indexing(network.portFor("alice"));
|
||||
const bobPort = network.portFor("bob");
|
||||
|
||||
for (const field of ["constructor", "toString", "valueOf", "hasOwnProperty"]) {
|
||||
const index = await owner.createIndex(field);
|
||||
// An object that CARRIES such a predicate cannot be read at all: `readUnion`
|
||||
// fills `props` with `(props[p] ??= []).push(o)`, and `??=` does not assign
|
||||
// over the inherited member, so `.push` is undefined and the read throws.
|
||||
// Upstream's behaviour, mirrored by the double — so this comes back as a
|
||||
// failure to resolve, NOT as an entry.
|
||||
const carries = await publishObject(bobPort, field, "a value");
|
||||
// An object that merely LACKS it must still resolve cleanly: reading the field
|
||||
// off a plain object literal would otherwise hand back an inherited function.
|
||||
const lacks = await publishObject(bobPort, "http://schema.org/name", "unrelated");
|
||||
await indexing(bobPort).refer(index, lacks);
|
||||
await indexing(bobPort).refer(index, carries);
|
||||
|
||||
const report = await owner.curate(index);
|
||||
expect(report.outcomes[0]).toEqual({ result: "skipped", object: lacks, reason: "no-field" });
|
||||
expect(report.outcomes[1]?.result).toBe("unresolved");
|
||||
expect(await owner.read(index)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
// --- the resolution rule, which used to be unreachable in the adapter -----
|
||||
|
||||
test("an empty read resolves as unresolved — never as an object with no field", () => {
|
||||
const resolution = resolutionFromRead([]);
|
||||
expect(resolution.state).toBe("unresolved");
|
||||
// The distinction that matters: had this said `present`, the curator would have
|
||||
// filed a FAILED read as `skipped: "no-field"` — a fact about the object.
|
||||
expect(resolution.state === "unresolved" && resolution.reason).toContain("absent, unreadable");
|
||||
});
|
||||
|
||||
test("a non-empty read resolves as present, carrying the subjects through", () => {
|
||||
const subjects = [subject("did:ng:o:a", ["v"])];
|
||||
expect(resolutionFromRead(subjects)).toEqual({ state: "present", subjects });
|
||||
});
|
||||
|
||||
test("a read that threw resolves as unresolved, naming the error", () => {
|
||||
const resolution = resolutionFromFailure(new Error("broker unreachable"));
|
||||
expect(resolution).toEqual({ state: "unresolved", reason: "Error: broker unreachable" });
|
||||
});
|
||||
|
||||
// --- a failure must SURFACE, not just be returned -------------------------
|
||||
|
||||
test("an unresolved reference is warned about, not only reported", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const owner = indexing(network.portFor("alice"));
|
||||
const index = await owner.createIndex(FIELD);
|
||||
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
||||
await indexing(network.portFor("bob")).refer(index, article);
|
||||
network.breakReadsOf(article, "broker unreachable");
|
||||
|
||||
const warn = mock((..._args: unknown[]) => {});
|
||||
const original = console.warn;
|
||||
console.warn = warn;
|
||||
try {
|
||||
await owner.curate(index);
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain("broker unreachable");
|
||||
});
|
||||
|
||||
test("a normal run warns about nothing", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const owner = indexing(network.portFor("alice"));
|
||||
const index = await owner.createIndex(FIELD);
|
||||
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
||||
await indexing(network.portFor("bob")).refer(index, article);
|
||||
|
||||
const warn = mock((..._args: unknown[]) => {});
|
||||
const original = console.warn;
|
||||
console.warn = warn;
|
||||
let report;
|
||||
try {
|
||||
report = await owner.curate(index);
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
// Assert the run actually DID something — otherwise this passes for a curation
|
||||
// that indexed nothing at all, which would warn about nothing either.
|
||||
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-01-01" }]);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- an unreadable index must not be diagnosed as a malformed one ---------
|
||||
|
||||
test("an index that could not be read is refused, and says so without blaming the document", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const owner = indexing(network.portFor("alice"));
|
||||
const index = await owner.createIndex(FIELD);
|
||||
network.breakReadsOf(index, "broker unreachable");
|
||||
|
||||
// The real `readUnion` turns a failed read into `[]`, so the failure arrives
|
||||
// looking like a blank document. Whatever the shape, nothing may be written.
|
||||
await expect(owner.curate(index)).rejects.toThrow();
|
||||
await expect(owner.read(index)).rejects.toThrow();
|
||||
|
||||
network.healReadsOf(index);
|
||||
expect(await owner.read(index)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "bun:test";
|
||||
// Deliberately the PACKAGE ENTRY POINT, not the modules behind it: this is the
|
||||
// surface an application gets, and it must be usable on its own. It pulls in
|
||||
// `polyfill-adapter.ts`, so this also proves the real `@ng-eventually/polyfill`
|
||||
// still loads and still exports everything this package compiles against.
|
||||
import {
|
||||
indexing,
|
||||
decodeReference,
|
||||
polyfillPort,
|
||||
ENTRY_VALUE,
|
||||
INDEX_FIELD,
|
||||
type CurationReport,
|
||||
type IndexEntry,
|
||||
type NextGraphPort,
|
||||
} from "../src/index";
|
||||
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
|
||||
|
||||
const PUBLISHED_AT = "http://schema.org/datePublished";
|
||||
|
||||
test("the published surface carries the whole loop, end to end", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const ownerPort: NextGraphPort = network.portFor("alice");
|
||||
const strangerPort: NextGraphPort = network.portFor("bob");
|
||||
|
||||
const owner = indexing(ownerPort);
|
||||
const stranger = indexing(strangerPort);
|
||||
|
||||
const index = await owner.createIndex(PUBLISHED_AT);
|
||||
const article = await publishObject(strangerPort, PUBLISHED_AT, "2026-07-08");
|
||||
await stranger.refer(index, article);
|
||||
|
||||
const report: CurationReport = await owner.curate(index);
|
||||
expect(report.index).toBe(index);
|
||||
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]);
|
||||
|
||||
const entries: IndexEntry[] = await owner.read(index);
|
||||
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]);
|
||||
});
|
||||
|
||||
test("the published surface exposes the deposit decoder and the two IRIs it writes", () => {
|
||||
expect(decodeReference("did:ng:o:doc-1")).toBe("did:ng:o:doc-1");
|
||||
expect(decodeReference({ object: "did:ng:o:doc-1" })).toBeNull();
|
||||
expect(INDEX_FIELD).toBe("urn:ng-helpers:index:field");
|
||||
expect(ENTRY_VALUE).toBe("urn:ng-helpers:index:value");
|
||||
});
|
||||
|
||||
test("polyfillPort is published and builds a port without a live session", () => {
|
||||
// Constructing it must not touch the broker — an application wires it at
|
||||
// startup, and only the calls on it talk to anything.
|
||||
const port: NextGraphPort = polyfillPort({ sessionId: "session-under-test" });
|
||||
expect(typeof port.resolveObject).toBe("function");
|
||||
expect(typeof port.addLiteralProperty).toBe("function");
|
||||
// The port has NO operation that could take an entry out of an index.
|
||||
const removing = Object.keys(port).filter((name) => /delete|remove|clear|drop/i.test(name));
|
||||
expect(removing).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { buildInsertTriple, escapeIri, escapeLiteral, isIriSafe } from "../src/sparql";
|
||||
import { asNuri, isNuri } from "../src/nuri";
|
||||
import { decodeReference } from "../src/deposit";
|
||||
|
||||
// --- what a reference is, and what it is not ------------------------------
|
||||
|
||||
test("isNuri accepts a NURI and rejects everything that is not one", () => {
|
||||
expect(isNuri("did:ng:o:abc-123")).toBe(true);
|
||||
expect(isNuri("did:ng:")).toBe(false);
|
||||
expect(isNuri("https://example.org/thing")).toBe(false);
|
||||
expect(isNuri(42)).toBe(false);
|
||||
expect(isNuri(null)).toBe(false);
|
||||
expect(isNuri({ nuri: "did:ng:o:abc" })).toBe(false);
|
||||
});
|
||||
|
||||
test("isNuri rejects a reference carrying characters that would break out of an IRI", () => {
|
||||
expect(isNuri("did:ng:o:a> <urn:evil> ?x")).toBe(false);
|
||||
expect(isNuri('did:ng:o:a"b')).toBe(false);
|
||||
expect(isNuri("did:ng:o:a\nb")).toBe(false);
|
||||
});
|
||||
|
||||
test("asNuri throws on a non-reference rather than passing it on", () => {
|
||||
expect(() => asNuri("nope")).toThrow(/not a NURI/);
|
||||
});
|
||||
|
||||
test("a deposit is a bare reference — anything else decodes to null", () => {
|
||||
expect(decodeReference("did:ng:o:doc-7")).toBe("did:ng:o:doc-7");
|
||||
// The shapes a well-meaning caller might invent, all refused: the payload IS
|
||||
// the reference, it is not wrapped and it carries nothing else.
|
||||
expect(decodeReference({ object: "did:ng:o:doc-7" })).toBeNull();
|
||||
expect(decodeReference({ assert: "published", object: "did:ng:o:doc-7" })).toBeNull();
|
||||
expect(decodeReference(["did:ng:o:doc-7"])).toBeNull();
|
||||
expect(decodeReference("please index did:ng:o:doc-7")).toBeNull();
|
||||
expect(decodeReference(null)).toBeNull();
|
||||
expect(decodeReference(7)).toBeNull();
|
||||
});
|
||||
|
||||
// --- escaping -------------------------------------------------------------
|
||||
|
||||
test("escapeLiteral leaves no raw quote that could close a SPARQL literal", () => {
|
||||
const attack = '" } ; DROP ALL ; INSERT DATA { GRAPH <urn:evil> { <a> <b> "';
|
||||
const escaped = escapeLiteral(attack);
|
||||
expect(/(^|[^\\])"/.test(escaped)).toBe(false);
|
||||
});
|
||||
|
||||
test("escapeLiteral escapes the backslash before the quote, so neither is undone", () => {
|
||||
expect(escapeLiteral('\\"')).toBe('\\\\\\"');
|
||||
});
|
||||
|
||||
test("escapeLiteral escapes the whitespace controls", () => {
|
||||
expect(escapeLiteral("a\nb\rc\td")).toBe("a\\nb\\rc\\td");
|
||||
});
|
||||
|
||||
test("escapeIri percent-encodes every character that could close an IRI", () => {
|
||||
expect(escapeIri("a>b")).toBe("a%3Eb");
|
||||
expect(escapeIri("a<b")).toBe("a%3Cb");
|
||||
expect(escapeIri('a"b')).toBe("a%22b");
|
||||
expect(escapeIri("a b")).toBe("a%20b");
|
||||
expect(escapeIri("a\nb")).toBe("a%0Ab");
|
||||
expect(escapeIri("a\\b")).toBe("a%5Cb");
|
||||
expect(escapeIri("a{b}c|d^e`f")).toBe("a%7Bb%7Dc%7Cd%5Ee%60f");
|
||||
});
|
||||
|
||||
test("escapeIri leaves the characters a NURI is actually made of alone", () => {
|
||||
expect(escapeIri("did:ng:o:AbC-123_x.y")).toBe("did:ng:o:AbC-123_x.y");
|
||||
});
|
||||
|
||||
test("escapeIri leaves printable non-ASCII alone, since encoding it would corrupt it", () => {
|
||||
expect(escapeIri("élan")).toBe("élan");
|
||||
});
|
||||
|
||||
test("isIriSafe agrees with escapeIri on what needs encoding", () => {
|
||||
expect(isIriSafe("did:ng:o:abc")).toBe(true);
|
||||
expect(isIriSafe("did:ng:o:a b")).toBe(false);
|
||||
expect(isIriSafe("did:ng:o:a>b")).toBe(false);
|
||||
});
|
||||
|
||||
// --- the one statement this package writes --------------------------------
|
||||
|
||||
test("buildInsertTriple writes an anchored INSERT DATA and nothing else", () => {
|
||||
expect(
|
||||
buildInsertTriple(
|
||||
"did:ng:o:index",
|
||||
"did:ng:o:object",
|
||||
"urn:ng-helpers:index:value",
|
||||
"2026-01-02",
|
||||
),
|
||||
).toBe(
|
||||
"INSERT DATA { GRAPH <did:ng:o:index> " +
|
||||
'{ <did:ng:o:object> <urn:ng-helpers:index:value> "2026-01-02" } }',
|
||||
);
|
||||
});
|
||||
|
||||
/** Quotes that are NOT preceded by an odd run of backslashes — i.e. real delimiters. */
|
||||
function unescapedQuotes(query: string): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < query.length; i += 1) {
|
||||
if (query[i] !== '"') continue;
|
||||
let backslashes = 0;
|
||||
for (let j = i - 1; j >= 0 && query[j] === "\\"; j -= 1) backslashes += 1;
|
||||
if (backslashes % 2 === 0) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
test("buildInsertTriple neutralises a breakout attempt in the value", () => {
|
||||
const query = buildInsertTriple(
|
||||
"did:ng:o:index",
|
||||
"did:ng:o:object",
|
||||
"urn:ng-helpers:index:value",
|
||||
'" } } ; DROP ALL ; INSERT DATA { GRAPH <urn:evil> { <a> <b> "c',
|
||||
);
|
||||
// The attack text survives as TEXT, which is fine — what matters is that it
|
||||
// cannot leave the literal. Exactly two quotes are real delimiters: the ones
|
||||
// this builder wrote. Every quote in the value is escaped, so the `DROP ALL`
|
||||
// and the second `INSERT DATA` are inert characters, not statements.
|
||||
expect(unescapedQuotes(query)).toBe(2);
|
||||
expect(query.startsWith('INSERT DATA { GRAPH <did:ng:o:index> { <did:ng:o:object> ')).toBe(true);
|
||||
expect(query.endsWith('" } }')).toBe(true);
|
||||
});
|
||||
|
||||
test("buildInsertTriple neutralises a breakout attempt in the subject", () => {
|
||||
const query = buildInsertTriple(
|
||||
"did:ng:o:index",
|
||||
"did:ng:o:a> <urn:evil> <urn:x",
|
||||
"urn:ng-helpers:index:value",
|
||||
"v",
|
||||
);
|
||||
// The injected angle brackets are percent-encoded, so the subject stays one IRI.
|
||||
expect(query).toBe(
|
||||
"INSERT DATA { GRAPH <did:ng:o:index> " +
|
||||
'{ <did:ng:o:a%3E%20%3Curn:evil%3E%20%3Curn:x> <urn:ng-helpers:index:value> "v" } }',
|
||||
);
|
||||
});
|
||||
|
||||
test("every builder emits an INSERT and nothing else", async () => {
|
||||
const sparql: Record<string, unknown> = await import("../src/sparql");
|
||||
const emitted: string[] = [];
|
||||
for (const [name, exported] of Object.entries(sparql)) {
|
||||
if (typeof exported !== "function" || !name.startsWith("build")) continue;
|
||||
const builder = exported as (...args: string[]) => unknown;
|
||||
emitted.push(String(builder("did:ng:o:g", "did:ng:o:s", "urn:p", "v")));
|
||||
}
|
||||
expect(emitted.length).toBeGreaterThan(0); // not vacuously true
|
||||
for (const query of emitted) {
|
||||
expect(query.startsWith("INSERT DATA")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("a tripwire: no destructive SPARQL keyword is written anywhere under src/", async () => {
|
||||
// SECONDARY, and worth being honest about what it is worth. A pattern over
|
||||
// SOURCE can always be out-written, and an earlier version of this test was:
|
||||
// four evasions passed it with the whole suite green — `COPY DEFAULT TO GRAPH`,
|
||||
// a keyword hidden behind the comment-stripper, `DELETE{` with no space, and a
|
||||
// literal split across concatenated lines.
|
||||
//
|
||||
// What actually guards the invariant is `test/adapter-write-path.test.ts`,
|
||||
// which RUNS the adapter and reads back every query it emits; all four evasions
|
||||
// fail there. This stays as a cheap tripwire that catches the obvious
|
||||
// regression early and names the file — it is not the proof.
|
||||
//
|
||||
// It fails CLOSED: a trailing `// … DROP GRAPH …` comment trips it. That is the
|
||||
// right direction for a tripwire, and stripping trailing comments properly
|
||||
// would need a tokenizer, since `//` appears inside every `http://` IRI.
|
||||
const { Glob } = await import("bun");
|
||||
const root = new URL("../src/", import.meta.url).pathname;
|
||||
const files = await Array.fromAsync(
|
||||
new Glob("**/*.{ts,mts,cts,js,mjs,cjs}").scan({ cwd: root, absolute: true }),
|
||||
);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
|
||||
// Bare keywords, destructive and data-moving alike. For a tripwire a false
|
||||
// positive is far cheaper than a miss, so this does not try to match forms.
|
||||
const destructive = /\b(DELETE|DROP|CLEAR|MOVE|COPY|MODIFY|LOAD)\b/i;
|
||||
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = (await Bun.file(file).text())
|
||||
.replace(/\/\*[^]*?\*\//g, " ") // block comments
|
||||
.replace(/^\s*\/\/.*$/gm, " "); // whole-line comments
|
||||
if (destructive.test(code)) offenders.push(file.slice(root.length));
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"declaration": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user