Files
ng-helpers/test/indexing.test.ts
T
Sylvain Duchesne 469346aef3 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.
2026-08-16 16:21:48 +02:00

345 lines
15 KiB
TypeScript

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" }]);
});