469346aef3
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.
123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
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);
|
|
});
|