Files
ng-helpers/test/only-grows.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

272 lines
12 KiB
TypeScript

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