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:
Sylvain Duchesne
2026-08-16 15:21:41 +02:00
commit 469346aef3
21 changed files with 2281 additions and 0 deletions
+122
View File
@@ -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);
});
+250
View File
@@ -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;
}
+344
View File
@@ -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" }]);
});
+271
View File
@@ -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([]);
});
+56
View File
@@ -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([]);
});
+185
View File
@@ -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([]);
});