feat: un dépôt est aiguillé sur son type, comme en amont, et l'index a son bras
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Applying an inbox deposit is a DISPATCH on what the deposit declares itself to be —
|
||||
* one arm per type, exactly as the target does it.
|
||||
*
|
||||
* ── The shape is upstream's, read in its source ───────────────────────────
|
||||
* `Verifier::process_inbox(msg, content)` is literally `match content { … }`, one arm per
|
||||
* `InboxMsgContent` variant, and ends `_ => return Err(VerifierError::NotImplemented)`
|
||||
* (`engine/verifier/src/inbox_processor.rs`). An arm is a real program, not a branch: the
|
||||
* `ContactDetails` arm validates the envelope (a missing `from_inbox` is
|
||||
* `InvalidInboxPost`, a hard failure and not a skip), builds its addresses from the typed
|
||||
* content, queries the recipient's OWN store to see whether the thing already exists
|
||||
* (a SPARQL `ASK`), decides idempotently (`ContactAlreadyExists` rather than a duplicate),
|
||||
* and only then writes. Validate, read, decide, write — that is what an arm owes.
|
||||
*
|
||||
* ── What each of ours is ──────────────────────────────────────────────────
|
||||
* - {@link LINK_KIND} — a deposit carrying a `ReadCap`. Applying it means holding it and
|
||||
* filing it durably (`branch-registers.addLink`, the emulated `AddLink { read_cap }`).
|
||||
* - a **bare reference** — a deposit that is a NURI and nothing else, applied by
|
||||
* `index-deposit.ts`. It carries no tag on the wire because the payload IS the
|
||||
* reference; upstream a `SocialQueryRequest` addresses its content the same way.
|
||||
* - anything else that declares itself in this package's own namespace has **no arm**,
|
||||
* and says so out loud — upstream's `NotImplemented`, which is an answer and not a
|
||||
* shrug.
|
||||
*
|
||||
* ── Consumer data is not a variant ────────────────────────────────────────
|
||||
* Upstream every message is typed and none is handed to an application; here a deposit
|
||||
* may carry an arbitrary payload the consumer alone interprets, which is a divergence
|
||||
* `surface/inbox.ts` already names. Such a payload declares no kind of ours, so no arm
|
||||
* claims it and nothing is reported about it — it is not an unapplied message, it is not
|
||||
* a message. That is why the reserved namespace is the discriminator and a bare `kind`
|
||||
* field is not: `{ kind: "join" }` is a consumer's word, and it stays one.
|
||||
*
|
||||
* Never exported from the package: an application must have no way to register an arm,
|
||||
* to aim the dispatch, or to turn one off.
|
||||
*/
|
||||
|
||||
import { addLink } from "./branch-registers";
|
||||
import { applyIndexDeposit } from "./index-deposit";
|
||||
import { getCaps } from "../shared-wallet/bootstrap";
|
||||
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log";
|
||||
import { hasReadCap, isNuri } from "../model/nuri";
|
||||
import type { Nuri, PrincipalId, ReadCap } from "../model/types";
|
||||
|
||||
/**
|
||||
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it is the
|
||||
* same one at all three stages: `InboxMsgContent::Link` is the message
|
||||
* (`engine/net/src/types.rs`, declared but payload-less so far), `AddLink { read_cap }` is
|
||||
* where the recipient files it (`engine/repo/src/types.rs`), `RemoveLink` withdraws it.
|
||||
* So giving access is: deposit a Link, and the recipient's processing files it.
|
||||
*
|
||||
* It travels the SAME channel as any other deposit, which is why key ROTATION needs no
|
||||
* special case on the surface — a re-delivered cap is just another Link.
|
||||
*/
|
||||
const LINK_KIND = "urn:ng-eventually:inbox:link";
|
||||
|
||||
/**
|
||||
* The namespace every kind this package declares lives under — and the discriminator.
|
||||
*
|
||||
* `urn:ng-eventually:` is this library's reserved namespace; a consumer's own payload
|
||||
* never lands in it, so "does this deposit declare itself to be one of ours?" has an
|
||||
* answer that no application can accidentally give. Keying on the mere PRESENCE of a
|
||||
* `kind` field would have claimed `{ kind: "join" }` — a consumer's word for a consumer's
|
||||
* message.
|
||||
*/
|
||||
const DECLARED_NAMESPACE = "urn:ng-eventually:inbox:";
|
||||
|
||||
/**
|
||||
* The bare-reference variant. It is a tag this module synthesizes, never one that travels:
|
||||
* the payload of such a deposit is the reference itself, with no envelope at all (see
|
||||
* `index-deposit.ts` for why it carries nothing else). Naming it here is what lets the
|
||||
* match below have one arm per type instead of a shape test in the middle of it.
|
||||
*/
|
||||
const REFERENCE_KIND = "urn:ng-eventually:inbox:reference";
|
||||
|
||||
/** WHOSE inbox is being applied, and by whom — every arm acts for this identity alone. */
|
||||
export interface Applying {
|
||||
/** The inbox's owner. Every act an arm performs is done for them and for nobody else. */
|
||||
readonly owner: PrincipalId;
|
||||
/** The cap-registry ring of {@link owner} — `accountKey(owner)`. One rule keys it for
|
||||
* both cases: the owner's own session, and a drain running under someone else's. */
|
||||
readonly ring: string;
|
||||
/** The inbox the deposit was found in. Upstream the address IS the identification of
|
||||
* the recipient, so no arm needs the deposit to name a target. */
|
||||
readonly inbox: Nuri;
|
||||
/**
|
||||
* Whether {@link owner} is ALSO the identity holding the session.
|
||||
*
|
||||
* It decides what an arm may do, and the line is not arbitrary. A drain running under
|
||||
* SOMEONE ELSE's session (the deferred stand-in for an absent owner,
|
||||
* `emulated-verifier/inbox-processor.ts`) usurps the owner's identity, and it does so
|
||||
* for the verifier's own bookkeeping only — a register write on the owner's User
|
||||
* branch. Writing APPLICATION data into the owner's documents from a stranger's page is
|
||||
* a different act, and this package does not take it: the deposit is not consumed, so
|
||||
* it stays for the owner's own next connection, which is the real path.
|
||||
*/
|
||||
readonly ownerHoldsTheSession: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this deposit DECLARES itself to be — `null` when it declares nothing of ours.
|
||||
*
|
||||
* The one place the wire form is turned into a variant, so `surface/inbox.ts` and the
|
||||
* dispatch below cannot come to disagree about what counts as this package's business.
|
||||
*/
|
||||
// @provenance kindOf kind=aligned level=1 ref=engine/net/src/types.rs:InboxMsgContent — upstream the VARIANT is the type and nothing else can be sent; here it is read off a JSON payload, because a deposit carries an arbitrary one
|
||||
export function kindOf(payload: unknown): string | null {
|
||||
if (typeof payload === "string") return isNuri(payload) ? REFERENCE_KIND : null;
|
||||
if (payload === null || typeof payload !== "object") return null;
|
||||
const kind: unknown = (payload as { kind?: unknown }).kind;
|
||||
return typeof kind === "string" && kind.startsWith(DECLARED_NAMESPACE) ? kind : null;
|
||||
}
|
||||
|
||||
/** The cap a Link deposit carries, or `null` when it carries none that reads. */
|
||||
// @provenance capOfPayload kind=declared-not-wired level=1 ref=engine/net/src/types.rs:InboxMsgContent — the `Link` variant is DECLARED and carries nothing, so the cap this reads has no upstream field yet
|
||||
export function capOfPayload(payload: unknown): ReadCap | null {
|
||||
const p = payload as { kind?: unknown; cap?: unknown } | null;
|
||||
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
|
||||
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
|
||||
}
|
||||
|
||||
/** The payload of a Link deposit carrying `cap` — the one place `share` composes one. */
|
||||
// @provenance linkPayload kind=declared-not-wired level=1 ref=engine/net/src/types.rs:InboxMsgContent — the composing half of the same declared-and-empty variant
|
||||
export function linkPayload(cap: ReadCap): { kind: string; cap: ReadCap } {
|
||||
return { kind: LINK_KIND, cap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an arm's refusal goes: the package's own log stream, NOT gated by the access-log
|
||||
* flag. A diagnostic may be opt-in; a message that could not be applied may not.
|
||||
*
|
||||
* It does not throw, and that is the rule this package already runs on: one unapplicable
|
||||
* item is reported and denies nobody their session, nor the OTHER deposits their turn. A
|
||||
* genuine I/O failure is a different thing and still propagates — the caller reports the
|
||||
* whole inbox and moves to the next one.
|
||||
*/
|
||||
function report(ctx: Applying, why: string): void {
|
||||
console.error(
|
||||
accessLogPrefix() + " could not apply a deposit in " + shortNuri(ctx.inbox) +
|
||||
" — it stays in it and the next attempt will try again: " + why,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply ONE deposit for the owner of its inbox. Answers whether it produced an effect —
|
||||
* which is what a drain counts, and all any caller has ever needed from it.
|
||||
*
|
||||
* Nothing is consumed here, by any arm: an inbox stays re-readable, so every abandon and
|
||||
* every refusal above is harmless — what was not applied is still there for the next push
|
||||
* or the next connection.
|
||||
*/
|
||||
// @provenance applyDeposit kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — the same `match` over the declared content, one arm per variant, `NotImplemented` for the rest
|
||||
export async function applyDeposit(payload: unknown, ctx: Applying): Promise<boolean> {
|
||||
const kind = kindOf(payload);
|
||||
switch (kind) {
|
||||
case null:
|
||||
// Consumer data. No arm claims it, and nothing is reported: it is not an unapplied
|
||||
// message, it is not a message.
|
||||
return false;
|
||||
case LINK_KIND:
|
||||
return applyLink(payload, ctx);
|
||||
case REFERENCE_KIND:
|
||||
return applyReference(payload as Nuri, ctx);
|
||||
default:
|
||||
// Upstream's `_ => Err(NotImplemented)`. A kind in this package's own namespace that
|
||||
// no arm answers is a version skew, not consumer data — and until 2026-08-21 it was
|
||||
// read, seen, and produced nothing at all: no effect and no reported failure, which
|
||||
// is the "failure disguised as an absence" defect this package has closed repeatedly.
|
||||
report(ctx, `no arm applies deposits of kind ${JSON.stringify(kind)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The **Link** arm: validate the envelope, hold the cap, file it durably.
|
||||
*
|
||||
* `learnFor` before `addLink`, and both NAMED rather than resolved: the ring is the one
|
||||
* the caller decided for when its guard ran, not whoever happens to be connected when the
|
||||
* `await` resumes. In the owner's own session the two are the same ring and the first act
|
||||
* is a no-op — `read` already learned it in memory; only this makes it durable.
|
||||
*
|
||||
* A payload that declares itself a Link and carries no readable cap is REFUSED rather than
|
||||
* passed over. That is the envelope validation upstream's arms open with (`from_inbox`
|
||||
* missing is `InvalidInboxPost`, a hard failure), and passing it over would have been the
|
||||
* same silence the `_` arm above exists to end.
|
||||
*/
|
||||
async function applyLink(payload: unknown, ctx: Applying): Promise<boolean> {
|
||||
const cap = capOfPayload(payload);
|
||||
if (cap === null) {
|
||||
report(ctx, "a deposit declares itself a Link and carries no readable cap");
|
||||
return false;
|
||||
}
|
||||
getCaps().learnFor(ctx.ring, cap);
|
||||
await addLink(cap, ctx.owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The **reference** arm: resolve it and look — see `index-deposit.ts` for the whole of it.
|
||||
*
|
||||
* Only in the owner's OWN session, for the reason {@link Applying.ownerHoldsTheSession}
|
||||
* gives: an entry is application data in the owner's document, and a drain running under a
|
||||
* stranger's session writes the verifier's registers and nothing else. Left waiting is not
|
||||
* left silent, and it is not a failure either — the deposit is not consumed, so the owner's
|
||||
* next connection applies it, which is the path this package documents.
|
||||
*/
|
||||
async function applyReference(reference: Nuri, ctx: Applying): Promise<boolean> {
|
||||
if (!ctx.ownerHoldsTheSession) {
|
||||
logStage(
|
||||
"WAITING " + shortNuri(ctx.inbox) + " — a reference deposit is applied by its own " +
|
||||
"owner's session; it stays for " + ctx.owner,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const application = await applyIndexDeposit(reference, ctx.inbox);
|
||||
switch (application.outcome) {
|
||||
case "applied":
|
||||
logStage(
|
||||
"INDEXED " + shortNuri(application.object) + " in the index behind " +
|
||||
shortNuri(ctx.inbox) + " under " + JSON.stringify(application.value),
|
||||
);
|
||||
return true;
|
||||
case "unchanged":
|
||||
return false;
|
||||
case "skipped":
|
||||
// A property of the OBJECT, not a failure: it resolved, and there is nothing to
|
||||
// index it by. Traced, not reported — see `index-deposit.ts`.
|
||||
logStage(
|
||||
"NOT INDEXED " + shortNuri(application.object) + " — " + application.reason,
|
||||
);
|
||||
return false;
|
||||
case "refused":
|
||||
report(ctx, application.why);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* The INDEX arm of the inbox dispatch — what this package does with a deposit that is a
|
||||
* bare reference.
|
||||
*
|
||||
* ── The variant, and why it carries nothing else ──────────────────────────
|
||||
* A reference deposit is a NURI and nothing more: no operation, no claim, no copy of any
|
||||
* value. That is the shape upstream already uses — a `SocialQueryRequest` carries
|
||||
* `definition_commit_body_ref`, a REFERENCE, and the recipient's `inbox_processor`
|
||||
* composes its own SPARQL from what it resolves (`engine/verifier/src/inbox_processor.rs`).
|
||||
* It carries no target document either, for the reason `surface/inbox.ts` gives at length:
|
||||
* an inbox belongs to exactly one repo, so the ADDRESS identifies the recipient.
|
||||
*
|
||||
* Since anyone may deposit into any inbox, a payload that carried an operation would be a
|
||||
* licence to rewrite someone else's document, and a payload that carried a value would let
|
||||
* a depositor put in the index something the object does not say. So the arm does not act
|
||||
* on a claim: it resolves the reference and LOOKS. What the object says is what is written.
|
||||
*
|
||||
* ── An index document is an ORDINARY document ─────────────────────────────
|
||||
* Nothing marks one. What makes a document an index is that it declares, on its own NURI
|
||||
* as subject, the FIELD it indexes by ({@link INDEX_FIELD}); each entry is then the
|
||||
* indexed object's NURI as subject, carrying that object's value for the field
|
||||
* ({@link ENTRY_VALUE}). The declaration lives IN the document on purpose: two
|
||||
* applications pointing at the same index would otherwise curate it on two different
|
||||
* fields and corrupt each other's entries.
|
||||
*
|
||||
* ── AN INDEX ONLY EVER GROWS ──────────────────────────────────────────────
|
||||
* Nothing here removes an entry, and nothing here can express a removal. That is what
|
||||
* makes the failure story trivial: since the only write is an addition, a read that comes
|
||||
* back empty — object gone, unreadable, or the broker silent — can only ever mean "not
|
||||
* added this time", and a later deposit adds it. Nothing has to tell an absence from a
|
||||
* failure, so nothing can get that wrong. The price, stated plainly because someone will
|
||||
* need it: an entry cannot be taken out of an index, by anyone, including its owner.
|
||||
*
|
||||
* Harmless is not the same as invisible: every reference that could not be resolved is
|
||||
* REPORTED, because a failure that looks exactly like a normal outcome teaches nobody
|
||||
* anything.
|
||||
*
|
||||
* ── The IRIs are another package's, deliberately unchanged ────────────────
|
||||
* `urn:ng-helpers:index:field` / `:value` are the IRIs `@ng-helpers/indexing` already
|
||||
* writes, and they are kept letter for letter: renaming them would be a data-format change
|
||||
* that no one decided, and every index already written would stop being read. It IS odd
|
||||
* for this package to write another package's namespace — that oddity is flagged, not
|
||||
* acted on.
|
||||
*/
|
||||
|
||||
import { readUnion } from "../surface/read-model";
|
||||
import { sparqlUpdate } from "../surface/docs";
|
||||
import { readInboxCapPairs } from "./branch-registers";
|
||||
import { session } from "../shared-wallet/account-registry";
|
||||
import { assertNuri, escapeLiteral } from "../surface/sparql";
|
||||
import type { UnionSubject } from "../surface/read-model";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* On the index document's own subject: the predicate an indexed object must carry,
|
||||
* declared once by whoever created the index.
|
||||
*/
|
||||
// @provenance INDEX_FIELD kind=invention level=none ref=none — an index is an ordinary document upstream, and nothing there declares what a document indexes by
|
||||
export const INDEX_FIELD = "urn:ng-helpers:index:field";
|
||||
|
||||
/** On an entry (subject = the indexed object's NURI): that object's value for the field. */
|
||||
// @provenance ENTRY_VALUE kind=invention level=none ref=none — same: the entry shape is ours
|
||||
export const ENTRY_VALUE = "urn:ng-helpers:index:value";
|
||||
|
||||
/** What applying ONE reference deposit came to. */
|
||||
export type IndexApplication =
|
||||
/** Resolved, and now in the index under the value the object holds. */
|
||||
| { readonly outcome: "applied"; readonly object: Nuri; readonly value: string }
|
||||
/** Resolved, and already in the index. Nothing was read again and nothing written. */
|
||||
| { readonly outcome: "unchanged"; readonly object: Nuri }
|
||||
/** Resolved, and deliberately not added — a property of the OBJECT, not a failure. */
|
||||
| { readonly outcome: "skipped"; readonly object: Nuri; readonly reason: string }
|
||||
/** Nothing was written, and something is wrong enough to say out loud. */
|
||||
| { readonly outcome: "refused"; readonly why: string };
|
||||
|
||||
/**
|
||||
* The values a subject carries for a predicate.
|
||||
*
|
||||
* OWN properties only. `props` arrives as a plain object literal (`readUnion` builds
|
||||
* `const props: Record<string, string[]> = {}`), so it inherits from `Object.prototype`:
|
||||
* an index declaring `constructor`, `toString` or `valueOf` as its field would otherwise
|
||||
* find a FUNCTION where a list of values belongs, and spreading it throws. The non-string
|
||||
* filter is the same caution — this data crossed a process boundary, and a value that is
|
||||
* not a string has no business being compared or written as a literal.
|
||||
*
|
||||
* The two guards are deliberately redundant; 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");
|
||||
}
|
||||
|
||||
/** Every field the document declares for itself — zero, one, or (wrongly) several. */
|
||||
export function declaredFields(
|
||||
subjects: readonly UnionSubject[],
|
||||
index: Nuri,
|
||||
): readonly string[] {
|
||||
return valuesOf(
|
||||
subjects.find((s) => s.subject === index),
|
||||
INDEX_FIELD,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The objects already in the index — every subject that is NOT the document itself and
|
||||
* carries at least one value for {@link ENTRY_VALUE}.
|
||||
*
|
||||
* At least one, never exactly one. Requiring exactly one was a hole big enough to drive
|
||||
* the "only ever grows" guarantee through: two values for one object — which two curating
|
||||
* sessions racing each other produce, since each only ever ADDS — made the entry read as
|
||||
* ABSENT, so the index could SHRINK through nothing but additions.
|
||||
*/
|
||||
export function indexedObjects(
|
||||
subjects: readonly UnionSubject[],
|
||||
index: Nuri,
|
||||
): ReadonlySet<string> {
|
||||
const out = new Set<string>();
|
||||
for (const subject of subjects) {
|
||||
if (subject.subject === index) continue;
|
||||
if (valuesOf(subject, ENTRY_VALUE).length > 0) out.add(subject.subject);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH document this inbox belongs to — the reverse of `openDocumentInbox`'s pairing.
|
||||
*
|
||||
* Read off the owner's own User branch (the emulated `AddInboxCap`, which pairs the
|
||||
* document with the inbox opened on it), so the answer is the owner's own record and not
|
||||
* something the deposit claimed. `null` when no document is paired with this inbox, which
|
||||
* is the ordinary case for a PERSON's inbox: those belong to a user, not to a document.
|
||||
*/
|
||||
async function documentAddressedBy(inbox: Nuri): Promise<Nuri | null> {
|
||||
const pairs = await readInboxCapPairs();
|
||||
return pairs.find((p) => p.inbox === inbox)?.doc ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one reference deposit, for the owner of `inbox`.
|
||||
*
|
||||
* Resolves and looks, in this order: which document the inbox belongs to, what that
|
||||
* document declares it indexes by, whether the reference is already in, what the
|
||||
* referenced object says for the field — and only then writes ONE entry.
|
||||
*
|
||||
* Never removes anything and never rewrites an entry, so re-applying the same deposit —
|
||||
* which happens on every drain, because deposits are not consumed — lands on the same
|
||||
* result. Curation is convergent, and its outcome does not depend on the order references
|
||||
* arrive in.
|
||||
*/
|
||||
// @provenance applyIndexDeposit kind=invention level=none ref=none — nothing upstream turns a deposited reference into an index entry; the whole arm is this library's own
|
||||
export async function applyIndexDeposit(
|
||||
reference: Nuri,
|
||||
inbox: Nuri,
|
||||
): Promise<IndexApplication> {
|
||||
const index = await documentAddressedBy(inbox);
|
||||
if (index === null) {
|
||||
return {
|
||||
outcome: "refused",
|
||||
why:
|
||||
"a bare reference is a deposit for an INDEX document, and this inbox is not a " +
|
||||
`document's — nothing here says what to index it by: ${JSON.stringify(inbox)}`,
|
||||
};
|
||||
}
|
||||
if (reference === index) {
|
||||
return { outcome: "skipped", object: reference, reason: "self-reference" };
|
||||
}
|
||||
|
||||
const subjects = await readUnion([index]);
|
||||
const declared = declaredFields(subjects, index);
|
||||
if (declared.length > 1) {
|
||||
// Ambiguity refuses to curate and leaves READING alone. Picking one would silently
|
||||
// corrupt the index: entries already written are never re-read, so they keep their
|
||||
// old field's values while new ones arrive under the new field, and one list ends up
|
||||
// ordered by two different properties. A quiet wrong answer is worse than a loud
|
||||
// refusal — and this one is permanent, because nothing here deletes.
|
||||
return {
|
||||
outcome: "refused",
|
||||
why:
|
||||
`${index} declares ${declared.length} index fields ` +
|
||||
`(${declared.map((f) => JSON.stringify(f)).join(", ")}) — refusing to curate rather ` +
|
||||
"than pick one. Entries already in it stay readable. This cannot be undone " +
|
||||
"(nothing here deletes): curate into a fresh index.",
|
||||
};
|
||||
}
|
||||
const field = declared[0];
|
||||
if (field === undefined) {
|
||||
return {
|
||||
outcome: "refused",
|
||||
why:
|
||||
`${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.",
|
||||
};
|
||||
}
|
||||
|
||||
if (indexedObjects(subjects, index).has(reference)) {
|
||||
// Already there. Nothing is re-read and nothing is rewritten — an entry, once made,
|
||||
// is never touched again.
|
||||
return { outcome: "unchanged", object: reference };
|
||||
}
|
||||
|
||||
let resolved: readonly UnionSubject[];
|
||||
try {
|
||||
resolved = await readUnion([reference]);
|
||||
} catch (error) {
|
||||
return { outcome: "refused", why: `${reference}: ${String(error)}` };
|
||||
}
|
||||
if (resolved.length === 0) {
|
||||
// ABSENT, unreadable, or the read failed — and this layer does not tell them apart,
|
||||
// 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.
|
||||
return {
|
||||
outcome: "refused",
|
||||
why:
|
||||
`${reference}: the read came back empty — the object is absent, unreadable, or the ` +
|
||||
"read failed; they are not distinguished here, and a later deposit adds it",
|
||||
};
|
||||
}
|
||||
|
||||
const values = resolved.flatMap((s) => [...valuesOf(s, field)]);
|
||||
const value = values[0];
|
||||
if (value === undefined) {
|
||||
// NARROW, 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 in the index something the object does not say.
|
||||
return { outcome: "skipped", object: reference, reason: `carries no ${field}` };
|
||||
}
|
||||
if (values.length > 1) {
|
||||
return {
|
||||
outcome: "skipped",
|
||||
object: reference,
|
||||
reason: `carries ${values.length} values for ${field} — which one would the entry hold?`,
|
||||
};
|
||||
}
|
||||
|
||||
const s = await session();
|
||||
// An INSERT and nothing else. The document is named ONCE, as the anchor: `sparqlUpdate`
|
||||
// scopes the write to that repo's default graph, so the statement carries no `GRAPH <…>`
|
||||
// wrapper — the canonical shape `surface/inbox.ts` explains beside its own write.
|
||||
await sparqlUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${assertNuri(reference)}> <${ENTRY_VALUE}> "${escapeLiteral(value)}" }`,
|
||||
index,
|
||||
"indexEntry",
|
||||
);
|
||||
return { outcome: "applied", object: reference, value };
|
||||
}
|
||||
Reference in New Issue
Block a user