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
+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;
}