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:
+167
@@ -0,0 +1,167 @@
|
||||
import type { NextGraphPort, Nuri, NuriLike, UnionSubject } from "./port";
|
||||
import { asNuri } from "./nuri";
|
||||
import { decodeReference } from "./deposit";
|
||||
import { descriptorOf, entriesOf, readIndexDocument, valuesOf } from "./index-document";
|
||||
import { ENTRY_VALUE } from "./vocabulary";
|
||||
|
||||
/**
|
||||
* Applying the references an index has received. Runs as the index's OWNER — only
|
||||
* the owner reads the inbox, and only the owner writes the document.
|
||||
*
|
||||
* ## The curator does not act on a claim, it resolves the reference and looks
|
||||
*
|
||||
* A deposit is a bare reference. For each one the curator opens the object
|
||||
* itself — which it can, because indexing is limited to PUBLIC data for now:
|
||||
*
|
||||
* - the object is **present** → it is added to the index, under the value it
|
||||
* holds for the index's field, unless it is already there;
|
||||
* - **anything else** → nothing at all happens.
|
||||
*
|
||||
* So there is no ownership check, and none is needed. A deposit is an invitation
|
||||
* to re-examine, not an instruction: the truth is what the object says, not what
|
||||
* the depositor says. A stranger who deposits a reference achieves exactly what
|
||||
* the owner would have — which is what lets a passer-by who notices a missing
|
||||
* entry nudge the index into re-checking it.
|
||||
*
|
||||
* ## AN INDEX ONLY EVER GROWS
|
||||
*
|
||||
* Nothing here removes an entry. Not "removal is guarded", not "removal needs
|
||||
* proof" — there is no removal, and `NextGraphPort` has no operation that could
|
||||
* express one.
|
||||
*
|
||||
* That is what makes the failure story trivial. Since the only write is an
|
||||
* addition, a read that comes back empty — whether the object is gone, or
|
||||
* unreadable, or the broker simply did not answer — can only ever mean "not added
|
||||
* this time". It cannot damage what is already there, and a later deposit adds
|
||||
* it. Nothing has to tell an absence from a failure, so nothing can get that
|
||||
* wrong. Compare the defect this avoids: a failed read resolving as an absence,
|
||||
* removed from `ng-eventually-js` in 8c8ade7 and e32b6d0 — "le silence n'est plus
|
||||
* possible que sur une absence VÉRIFIÉE".
|
||||
*
|
||||
* The cost is stated plainly, because someone will eventually need it: an entry
|
||||
* cannot be taken out of an index, by anyone, including the index's owner.
|
||||
*
|
||||
* ## A failure still has to be seen
|
||||
*
|
||||
* Harmless is not the same as invisible. Every reference that could not be
|
||||
* resolved comes back as an `unresolved` outcome AND is warned about, because a
|
||||
* failure that looks exactly like a normal outcome teaches nobody anything.
|
||||
*
|
||||
* ## Deposits are not consumed
|
||||
*
|
||||
* Nothing here retires an applied deposit, so every run sees every deposit again.
|
||||
* That is affordable only because a deposit carries no instruction: re-applying
|
||||
* one re-resolves the reference and lands on the same result. Curation is
|
||||
* convergent, and its outcome does not depend on the order references arrive in.
|
||||
*/
|
||||
|
||||
export type CurationOutcome =
|
||||
/** Present, and now in the index under this value. */
|
||||
| { readonly result: "indexed"; readonly object: Nuri; readonly value: string }
|
||||
/** Present, and already in the index. Nothing was written. */
|
||||
| { readonly result: "unchanged"; readonly object: Nuri }
|
||||
/** Resolved, and deliberately not added. */
|
||||
| { readonly result: "skipped"; readonly object: Nuri; readonly reason: SkipReason }
|
||||
/**
|
||||
* The reference could not be resolved — gone, unreadable, or the read failed;
|
||||
* this layer does not tell those apart. Nothing was written, and nothing that
|
||||
* was already in the index was touched. A later deposit will add it.
|
||||
*/
|
||||
| { readonly result: "unresolved"; readonly object: Nuri; readonly reason: string }
|
||||
/** A payload in the inbox that is not a reference at all. */
|
||||
| { readonly result: "foreign"; readonly reason: string };
|
||||
|
||||
export type SkipReason =
|
||||
/** Present, but carries nothing for the index's field. OPEN QUESTION — see README. */
|
||||
| "no-field"
|
||||
/** Present, but carries several values for the field: which one would the entry hold? */
|
||||
| "several-values"
|
||||
/** A reference to the index document itself. */
|
||||
| "self-reference";
|
||||
|
||||
export interface CurationReport {
|
||||
readonly index: Nuri;
|
||||
/**
|
||||
* One per deposit, in the order they were made. Check it for `unresolved`:
|
||||
* those references were not added and are worth a retry or a look.
|
||||
*/
|
||||
readonly outcomes: readonly CurationOutcome[];
|
||||
}
|
||||
|
||||
export async function curate(port: NextGraphPort, indexLike: NuriLike): Promise<CurationReport> {
|
||||
const index = asNuri(indexLike);
|
||||
|
||||
const subjects = await readIndexDocument(port, index);
|
||||
const descriptor = descriptorOf(subjects, index);
|
||||
|
||||
// What the index already holds, kept up to date as this run writes, so that the
|
||||
// same reference deposited twice produces one entry.
|
||||
const indexed = new Set<Nuri>(entriesOf(subjects, index).map((e) => e.object));
|
||||
|
||||
const deposits = await port.readDeposits(index);
|
||||
const outcomes: CurationOutcome[] = [];
|
||||
|
||||
for (const deposit of deposits) {
|
||||
const object = decodeReference(deposit.payload);
|
||||
if (object === null) {
|
||||
outcomes.push({ result: "foreign", reason: "payload is not a reference" });
|
||||
continue;
|
||||
}
|
||||
if (object === index) {
|
||||
outcomes.push({ result: "skipped", object, reason: "self-reference" });
|
||||
continue;
|
||||
}
|
||||
if (indexed.has(object)) {
|
||||
// Already there. Nothing is re-read and nothing is rewritten — an entry, once
|
||||
// made, is never touched again.
|
||||
outcomes.push({ result: "unchanged", object });
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolution = await port.resolveObject(object);
|
||||
if (resolution.state === "unresolved") {
|
||||
console.warn(
|
||||
`[ng-helpers/indexing] ${index}: reference not added — ${object}: ${resolution.reason}`,
|
||||
);
|
||||
outcomes.push({ result: "unresolved", object, reason: resolution.reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
const values = fieldValues(resolution.subjects, descriptor.field);
|
||||
if (values.length === 0) {
|
||||
// NARROW BEHAVIOUR, and an open question: an object carrying nothing for the
|
||||
// field is simply not added. There is no key to index it by, and inventing
|
||||
// one (a placeholder, the deposit time) would put something in the index that
|
||||
// the object does not say.
|
||||
outcomes.push({ result: "skipped", object, reason: "no-field" });
|
||||
continue;
|
||||
}
|
||||
const value = values[0];
|
||||
if (values.length > 1 || value === undefined) {
|
||||
outcomes.push({ result: "skipped", object, reason: "several-values" });
|
||||
continue;
|
||||
}
|
||||
|
||||
await port.addLiteralProperty(index, object, ENTRY_VALUE, value);
|
||||
indexed.add(object);
|
||||
outcomes.push({ result: "indexed", object, value });
|
||||
}
|
||||
|
||||
return { index, outcomes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value the object holds for the field, across all the subjects it carries.
|
||||
*
|
||||
* Goes through `valuesOf`, which reads OWN properties only. The field is named by
|
||||
* the index document, so `constructor` and `toString` are fields an index can
|
||||
* genuinely declare — and reading one off a plain object literal hands back an
|
||||
* inherited FUNCTION where a list of values belongs.
|
||||
*/
|
||||
function fieldValues(subjects: readonly UnionSubject[], field: string): readonly string[] {
|
||||
const values: string[] = [];
|
||||
for (const subject of subjects) {
|
||||
values.push(...valuesOf(subject, field));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Nuri } from "./port";
|
||||
import { isNuri } from "./nuri";
|
||||
|
||||
/**
|
||||
* What a depositor puts in an index document's inbox: **a bare reference, and
|
||||
* nothing else**.
|
||||
*
|
||||
* Not an instruction — not "add this row", not even "I published this". The
|
||||
* curator resolves the reference and looks; what it finds is what decides. This
|
||||
* is the shape NextGraph already uses upstream: a `SocialQueryRequest` carries
|
||||
* `definition_commit_body_ref` — a REFERENCE — and the recipient's
|
||||
* `inbox_processor` composes its own SPARQL. Since anyone may deposit into any
|
||||
* index, a payload that carried an operation would be a licence to rewrite
|
||||
* someone else's document.
|
||||
*
|
||||
* It carries no index reference either. The deposit is addressed to the index
|
||||
* document's inbox, and upstream an inbox belongs to exactly one repo — the
|
||||
* address IS the identification. The polyfill says the same of its own inbox:
|
||||
* "Tagging deposits with their document would be an invention consumers would
|
||||
* have to unlearn at migration."
|
||||
*
|
||||
* And it carries no indexed data. Indexing is limited to PUBLIC objects
|
||||
* precisely so that the curator can open the object itself; a value copied into
|
||||
* the deposit would let a depositor put something in the index that the object
|
||||
* does not say.
|
||||
*
|
||||
* What is left is the NURI. That is the whole payload.
|
||||
*/
|
||||
export type IndexDeposit = Nuri;
|
||||
|
||||
/**
|
||||
* Anyone may deposit anything into an index's inbox, so every payload is
|
||||
* untrusted input. Returns `null` for everything that is not a reference.
|
||||
*/
|
||||
export function decodeReference(payload: unknown): Nuri | null {
|
||||
return isNuri(payload) ? payload : null;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { NextGraphPort, Nuri, NuriLike, UnionSubject } from "./port";
|
||||
import { asNuri, isNuri } from "./nuri";
|
||||
import { ENTRY_VALUE, INDEX_FIELD } from "./vocabulary";
|
||||
|
||||
/**
|
||||
* An index is an ORDINARY document. Nothing in NextGraph marks it as one; what
|
||||
* makes it an index is that an application references its NURI in its own source,
|
||||
* and that it holds the two things below.
|
||||
*
|
||||
* - **A descriptor**, on the document's own NURI as subject: the FIELD this index
|
||||
* indexes by — a predicate, since the objects are RDF.
|
||||
* - **One entry per indexed object**: subject = the object's NURI, carrying the
|
||||
* value that object holds for the field.
|
||||
*
|
||||
* ## Why the field is declared IN the document
|
||||
*
|
||||
* The curator has to know which field to read off an object. The only other place
|
||||
* to keep it is the application's source next to the NURI — and then two
|
||||
* applications pointing at the same index could curate it on two different fields
|
||||
* and corrupt each other's entries. Declared in the document, the index answers
|
||||
* for itself and its creator's choice is the only one.
|
||||
*
|
||||
* The cost is one extra subject in `readUnion([indexNuri])`, told apart from the
|
||||
* entries by being the index's own NURI. `read` drops it; a reader going straight
|
||||
* to `readUnion` sees it.
|
||||
*/
|
||||
|
||||
export interface IndexDescriptor {
|
||||
/** The predicate an indexed object must carry. */
|
||||
readonly field: string;
|
||||
}
|
||||
|
||||
/** One row of an index, as an application reads it. */
|
||||
export interface IndexEntry {
|
||||
/** The indexed object — hand this straight back to `readUnion`. */
|
||||
readonly object: Nuri;
|
||||
/** What that object holds for the index's field. */
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export async function writeDescriptor(
|
||||
port: NextGraphPort,
|
||||
index: Nuri,
|
||||
descriptor: IndexDescriptor,
|
||||
): Promise<void> {
|
||||
await port.addLiteralProperty(index, index, INDEX_FIELD, descriptor.field);
|
||||
}
|
||||
|
||||
/**
|
||||
* The values a subject carries for a predicate.
|
||||
*
|
||||
* Reads OWN properties only, because `props` arrives as a plain object literal
|
||||
* (`readUnion` builds `const props: Record<string, string[]> = {}`), so it
|
||||
* inherits from `Object.prototype`. An index whose field is `constructor`,
|
||||
* `toString` or `valueOf` would otherwise find a FUNCTION where a list of values
|
||||
* belongs — verified: spreading it throws `TypeError: Spread syntax requires
|
||||
* ...iterable[Symbol.iterator] to be a function`, which aborted curation for
|
||||
* every remaining deposit and could not be undone, since the field cannot change.
|
||||
*
|
||||
* The non-string filter is the same caution: this data crosses a process boundary,
|
||||
* and a value that is not a string has no business being compared with `<`.
|
||||
*
|
||||
* The two guards are deliberately redundant — either alone stops the crash, as
|
||||
* mutation testing confirms (removing just one fails nothing; removing both fails
|
||||
* a test). They are kept because they answer different questions: `hasOwn` asks
|
||||
* whether the subject really carries this predicate, `Array.isArray` whether what
|
||||
* came back has the shape the type promises.
|
||||
*/
|
||||
export function valuesOf(
|
||||
subject: UnionSubject | undefined,
|
||||
predicate: string,
|
||||
): readonly string[] {
|
||||
const props = subject?.props;
|
||||
if (props === undefined || !Object.hasOwn(props, predicate)) return [];
|
||||
const values: unknown = props[predicate];
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values.filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest of several values, or `undefined` when there are none.
|
||||
*
|
||||
* The one tie-break rule this package has, used everywhere a document may carry
|
||||
* more values than expected. It is deterministic and order-independent, so every
|
||||
* reader of the same document reaches the same answer — which is the property that
|
||||
* matters, far more than WHICH value wins.
|
||||
*/
|
||||
function smallestOf(values: readonly string[]): string | undefined {
|
||||
let chosen: string | undefined;
|
||||
for (const value of values) {
|
||||
if (chosen === undefined || value < chosen) chosen = value;
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value of an ENTRY: it exists as soon as its subject carries AT LEAST ONE.
|
||||
*
|
||||
* This is what makes "an index only ever grows" true, and requiring exactly one
|
||||
* was a hole big enough to drive the whole guarantee through. Two values for one
|
||||
* object — which two curation runs racing each other produce, since each only ever
|
||||
* ADDS — made the entry read as ABSENT. The index could therefore SHRINK through
|
||||
* nothing but additions: the invariant defeated by the very operation meant to
|
||||
* uphold it, with no delete anywhere in sight.
|
||||
*/
|
||||
export function entryValue(subject: UnionSubject): string | undefined {
|
||||
return smallestOf(valuesOf(subject, ENTRY_VALUE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this document an index at all? Answering needs only that it declares SOME
|
||||
* field — not which one.
|
||||
*
|
||||
* Reading entries is deliberately separated from curating them, because the two
|
||||
* need different things and conflating them cost every entry in the document. An
|
||||
* entry already written is a fact; it does not become unreadable because the
|
||||
* declaration above it turned ambiguous.
|
||||
*/
|
||||
export function assertIndexDocument(subjects: readonly UnionSubject[], index: Nuri): void {
|
||||
const self = subjects.find((s) => s.subject === index);
|
||||
if (valuesOf(self, INDEX_FIELD).length > 0) return;
|
||||
throw new Error(
|
||||
`${index} declares no index field (${INDEX_FIELD}) — either it is not an index, ` +
|
||||
"or it could not be read: an unreadable document and an empty one are the same " +
|
||||
"empty result here. Nothing was written. Retry before concluding it is malformed.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The field to CURATE on — and here it must be unambiguous.
|
||||
*
|
||||
* Both earlier attempts at this were wrong, in opposite directions, and the
|
||||
* second was worse than the first:
|
||||
*
|
||||
* - demanding exactly one and throwing out of the READ path meant a single
|
||||
* add-only write of a second `INDEX_FIELD` made every entry in the document
|
||||
* unreadable, permanently, since nothing here deletes;
|
||||
* - "smallest wins" kept it readable but silently CORRUPTED it: entries already
|
||||
* written are never re-read, so they keep their old field's values while new
|
||||
* ones arrive under the new field, and `read()` returns a single list "ordered
|
||||
* by value" whose values come from two different properties. A quiet wrong
|
||||
* answer, which is worse than a loud failure.
|
||||
*
|
||||
* So: ambiguity refuses to CURATE, loudly, and leaves reading alone. Entries
|
||||
* already in the index stay visible and correct; nothing new is added until a
|
||||
* human looks. That the refusal is permanent is the honest price of having no
|
||||
* delete — the message says so instead of pretending a retry will help.
|
||||
*/
|
||||
export function descriptorOf(subjects: readonly UnionSubject[], index: Nuri): IndexDescriptor {
|
||||
const self = subjects.find((s) => s.subject === index);
|
||||
const declared = valuesOf(self, INDEX_FIELD);
|
||||
|
||||
if (declared.length > 1) {
|
||||
throw new Error(
|
||||
`${index} declares ${declared.length} index fields (${declared.map((f) => JSON.stringify(f)).join(", ")}) ` +
|
||||
"— refusing to curate rather than pick one, because entries already written under " +
|
||||
"the other field are never re-read, and mixing them would leave one list ordered " +
|
||||
"by two different properties. Reading the existing entries still works. This " +
|
||||
"cannot be undone (nothing here deletes): curate into a fresh index.",
|
||||
);
|
||||
}
|
||||
|
||||
const field = declared[0];
|
||||
if (field === undefined) {
|
||||
throw new Error(
|
||||
`${index} declares no index field (${INDEX_FIELD}) — either it is not an index, ` +
|
||||
"or it could not be read: an unreadable document and an empty one are the same " +
|
||||
"empty result here. Nothing was written. Retry before concluding it is malformed.",
|
||||
);
|
||||
}
|
||||
return { field };
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries, ordered by value. Ties are broken on the object NURI so that two
|
||||
* readers of the same index always see the same order.
|
||||
*
|
||||
* Values are compared AS STRINGS — this layer does not know what the field means.
|
||||
* An index whose field holds ISO-8601 dates therefore comes out in chronological
|
||||
* order, which is the whole reason to index by a date.
|
||||
*
|
||||
* A subject that is not a NURI is SKIPPED, never thrown on. An index document is
|
||||
* an ordinary document and its owner may put anything in it; a single stray
|
||||
* triple must not be able to make every real entry unreadable at once. Reading is
|
||||
* per-entry tolerant for the same reason the write path has no delete — what a
|
||||
* reader could already see has to keep being visible.
|
||||
*/
|
||||
export function entriesOf(subjects: readonly UnionSubject[], index: Nuri): IndexEntry[] {
|
||||
const entries: IndexEntry[] = [];
|
||||
for (const subject of subjects) {
|
||||
if (subject.subject === index) continue;
|
||||
if (!isNuri(subject.subject)) continue;
|
||||
const value = entryValue(subject);
|
||||
if (value === undefined) continue;
|
||||
entries.push({ object: asNuri(subject.subject), value });
|
||||
}
|
||||
entries.sort((a, b) => (a.value === b.value ? cmp(a.object, b.object) : cmp(a.value, b.value)));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function cmp(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function readIndexDocument(
|
||||
port: NextGraphPort,
|
||||
index: NuriLike,
|
||||
): Promise<readonly UnionSubject[]> {
|
||||
return port.readDocument(asNuri(index));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `@ng-helpers/indexing` — an index built on top of NextGraph.
|
||||
*
|
||||
* NextGraph has no indexing concept and will not grow one, so nothing here
|
||||
* belongs in `@ng-eventually/polyfill`: that package's inbox stays generic and
|
||||
* carries opaque deposits, and knows nothing of what a deposit means.
|
||||
*
|
||||
* An index is an ORDINARY document in its creator's public store. What makes it
|
||||
* an index is that an application references its NURI in its own source. Anyone
|
||||
* may hand it a reference by depositing into its inbox; its owner resolves those
|
||||
* references itself and adds what it finds.
|
||||
*
|
||||
* An index only ever grows — see `curator.ts`.
|
||||
*/
|
||||
|
||||
// Wiring
|
||||
export { indexing } from "./indexing";
|
||||
export type { Indexing } from "./indexing";
|
||||
export { polyfillPort } from "./polyfill-adapter";
|
||||
export type { PolyfillPortOptions } from "./polyfill-adapter";
|
||||
export type {
|
||||
NextGraphPort,
|
||||
IncomingDeposit,
|
||||
ObjectResolution,
|
||||
Nuri,
|
||||
NuriLike,
|
||||
UnionSubject,
|
||||
} from "./port";
|
||||
|
||||
// What travels from a depositor to a curator: a bare reference
|
||||
export { decodeReference } from "./deposit";
|
||||
export type { IndexDeposit } from "./deposit";
|
||||
|
||||
// What an index holds, and what curating it reports
|
||||
export type { IndexDescriptor, IndexEntry } from "./index-document";
|
||||
export type { CurationOutcome, CurationReport, SkipReason } from "./curator";
|
||||
|
||||
// The IRIs written into an index document, for a reader going straight to `readUnion`
|
||||
export { ENTRY_VALUE, INDEX_FIELD } from "./vocabulary";
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import type { NextGraphPort, Nuri, NuriLike } from "./port";
|
||||
import { asNuri } from "./nuri";
|
||||
import { curate, type CurationReport } from "./curator";
|
||||
import {
|
||||
assertIndexDocument,
|
||||
entriesOf,
|
||||
readIndexDocument,
|
||||
writeDescriptor,
|
||||
type IndexEntry,
|
||||
} from "./index-document";
|
||||
|
||||
/**
|
||||
* Everything this package does, bound to one identity.
|
||||
*
|
||||
* A port carries an identity — the polyfill's session is one user's and no call
|
||||
* takes an identifier — so an `Indexing` is one person's handle. Two people mean
|
||||
* two handles, which is also what keeps a multi-actor test honest.
|
||||
*/
|
||||
export interface Indexing {
|
||||
/**
|
||||
* Creates an index, in THIS identity's public store; the creator owns it. Any
|
||||
* user may create one.
|
||||
*
|
||||
* `field` is the predicate an indexed object must carry, declared once, here.
|
||||
* An index "by a date" is just an index whose field is a date predicate — there
|
||||
* is no separate kind of index, and the entries of such an index come out in
|
||||
* chronological order because ISO-8601 sorts as a string.
|
||||
*
|
||||
* The returned NURI is what an application hardcodes in its own source: that
|
||||
* reference is the only thing that makes this ordinary document an index, and
|
||||
* the only way anyone reaches it.
|
||||
*/
|
||||
createIndex(field: string): Promise<Nuri>;
|
||||
|
||||
/**
|
||||
* Hands an index a reference to an object. Open to ANYONE — it is a deposit in
|
||||
* the index document's inbox, not a write.
|
||||
*
|
||||
* The reference is the whole message: it claims nothing and instructs nothing,
|
||||
* it just invites the index's owner to look. Call it when the object is
|
||||
* created, and again whenever anyone notices the index may not have it yet —
|
||||
* including a third party. Nothing lands in the index until its owner curates.
|
||||
*/
|
||||
refer(index: NuriLike, object: NuriLike): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resolves the references this index has received and adds what it can. Only
|
||||
* the index's OWNER gets anything: nobody else reads its inbox, and nobody else
|
||||
* may write it.
|
||||
*
|
||||
* Check the returned outcomes for `unresolved` — those references were not
|
||||
* added.
|
||||
*/
|
||||
curate(index: NuriLike): Promise<CurationReport>;
|
||||
|
||||
/**
|
||||
* The index's entries, ordered by value.
|
||||
*
|
||||
* Sugar only. Reading an index needs NOTHING new from NextGraph: an application
|
||||
* that knows the NURI can call `readUnion([indexNuri])` and get these entries as
|
||||
* subjects — one per indexed object, keyed by its NURI — plus the index's own
|
||||
* subject declaring its field, which this function drops.
|
||||
*/
|
||||
read(index: NuriLike): Promise<IndexEntry[]>;
|
||||
}
|
||||
|
||||
export function indexing(port: NextGraphPort): Indexing {
|
||||
return {
|
||||
async createIndex(field: string): Promise<Nuri> {
|
||||
// Refused at the door, because a field cannot be corrected afterwards:
|
||||
// nothing here deletes, so an index created on a useless field is useless
|
||||
// for good. An empty string is the sharp case — it would match no object.
|
||||
if (field.length === 0 || field.trim().length === 0) {
|
||||
throw new Error(
|
||||
"createIndex: the field must be the predicate an indexed object carries, " +
|
||||
"and it cannot be changed later — this package never removes anything.",
|
||||
);
|
||||
}
|
||||
const index = await port.createPublicDocument();
|
||||
await writeDescriptor(port, index, { field });
|
||||
// An index nobody can deposit into is not an index. Only its owner can open
|
||||
// its inbox, and this is the one moment the owner is here — so it is opened
|
||||
// at creation rather than left for a later call to remember.
|
||||
await port.openInbox(index);
|
||||
return index;
|
||||
},
|
||||
|
||||
async refer(index: NuriLike, object: NuriLike): Promise<void> {
|
||||
// The payload IS the reference. Nothing wraps it, nothing annotates it.
|
||||
await port.depositTo(asNuri(index), asNuri(object));
|
||||
},
|
||||
|
||||
curate(index: NuriLike): Promise<CurationReport> {
|
||||
return curate(port, index);
|
||||
},
|
||||
|
||||
async read(index: NuriLike): Promise<IndexEntry[]> {
|
||||
const nuri = asNuri(index);
|
||||
const subjects = await readIndexDocument(port, nuri);
|
||||
// Only "is this an index?", deliberately NOT "which field does it curate
|
||||
// on?". Reading entries never needed the field, and making it need one is
|
||||
// what let an ambiguous declaration make every entry unreadable for good.
|
||||
assertIndexDocument(subjects, nuri);
|
||||
return entriesOf(subjects, nuri);
|
||||
},
|
||||
};
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { Nuri, NuriLike } from "./port";
|
||||
import { isIriSafe } from "./sparql";
|
||||
|
||||
const NURI_PREFIX = "did:ng:";
|
||||
|
||||
/**
|
||||
* `@ng-eventually/polyfill` publishes the `Nuri` type but NO type guard
|
||||
* (`contract_polyfill-surface`: "No type guard is published"), so this layer
|
||||
* carries its own rather than reaching into that package.
|
||||
*
|
||||
* A NURI reaches this layer from an inbox deposit, so it is untrusted input: it
|
||||
* is checked to be writable inside `<...>` before it is ever put in a query.
|
||||
*/
|
||||
export function isNuri(value: unknown): value is Nuri {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.startsWith(NURI_PREFIX) &&
|
||||
value.length > NURI_PREFIX.length &&
|
||||
isIriSafe(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function asNuri(value: NuriLike): Nuri {
|
||||
if (!isNuri(value)) {
|
||||
throw new Error(`not a NURI: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { docs, inbox, readUnion, storeRegistry } from "@ng-eventually/polyfill";
|
||||
import type {
|
||||
IncomingDeposit,
|
||||
NextGraphPort,
|
||||
NuriLike,
|
||||
ObjectResolution,
|
||||
UnionSubject,
|
||||
} from "./port";
|
||||
import { asNuri } from "./nuri";
|
||||
import { buildInsertTriple } from "./sparql";
|
||||
import { resolutionFromFailure, resolutionFromRead } from "./resolution";
|
||||
|
||||
/**
|
||||
* The ONLY place in this package that touches `@ng-eventually/polyfill` at
|
||||
* runtime. Everything else is written against `NextGraphPort`, so the indexing
|
||||
* rules never learn a polyfill call — and the polyfill never learns anything
|
||||
* about indexing.
|
||||
*
|
||||
* Only entries listed in `contract_polyfill-surface` are used here; nothing is
|
||||
* reached for inside that package. Because this file compiles against the real
|
||||
* module, a change to the published surface breaks THIS file and nothing else.
|
||||
*/
|
||||
export interface PolyfillPortOptions {
|
||||
/**
|
||||
* The polyfill's session id, as handed to the callback of its own `init(...)`.
|
||||
* The published write primitive (`docs.sparqlUpdate`) takes it; the polyfill
|
||||
* publishes no higher-level way to write triples into a document, so this is
|
||||
* the level to align on.
|
||||
*
|
||||
* Its type is upstream's own: it is relayed, never converted.
|
||||
*/
|
||||
readonly sessionId: string | number;
|
||||
}
|
||||
|
||||
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
|
||||
const { sessionId } = options;
|
||||
|
||||
return {
|
||||
async createPublicDocument() {
|
||||
// An index lives in its creator's public store, so that any reader can open
|
||||
// it from the reference alone.
|
||||
return storeRegistry.createEntityDoc("public");
|
||||
},
|
||||
|
||||
async resolveObject(doc: NuriLike): Promise<ObjectResolution> {
|
||||
// The decision itself lives in `resolution.ts`, where it is unit-tested:
|
||||
// here it would be reachable only through a live broker, and an untested
|
||||
// "empty means unresolved" quietly becomes "empty means the object has no
|
||||
// field" — a failure filed as a fact about the object.
|
||||
let subjects: readonly UnionSubject[];
|
||||
try {
|
||||
subjects = await readUnion([asNuri(doc)]);
|
||||
} catch (error) {
|
||||
return resolutionFromFailure(error);
|
||||
}
|
||||
return resolutionFromRead(subjects);
|
||||
},
|
||||
|
||||
async readDocument(doc: NuriLike): Promise<readonly UnionSubject[]> {
|
||||
return readUnion([asNuri(doc)]);
|
||||
},
|
||||
|
||||
async addLiteralProperty(
|
||||
doc: NuriLike,
|
||||
subject: string,
|
||||
predicate: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const graph = asNuri(doc);
|
||||
// An INSERT and nothing else. There is deliberately no delete anywhere in
|
||||
// this package, so no failure here can leave an index short of an entry.
|
||||
await docs.sparqlUpdate(sessionId, buildInsertTriple(graph, subject, predicate, value), graph);
|
||||
},
|
||||
|
||||
async openInbox(doc: NuriLike): Promise<void> {
|
||||
// Without this nobody can deposit: `postToDocument` resolves the document's
|
||||
// address and throws when its owner never opened one.
|
||||
await storeRegistry.openDocumentInbox(asNuri(doc));
|
||||
},
|
||||
|
||||
async depositTo(doc: NuriLike, payload: unknown): Promise<void> {
|
||||
// `postToDocument` is exactly "reach this document's owner", and is open to
|
||||
// anyone — which is what lets a stranger contribute to someone else's index.
|
||||
await inbox.postToDocument(asNuri(doc), { payload });
|
||||
},
|
||||
|
||||
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
|
||||
return inbox.readForDocument(asNuri(doc));
|
||||
},
|
||||
};
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* What this layer needs from NextGraph, and nothing more.
|
||||
*
|
||||
* Every member below is backed by published entries of `@ng-eventually/polyfill`
|
||||
* (see that package's `contract_polyfill-surface`). The mapping lives in
|
||||
* `polyfill-adapter.ts`; the rest of this package never imports the polyfill at
|
||||
* runtime, so the indexing rules stay testable against an in-memory port.
|
||||
*
|
||||
* The types are imported *from* the polyfill (type-only, erased at build) so they
|
||||
* are literally the published ones rather than a copy that could drift.
|
||||
*/
|
||||
import type { Nuri, NuriLike, PrincipalId, UnionSubject } from "@ng-eventually/polyfill";
|
||||
|
||||
export type { Nuri, NuriLike, PrincipalId, UnionSubject };
|
||||
|
||||
/** One deposit read out of a document's inbox. Mirrors the published `Deposit`. */
|
||||
export interface IncomingDeposit {
|
||||
/** The depositor, when identified; `null` for an anonymous deposit. */
|
||||
readonly from: PrincipalId | null;
|
||||
/** Opaque to the polyfill — this layer decodes it (see `deposit.ts`). */
|
||||
readonly payload: unknown;
|
||||
/** Deposit timestamp (ms epoch). */
|
||||
readonly ts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to "what is at this reference?" — TWO states, deliberately.
|
||||
*
|
||||
* There is no `absent`. An index never removes anything, so nothing this layer
|
||||
* does could ever hinge on telling "the object is gone" from "I could not read
|
||||
* it". Both mean the same thing here: *not added this time*. Collapsing them is
|
||||
* safe only because of that, and it is the point — the alternative would be a
|
||||
* label a broker failure could wear, and an index emptied one reference at a time
|
||||
* by a network problem.
|
||||
*
|
||||
* `unresolved` is still a failure, and the curator reports every one of them.
|
||||
*/
|
||||
export type ObjectResolution =
|
||||
/** The read answered, and there is something there. */
|
||||
| { readonly state: "present"; readonly subjects: readonly UnionSubject[] }
|
||||
/** Nothing usable came back — gone, unreadable, or the read failed. Not distinguished. */
|
||||
| { readonly state: "unresolved"; readonly reason: string };
|
||||
|
||||
/**
|
||||
* A port is bound to ONE identity: the polyfill's session is one user's, and no
|
||||
* call takes an identifier. Two users mean two ports.
|
||||
*/
|
||||
export interface NextGraphPort {
|
||||
/**
|
||||
* A new document in this identity's PUBLIC store, owned by this identity.
|
||||
* Backs onto `storeRegistry.createEntityDoc("public")`.
|
||||
*/
|
||||
createPublicDocument(): Promise<Nuri>;
|
||||
|
||||
/** Resolves a reference deposited into an index: the two-state answer above. */
|
||||
resolveObject(doc: NuriLike): Promise<ObjectResolution>;
|
||||
|
||||
/**
|
||||
* Every subject an index document holds. Backs onto `readUnion([doc])`.
|
||||
*
|
||||
* CAUTION — an empty result is NOT proof the document is empty, and this
|
||||
* interface does not pretend otherwise. `readUnion` swallows a failing document
|
||||
* into `[]` (its `readDoc` is `try {…} catch { return [] }`) and drops documents
|
||||
* whose cap this user does not hold, so "could not read" and "holds nothing"
|
||||
* arrive here indistinguishable. It may also reject outright.
|
||||
*
|
||||
* So a caller must never read `[]` as "a valid index that happens to be empty".
|
||||
* Nothing here does: `descriptorOf` refuses a document that declares no field —
|
||||
* which is exactly what an unreadable one looks like — and that refusal aborts
|
||||
* curation before a single write.
|
||||
*/
|
||||
readDocument(doc: NuriLike): Promise<readonly UnionSubject[]>;
|
||||
|
||||
/**
|
||||
* Adds ONE value for `predicate` on `subject`, inside `doc`. Only the
|
||||
* document's OWNER may — holding a read key never grants a write.
|
||||
*
|
||||
* There is no counterpart that removes, and no way to pass "no value": this
|
||||
* package must not be able to take anything out of an index, and making that
|
||||
* structural beats leaving it to whoever edits the curator next. Backs onto
|
||||
* `docs.sparqlUpdate` with an `INSERT DATA`.
|
||||
*/
|
||||
addLiteralProperty(
|
||||
doc: NuriLike,
|
||||
subject: string,
|
||||
predicate: string,
|
||||
value: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Opens an inbox on this document so that others have somewhere to deposit.
|
||||
* OWNER only, and REQUIRED: until it is called, `depositTo` throws because the
|
||||
* document has no address. Backs onto `storeRegistry.openDocumentInbox(doc)`.
|
||||
*
|
||||
* Returns nothing on purpose — an application names a document or a person,
|
||||
* never an inbox.
|
||||
*/
|
||||
openInbox(doc: NuriLike): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deposit into the document's inbox — i.e. reach its owner. Open to ANYONE;
|
||||
* that is what lets a stranger hand a reference to an index they do not own.
|
||||
* Backs onto `inbox.postToDocument(doc, { payload })`.
|
||||
*
|
||||
* Throws when the document's owner never opened an inbox: a deposit that
|
||||
* vanishes silently is worse than a refusal.
|
||||
*/
|
||||
depositTo(doc: NuriLike, payload: unknown): Promise<void>;
|
||||
|
||||
/**
|
||||
* The deposits made to this document's inbox, oldest first.
|
||||
*
|
||||
* THROWS for anyone but the document's owner — you may deposit into anyone's
|
||||
* inbox, you may only READ your own. Backs onto `inbox.readForDocument(doc)`.
|
||||
*/
|
||||
readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ObjectResolution, UnionSubject } from "./port";
|
||||
|
||||
/**
|
||||
* Why an empty read is `unresolved` and never `present`.
|
||||
*
|
||||
* `readUnion` cannot tell an absent object from one it failed to read, and does
|
||||
* not claim to: its per-document read is wrapped in `try {…} catch { return [] }`
|
||||
* (`readDoc`, in `@ng-eventually/polyfill`'s `src/surface/read-model.ts`), and
|
||||
* documents whose cap this user does not hold are dropped from the batch before
|
||||
* it reads at all. An empty result means absent OR unreadable OR failed, with
|
||||
* nothing to separate them.
|
||||
*
|
||||
* That is not a gap this package has to close, because nothing it does depends on
|
||||
* the answer: an index only ever grows, so every reading leads to the same act —
|
||||
* do not add, and say so.
|
||||
*
|
||||
* The rule lives here, apart from the I/O, for one reason: in the adapter it was
|
||||
* unreachable by any test, and dropping it there left the suite green while a
|
||||
* FAILED read got reported as `skipped: "no-field"` — a failure filed as a
|
||||
* property of the object. That is exactly the misclassification this layer exists
|
||||
* to avoid, so the rule is now a pure function with its own tests.
|
||||
*/
|
||||
export const EMPTY_READ =
|
||||
"the read came back empty — the object is absent, unreadable, or the read failed; " +
|
||||
"the polyfill does not distinguish them, and this layer does not need it to";
|
||||
|
||||
/** Turns what a read returned into the two-state answer the curator acts on. */
|
||||
export function resolutionFromRead(subjects: readonly UnionSubject[]): ObjectResolution {
|
||||
if (subjects.length === 0) return { state: "unresolved", reason: EMPTY_READ };
|
||||
return { state: "present", subjects };
|
||||
}
|
||||
|
||||
/** Turns a read that threw into the same two-state answer. Never `present`. */
|
||||
export function resolutionFromFailure(error: unknown): ObjectResolution {
|
||||
return { state: "unresolved", reason: String(error) };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The SPARQL this layer writes — and it writes only one kind of statement.
|
||||
*
|
||||
* There is NO delete builder, and there must not be one: an index never removes
|
||||
* an entry, and the surest way to keep that true is for this package to be unable
|
||||
* to express a removal at all.
|
||||
*
|
||||
* `@ng-eventually/polyfill` has escaping helpers of its own but does NOT publish
|
||||
* them — `contract_polyfill-surface` lists no `escapeIri` / `escapeLiteral` — so
|
||||
* this package carries its own rather than reaching into that package's internals.
|
||||
*
|
||||
* Pure string builders, on purpose: their exact output is asserted in the unit
|
||||
* tests, and they are executed against a broker only through `polyfillPort`.
|
||||
*/
|
||||
|
||||
/** Characters that would close an IRI written between angle brackets. */
|
||||
const IRI_DELIMITERS = new Set(['"', "<", ">", "\\", "^", "`", "{", "|", "}"]);
|
||||
|
||||
/** True when the character may not appear inside `<...>`: a control, a space, or a delimiter. */
|
||||
function breaksIri(character: string): boolean {
|
||||
const code = character.codePointAt(0);
|
||||
if (code === undefined) return false;
|
||||
return code <= 0x20 || code === 0x7f || IRI_DELIMITERS.has(character);
|
||||
}
|
||||
|
||||
/** True when the whole string can be written between angle brackets as-is. */
|
||||
export function isIriSafe(value: string): boolean {
|
||||
for (const character of value) {
|
||||
if (breaksIri(character)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Neutralises anything that could close a SPARQL string literal. */
|
||||
export function escapeLiteral(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\t/g, "\\t");
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encodes anything that could close an IRI written between angle
|
||||
* brackets. Printable non-ASCII is left alone — it is legal in an IRI, and
|
||||
* encoding it would corrupt it.
|
||||
*/
|
||||
export function escapeIri(value: string): string {
|
||||
let out = "";
|
||||
for (const character of value) {
|
||||
if (!breaksIri(character)) {
|
||||
out += character;
|
||||
continue;
|
||||
}
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
out += `%${code.toString(16).toUpperCase().padStart(2, "0")}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One triple, added to `graph`. The only statement this package ever writes. */
|
||||
export function buildInsertTriple(
|
||||
graph: string,
|
||||
subject: string,
|
||||
predicate: string,
|
||||
value: string,
|
||||
): string {
|
||||
return (
|
||||
`INSERT DATA { GRAPH <${escapeIri(graph)}> ` +
|
||||
`{ <${escapeIri(subject)}> <${escapeIri(predicate)}> "${escapeLiteral(value)}" } }`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The IRIs this layer writes into an index document.
|
||||
*
|
||||
* `urn:ng-eventually:` is RESERVED by the polyfill — triples whose subject falls
|
||||
* under it are dropped on read. This layer stays out of it entirely and uses its
|
||||
* own `urn:ng-helpers:` namespace, for PREDICATES only. Subjects in an index
|
||||
* document are always `did:ng:` NURIs, so nothing here is ever filtered away.
|
||||
*/
|
||||
|
||||
/**
|
||||
* On the index document's own subject: the predicate an indexed object must
|
||||
* carry, declared once by whoever creates the index.
|
||||
*/
|
||||
export const INDEX_FIELD = "urn:ng-helpers:index:field";
|
||||
|
||||
/** On an entry (subject = the indexed object's NURI): the value of that field. */
|
||||
export const ENTRY_VALUE = "urn:ng-helpers:index:value";
|
||||
Reference in New Issue
Block a user