diff --git a/README.md b/README.md index 7d65b0c..4096a97 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ That boundary is held by one file. `src/polyfill-adapter.ts` is the only place t ## An index only ever grows -**Nothing is ever removed from an index — by anyone, including its owner.** This is a deliberate limitation, written down here rather than left to be discovered. +**Nothing is ever removed from an index — by anyone, including its owner.** An index only ever grows. There is no removal function, and there never was one: removal was deliberately **never built**, not built and then withdrawn, and nothing is planned. This is written down here rather than left to be deduced from a missing function, because someone who needs an entry gone should learn that it was never possible instead of hunting for the call that does it. Today the only answer to that need is a fresh index. It is what makes the failure story trivial. Since the only write is an addition, a reference that does not resolve — 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. @@ -45,6 +45,8 @@ What holds now: entries take **at least one value, smallest wins**, deterministi The lesson is worth keeping: **"nothing removes" is a claim about the write path, and an invariant about what a reader can *see* has to be checked on the read path too.** +What enforces the write half is no longer a pattern over source. `test/adapter.test.ts` runs the real adapter on `test/fake-polyfill.ts`, an in-memory polyfill whose SPARQL is **executed** by an engine that understands one statement — an anchored `INSERT DATA` of literal triples — and refuses everything else. A removal is therefore not *detected*, it is **unrunnable**: `DELETE WHERE …` fails on the first keyword, a second statement smuggled after the closing brace fails on the trailing text, a keyword hidden inside a literal stays inside the literal because a parser tokenises where a regex only matches, and splitting the keyword across concatenated strings buys nothing, since it is one string by the time it arrives. The regex over `src/` in `test/units.test.ts` stays as a cheap tripwire that names the file early; it is not the proof. + **A failed resolve is still a failure, and still surfaces.** Harmless is not the same as invisible. Every reference that could not be resolved comes back as an `unresolved` outcome in the curation report and is warned about — a failure that looks exactly like a normal outcome teaches nobody anything. ## Open questions @@ -53,8 +55,10 @@ Deliberately not settled. Each is implemented in its narrowest form and reported - **An object that carries nothing for the index's field.** Narrow behaviour: it is not added, and reported as `skipped: "no-field"`. There is no key to index it by, and inventing one — a placeholder, the deposit's timestamp — would put something in the index that the object does not say. Whether it should instead be indexed under an absent key, or refused louder, is open. - **An object that carries several values for the field.** Not added, reported as `skipped: "several-values"`. Which of them the entry would hold has not been decided. -- **An entry never changes after it is made.** An already-indexed object is not even re-read, so an object whose field value changes later keeps its original value in the index. Refreshing it would be a write nobody asked for, and it is the same question as removal. +- **An already-indexed object is never re-read.** Curation skips it outright, so an object whose field value changes later keeps its original value in the index. Refreshing it would be a write nobody asked for, and it is the same question as removal. - **Which value a raced entry should keep.** Two curation runs racing each other can leave an entry with two values; the smallest is chosen so that readers agree and the entry stays visible. That the entry must survive is settled; *which* of the two it should hold is not. + + Note what this means, since the two points above are easy to read as one: an entry's value **can** change after it is made. Not by re-reading the object — that never happens — but because a *second* value arriving for the same entry can be the smaller one, and `read()` returns the smallest. Index an object at `2026-05-05`, let a raced run add `2026-01-01`, and `read()` answers `2026-01-01`. What never changes is the set of entries and the fact that each stays visible; the value one of them reports is settled by "smallest wins", not by arrival order. - **How an index recovers from an ambiguous declaration.** Today it does not: curation refuses for good and the only way forward is a fresh index. Since nothing here removes anything, giving it a way back needs a mechanism that does not exist yet. - **Deposits are never retired.** Every curation run sees every deposit ever made. That is affordable because re-applying one is a no-op, but it is linear in the history. How a curator retires an applied deposit is open — `inbox.processInbox` may be the answer, but its semantics are not published. - **What an entry holds besides the object reference and the field value**, and **how several index kinds would coexist**, are both untouched. @@ -70,8 +74,9 @@ Deliberately not settled. Each is implemented in its narrowest form and reported | `src/curator.ts` | Resolving references and adding what is there | | `src/indexing.ts` | The public surface, bound to one identity | | `src/sparql.ts` | The one statement this package writes — no deletion exists | -| `test/fake-nextgraph.ts` | An in-memory NextGraph enforcing the polyfill's published guarantees | -| `test/adapter-write-path.test.ts` | Runs the real adapter against a recorder and reads back every query it emits | +| `test/fake-nextgraph.ts` | An in-memory NextGraph behind `NextGraphPort`, enforcing the polyfill's published guarantees | +| `test/fake-polyfill.ts` | An in-memory `@ng-eventually/polyfill` that **executes** the SPARQL — an engine that can only add | +| `test/adapter.test.ts` | Runs the real adapter on it: behaviour, every query emitted, and every method driven | ## Depends on diff --git a/src/polyfill-adapter.ts b/src/polyfill-adapter.ts index 81069b0..0e5e219 100644 --- a/src/polyfill-adapter.ts +++ b/src/polyfill-adapter.ts @@ -69,7 +69,12 @@ export function polyfillPort(options: PolyfillPortOptions): NextGraphPort { 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); + // + // The document is named ONCE, as the anchor: `sparqlUpdate(sid, update, anchor)` + // scopes the write to that repo's default graph, so the statement carries no + // `GRAPH <…>` wrapper. That is the polyfill's own canonical shape — see + // `buildInsertTriple`, which explains why this layer writes it too. + await docs.sparqlUpdate(sessionId, buildInsertTriple(subject, predicate, value), graph); }, async openInbox(doc: NuriLike): Promise { diff --git a/src/sparql.ts b/src/sparql.ts index 6e3bf04..79045c4 100644 --- a/src/sparql.ts +++ b/src/sparql.ts @@ -59,15 +59,26 @@ export function escapeIri(value: string): string { 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 { +/** + * One triple, for the ANCHORED default graph. The only statement this package + * ever writes. + * + * No `GRAPH <…>` wrapper, and the document is not a parameter at all: it is named + * once, as `docs.sparqlUpdate`'s anchor, which already scopes the write to that + * repo's default graph. Naming it twice would be two chances to disagree. + * + * This is the polyfill's own shape, and the alignment is deliberate: it calls the + * no-GRAPH form "the CANONICAL, always-safe shape and the one the anchored + * default-graph read queries" (`src/surface/inbox.ts`). The anchored + * `GRAPH ` form this package wrote until now ALSO round-trips — the + * polyfill's own e2e harness verified it resolves to the same repo graph, with no + * phantom graph — so this is not a bug being fixed. It is two layers writing the + * same kind of data the same way, instead of leaving someone to work out later + * whether the difference meant something. + */ +export function buildInsertTriple(subject: string, predicate: string, value: string): string { return ( - `INSERT DATA { GRAPH <${escapeIri(graph)}> ` + - `{ <${escapeIri(subject)}> <${escapeIri(predicate)}> "${escapeLiteral(value)}" } }` + `INSERT DATA { <${escapeIri(subject)}> <${escapeIri(predicate)}> ` + + `"${escapeLiteral(value)}" }` ); } diff --git a/test/adapter-write-path.test.ts b/test/adapter-write-path.test.ts deleted file mode 100644 index 3640c2b..0000000 --- a/test/adapter-write-path.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -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>) => Promise) { - 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 " + - '{ "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 ; INSERT DATA { GRAPH { "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 ")).toBe(true); -}); diff --git a/test/adapter.test.ts b/test/adapter.test.ts new file mode 100644 index 0000000..a8a13c8 --- /dev/null +++ b/test/adapter.test.ts @@ -0,0 +1,267 @@ +import { expect, test } from "bun:test"; +import { indexing } from "../src/indexing"; +import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary"; +import type { NextGraphPort, Nuri } from "../src/port"; +import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill"; + +/** + * The REAL `polyfill-adapter.ts`, running. + * + * ## What was wrong with the gate this replaces + * + * It mocked the polyfill to constants and drove exactly TWO of the adapter's seven + * methods — `addLiteralProperty` and (through `createIndex`) `createPublicDocument` + * and `openInbox`. Everything else was inert. Six methods could be gutted — write + * nothing, return `[]` — with the suite still green, and the gate's own promise + * ("every query it emits is read back") held only for the queries those two methods + * emitted. A destructive statement planted in `readDeposits` was never recorded, + * because `readDeposits` was never called. That matters most exactly there: + * retiring an applied deposit — the README's own open question — lands in + * `readDeposits`. + * + * ## What holds now + * + * The adapter runs on `fake-polyfill.ts`, an in-memory polyfill that ANSWERS rather + * than returning constants, so the tests below are ordinary behavioural tests that + * happen to run through the real wiring. Gut any of the seven and something here + * goes red, because each one is now load-bearing for an outcome that is asserted. + * + * Three properties are checked on top of behaviour, and each closes a hole the + * review walked through: + * + * - **the fake EXECUTES the SPARQL** (`applyInsertData`) — an engine that can only + * add, so a removal is unrunnable rather than "detected". This is what covers a + * query site in a method nobody thought to inspect: whatever the query, it has to + * go through the engine to have any effect at all, and a split literal is one + * string by the time it gets there; + * - **an unmodelled polyfill entry throws by name** — `docs.sparqlQuery` included, + * so routing a statement through the read door is red, not silent; + * - **the last test fails when a method is added without a driver** — it reads the + * adapter's own keys, so there is no list to keep up to date. + */ + +const world = installFakePolyfill(); + +const FIELD = "http://schema.org/datePublished"; + +/** Every adapter method actually CALLED by the tests below. Read by the last test. */ +const driven = new Set(); + +function track(port: NextGraphPort): NextGraphPort { + return new Proxy(port, { + get(target, property, receiver) { + const value: unknown = Reflect.get(target, property, receiver); + if (typeof property !== "string" || typeof value !== "function") return value; + return (...args: unknown[]) => { + driven.add(property); + return (value as (...a: unknown[]) => unknown).apply(target, args); + }; + }, + }); +} + +/** + * Everything inside runs under `user`'s session. + * + * A polyfill session IS one identity, so this is not test sugar: it is the only + * shape the real thing has. Each actor gets its OWN port and no actor is handed a + * value another one computed, except the index NURI — which is exactly the value + * the README says an application hardcodes in its own source, and the only thing + * that legitimately travels. + */ +async function as(user: string, run: (port: NextGraphPort) => Promise): Promise { + world.signIn(user); + const { polyfillPort } = await import("../src/polyfill-adapter"); + return run(track(polyfillPort({ sessionId: world.sessionId() }))); +} + +/** A public document carrying one value for `field`, published by whoever is signed in. */ +async function publish(port: NextGraphPort, field: string, value: string): Promise { + const object = await port.createPublicDocument(); + await port.addLiteralProperty(object, object, field, value); + return object; +} + +// --- the whole loop, through the real adapter ----------------------------- + +test("the real adapter carries the whole loop: create, publish, refer, curate, read", async () => { + const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); + + const article = await as("bob", async (bob) => { + const object = await publish(bob, FIELD, "2026-07-08"); + await indexing(bob).refer(index, object); + return object; + }); + + const report = await as("alice", (alice) => indexing(alice).curate(index)); + expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]); + + const entries = await as("alice", (alice) => indexing(alice).read(index)); + expect(entries).toEqual([{ object: article, value: "2026-07-08" }]); +}); + +test("two indexes are two documents, each owned by whoever created it", async () => { + const [first, second] = await as("alice", async (alice) => [ + await indexing(alice).createIndex(FIELD), + await indexing(alice).createIndex(FIELD), + ]); + expect(first).not.toBe(second); + + // A stranger holding the NURI still cannot write it — reaching is not owning. + await expect( + as("bob", (bob) => bob.addLiteralProperty(first, first, ENTRY_VALUE, "2026-01-01")), + ).rejects.toThrow(/only a document's owner writes to it/); +}); + +// --- the two answers a resolve may give, and why they must stay apart ----- + +test("a reference that could not be READ comes back unresolved", async () => { + const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); + const article = await as("bob", async (bob) => { + const object = await publish(bob, FIELD, "2026-07-08"); + await indexing(bob).refer(index, object); + return object; + }); + + // `readDoc` catches and yields `[]`, so a failed read arrives looking exactly + // like an object that holds nothing. Telling them apart is not possible; filing + // the failure as a FACT about the object is what must not happen. + world.breakReadsOf(article, "broker unreachable"); + try { + const report = await as("alice", (alice) => indexing(alice).curate(index)); + expect(report.outcomes).toEqual([ + { result: "unresolved", object: article, reason: expect.stringContaining("absent") }, + ]); + } finally { + world.healReadsOf(article); + } +}); + +test("an object that really carries nothing for the field is SKIPPED — a different answer", async () => { + const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); + const unrelated = await as("bob", async (bob) => { + const object = await publish(bob, "http://schema.org/name", "Anemone"); + await indexing(bob).refer(index, object); + return object; + }); + + // Paired with the test above ON PURPOSE. `resolveObject` is the only thing + // keeping these two apart: replace `resolutionFromRead(subjects)` with + // `{ state: "present", subjects }` and the unreadable object above is reported + // here's answer instead — a broker failure filed as a property of the object. + const report = await as("alice", (alice) => indexing(alice).curate(index)); + expect(report.outcomes).toEqual([ + { result: "skipped", object: unrelated, reason: "no-field" }, + ]); +}); + +test("an EMPTY read is unresolved, straight off the adapter method", async () => { + // The same rule as the pair above, asserted directly rather than through a + // curation report, so the wiring of `resolutionFromRead` is held by two + // independent tests and not by one. + const blank = await as("alice", (alice) => alice.createPublicDocument()); + const resolution = await as("alice", (alice) => alice.resolveObject(blank)); + expect(resolution.state).toBe("unresolved"); + expect(resolution.state === "unresolved" && resolution.reason).toContain("absent, unreadable"); +}); + +test("a read that REJECTS outright is unresolved too, and names the failure", async () => { + world.breakReadUnion("session lost"); + try { + const resolution = await as("alice", (alice) => alice.resolveObject("did:ng:o:whatever")); + expect(resolution.state).toBe("unresolved"); + expect(resolution.state === "unresolved" && resolution.reason).toContain("session lost"); + } finally { + world.healReadUnion(); + } +}); + +// --- the inbox, from both sides ------------------------------------------- + +test("a document whose owner never opened an inbox REFUSES the deposit", async () => { + const orphan = await as("alice", (alice) => alice.createPublicDocument()); + // A deposit that vanishes without an error is worse than a refusal. + await expect(as("bob", (bob) => bob.depositTo(orphan, "did:ng:o:x"))).rejects.toThrow( + /has no inbox/, + ); +}); + +test("anyone may deposit into an index, only its owner may read what was deposited", async () => { + const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); + await as("bob", (bob) => indexing(bob).refer(index, "did:ng:o:some-object")); + + const own = await as("alice", (alice) => alice.readDeposits(index)); + expect(own.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]); + expect(own[0]?.from).toBe("bob"); + + await expect(as("bob", (bob) => bob.readDeposits(index))).rejects.toThrow( + /may only READ your own/, + ); +}); + +// --- what the adapter actually wrote -------------------------------------- + +test("readDocument returns what was written, and refuses a document that is no index", async () => { + const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); + const subjects = await as("alice", (alice) => alice.readDocument(index)); + expect(subjects).toEqual([{ subject: index, graph: index, props: { [INDEX_FIELD]: [FIELD] } }]); + + const ordinary = await as("alice", (alice) => alice.createPublicDocument()); + await expect(as("alice", (alice) => indexing(alice).read(ordinary))).rejects.toThrow( + /declares no index field/, + ); +}); + +test("the write is the polyfill's canonical anchored form: the document named once, as the anchor", async () => { + const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); + const writes = world.calls.filter((call) => call.entry === "docs.sparqlUpdate"); + const last = writes.at(-1); + expect(last?.args[1]).toBe(`INSERT DATA { <${index}> <${INDEX_FIELD}> "${FIELD}" }`); + // No `GRAPH <…>` inside the statement — the anchor already scopes the write. + expect(String(last?.args[1])).not.toContain("GRAPH"); + expect(last?.args[2]).toBe(index); +}); + +test("a hostile value lands as ONE inert literal, not as a second statement", async () => { + const HOSTILE = '" } ; DROP GRAPH ; INSERT DATA { "c'; + const doc = await as("alice", async (alice) => { + const object = await alice.createPublicDocument(); + await alice.addLiteralProperty(object, object, ENTRY_VALUE, HOSTILE); + return object; + }); + + // Semantic, not a quote count: the value round-trips through a parser that would + // have refused the query outright had the literal closed early — and had it + // closed early and still parsed, the extra subject would show up here. + expect(world.contentsOf(doc)).toEqual([ + { subject: doc, predicate: ENTRY_VALUE, values: [HOSTILE] }, + ]); +}); + +// --- the two blanket checks ----------------------------------------------- + +test("nothing the adapter sent the polyfill, in any method, carries a destructive form", () => { + // Over EVERY recorded call, not a chosen few, and after blanking SPARQL literals + // so a keyword inside one reads as the inert text it is. If escaping ever breaks, + // the injected statement lands outside a literal and survives the blanking — + // which is the point of doing it this way rather than skipping queries wholesale. + const strings = world.calls.flatMap((call) => + call.args.flatMap((arg) => (typeof arg === "string" ? [arg] : [])), + ); + expect(world.calls.length).toBeGreaterThan(0); // not vacuously true + expect(strings.length).toBeGreaterThan(0); + for (const value of strings) { + expect(blankLiterals(value)).not.toMatch(DESTRUCTIVE); + } +}); + +test("LAST — every method the adapter exposes was driven above", async () => { + // Read off the adapter itself, so there is no list to remember to extend: add an + // eighth method and this fails until something above actually calls it. That is + // the whole answer to "the gate proves the methods it drives" — it now names them + // from the code rather than from a test's memory. + const { polyfillPort } = await import("../src/polyfill-adapter"); + const exposed = Object.keys(polyfillPort({ sessionId: "session:alice" })); + expect(exposed.length).toBeGreaterThan(0); + expect(exposed.filter((method) => !driven.has(method)).sort()).toEqual([]); +}); diff --git a/test/fake-polyfill.ts b/test/fake-polyfill.ts new file mode 100644 index 0000000..61854a3 --- /dev/null +++ b/test/fake-polyfill.ts @@ -0,0 +1,521 @@ +import { mock } from "bun:test"; +import type { Nuri, UnionSubject } from "../src/port"; + +/** + * `@ng-eventually/polyfill` itself, in memory — NOT a mock returning constants. + * + * `fake-nextgraph.ts` stands in one layer higher, behind `NextGraphPort`, so it + * exercises the indexing rules and leaves `polyfill-adapter.ts` untouched. This + * one stands BELOW the adapter, so the real adapter runs on top of it and every + * behavioural test becomes a test of the wiring too. + * + * ## Why a real implementation and not a recorder returning `[]` + * + * The previous gate mocked `readUnion` to a constant `[]`. That single constant + * was an escape hatch: a destructive statement guarded by + * `if ((await readUnion([graph])).length > 0)` never ran, so it was never + * recorded, and the gate passed. A mock that always answers the same thing tests + * one state of the world. This answers what was actually written, so a curation + * run that adds an entry to a document already holding a descriptor DOES take the + * non-empty branch. + * + * ## Why the SPARQL is EXECUTED and not pattern-matched + * + * {@link applyInsertData} is a strict parser for the one statement this package + * writes. It is not a regex looking for `DELETE`: it is an engine that can only + * ADD, so a removal is not "detected", it is **unrunnable**. `DELETE WHERE …` + * fails at the first keyword; `INSERT DATA { … } ; DROP GRAPH <…>` fails on the + * trailing statement; and a keyword hidden inside a literal stays inside the + * literal, because a parser tokenises where a regex only matches. A split literal + * (`"DEL" + "ETE …"`) is one string by the time it arrives, so the split buys + * nothing. + * + * ## Every rule below is one the polyfill actually enforces + * + * - only a document's owner writes to it (`docs.sparqlUpdate`'s `assertMayWrite`); + * - anyone may DEPOSIT into a document's inbox, but reading one throws for anyone + * but its owner (`inbox.read`'s `assertOwnInbox`); + * - depositing into a document whose owner never opened an inbox THROWS + * (`inbox.postToDocument`), rather than going nowhere; + * - a document with no inbox READS as `[]` — a state, not an error + * (`depositsForDocument`); + * - opening an inbox is refused to anyone but the document's owner + * (`openDocumentInbox`); + * - `readUnion` swallows a failing document into `[]` (`readDoc`'s + * `try {…} catch { return [] }`), and may also reject outright; + * - `readUnion` builds each subject's props as a plain object literal filled by + * `(props[p] ??= []).push(o)` — inherited members and all. + * + * ## What is NOT modelled, and what happens then + * + * Anything the adapter reaches for on `docs`, `inbox` or `storeRegistry` that is + * absent here THROWS by name (see {@link namespace}) instead of returning + * `undefined` — so routing a query through `docs.sparqlQuery` is a red test with a + * message that says so. A brand-new TOP-LEVEL import is the one case that degrades: + * Bun's `mock.module` materialises the module namespace from own keys, so a Proxy + * there is lost and an unmodelled top-level export arrives as `undefined`. Still + * red (`undefined is not a function`), just with a duller message. + */ + +// --- the deposit, as the polyfill publishes it ---------------------------- + +/** Mirrors `inbox.Deposit`: sender when identified, opaque payload, ms timestamp. */ +interface Deposit { + readonly from: string | null; + readonly payload: unknown; + readonly ts: number; +} + +/** Mirrors `inbox.PostOptions` for the two fields this package can reach. */ +interface PostOptions { + readonly payload?: unknown; + readonly from?: string | null; +} + +// --- the one statement this fake can execute ------------------------------ + +/** A triple as this package writes them: two IRIs and one string literal. */ +interface Triple { + readonly subject: string; + readonly predicate: string; + readonly value: string; +} + +const IRI_BREAKERS = new Set(['"', "<", ">", "\\", "^", "`", "{", "|", "}"]); + +/** + * A strict reader for `INSERT DATA {

"v" . … }` and NOTHING else. + * + * Deliberately whole-string: it consumes the query to its end, so a second + * statement smuggled after the closing brace is a parse failure rather than + * something the engine quietly ignores. + */ +class Reader { + #at = 0; + constructor(private readonly query: string) {} + + refuse(what: string): never { + throw new Error( + `[fake-polyfill] refused to execute: ${what} at offset ${this.#at}. ` + + "This fake implements ONE statement — an anchored `INSERT DATA` of literal " + + "triples — because that is the only thing this package may ever emit. " + + `Query: ${JSON.stringify(this.query)}`, + ); + } + + #skipSpace(): void { + while (this.#at < this.query.length && /\s/.test(this.query[this.#at] ?? "")) this.#at += 1; + } + + keyword(word: string): void { + this.#skipSpace(); + const slice = this.query.slice(this.#at, this.#at + word.length); + const next = this.query[this.#at + word.length] ?? " "; + if (slice.toUpperCase() !== word || /[A-Za-z0-9_]/.test(next)) { + this.refuse(`expected the keyword ${word}, found ${JSON.stringify(slice)}`); + } + this.#at += word.length; + } + + symbol(character: string): void { + if (!this.trySymbol(character)) this.refuse(`expected ${JSON.stringify(character)}`); + } + + trySymbol(character: string): boolean { + this.#skipSpace(); + if (this.query[this.#at] !== character) return false; + this.#at += 1; + return true; + } + + /** An IRI between angle brackets — no character that could close it early. */ + iri(): string { + this.#skipSpace(); + if (this.query[this.#at] === "G" || this.query[this.#at] === "g") { + // Named after the shape it is refusing, because this one is a CONVENTION and + // not a broker limit: `packages/polyfill/e2e/` verified an anchored + // `GRAPH ` write round-trips upstream too. The polyfill writes the + // anchored DEFAULT graph with no wrapper and calls that the canonical, + // always-safe shape (`src/surface/inbox.ts`), so this layer writes it too — + // two layers writing the same data two ways is diagnosis work bought for later. + this.refuse( + "an explicit GRAPH clause — `docs.sparqlUpdate(sid, update, anchor)` already " + + "scopes the write to the anchor's default graph, and the polyfill writes " + + "that shape with no GRAPH wrapper", + ); + } + this.symbol("<"); + let out = ""; + while (true) { + const character = this.query[this.#at]; + if (character === undefined) this.refuse("an IRI that is never closed"); + this.#at += 1; + if (character === ">") return out; + if (IRI_BREAKERS.has(character) || (character.codePointAt(0) ?? 0) <= 0x20) { + this.refuse(`${JSON.stringify(character)} inside an IRI`); + } + out += character; + } + } + + /** A quoted literal, unescaped back to the string the caller passed in. */ + literal(): string { + this.#skipSpace(); + this.symbol('"'); + let out = ""; + while (true) { + const character = this.query[this.#at]; + if (character === undefined) this.refuse("a literal that is never closed"); + this.#at += 1; + if (character === '"') return out; + if (character !== "\\") { + out += character; + continue; + } + const escaped = this.query[this.#at]; + this.#at += 1; + if (escaped === undefined) this.refuse("a trailing backslash"); + const known: Record = { n: "\n", r: "\r", t: "\t", "\\": "\\", '"': '"' }; + const decoded = Object.hasOwn(known, escaped) ? known[escaped] : undefined; + if (decoded === undefined) this.refuse(`the escape \\${escaped}`); + out += decoded; + } + } + + end(): void { + this.#skipSpace(); + if (this.#at !== this.query.length) this.refuse("a second statement"); + } +} + +/** Reads the ONLY query form this package emits. Everything else throws. */ +export function applyInsertData(query: string): Triple[] { + const reader = new Reader(query); + reader.keyword("INSERT"); + reader.keyword("DATA"); + reader.symbol("{"); + const triples: Triple[] = []; + while (!reader.trySymbol("}")) { + const subject = reader.iri(); + const predicate = reader.iri(); + const value = reader.literal(); + triples.push({ subject, predicate, value }); + reader.trySymbol("."); + } + reader.end(); + return triples; +} + +// --- the lexical tripwire, kept as a SECOND and independent layer --------- + +/** + * Blanks every SPARQL string literal, so a keyword INSIDE one reads as what it is: + * inert text. If escaping ever breaks, the injected statement lands OUTSIDE a + * literal and survives this — which is the whole point. + */ +export function blankLiterals(value: string): string { + let out = ""; + let inside = false; + for (let i = 0; i < value.length; i += 1) { + const character = value[i]; + if (inside && character === "\\") { + i += 1; // an escaped character can never close the literal + continue; + } + if (character === '"') { + inside = !inside; + out += '"'; + continue; + } + if (!inside) out += character; + } + return out; +} + +/** + * Every SPARQL 1.1 form that can destroy or displace data, plus the modifiers that + * introduce one. For a tripwire a false positive is far cheaper than a miss. + */ +export const DESTRUCTIVE = /\b(DELETE|DROP|CLEAR|MOVE|COPY|ADD|LOAD|MODIFY|WITH|SILENT)\b/i; + +// --- what was called ------------------------------------------------------ + +export interface RecordedCall { + /** `docs.sparqlUpdate`, `inbox.postToDocument`, … */ + readonly entry: string; + readonly args: readonly unknown[]; +} + +interface StoredDocument { + readonly nuri: Nuri; + readonly owner: string; + readonly subjects: Map>; + /** `undefined` until the owner opens one — the state `postToDocument` refuses. */ + deposits: Deposit[] | undefined; +} + +export interface FakePolyfill { + /** Every call the adapter made into the polyfill, in order. */ + readonly calls: readonly RecordedCall[]; + /** Acts under this identity from now on — a polyfill session IS one identity. */ + signIn(user: string): void; + /** The session id for the signed-in identity, as `init`'s callback hands it over. */ + sessionId(): string; + /** + * The broker can no longer answer about this document. `readDoc` catches and + * yields `[]`, so it arrives indistinguishable from an empty document — which is + * exactly the confusion `resolution.ts` exists to refuse. + */ + breakReadsOf(doc: string, reason: string): void; + healReadsOf(doc: string): void; + /** `readUnion` REJECTS outright — its session-level failure, not a per-document one. */ + breakReadUnion(reason: string): void; + healReadUnion(): void; + /** Every subject in a document, read from outside the adapter. */ + contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[]; +} + +/** + * Installs the fake in place of `@ng-eventually/polyfill` and hands back the + * handle on it. Call at the TOP of a test file: the adapter must be imported + * after this runs, so import it with `await import(…)` inside the tests. + */ +export function installFakePolyfill(): FakePolyfill { + const documents = new Map(); + const unreachable = new Map(); + const calls: RecordedCall[] = []; + let unionFailure: string | undefined; + let currentUser = "nobody"; + let documentCount = 0; + let clock = 0; + + function require(doc: string): StoredDocument { + const stored = documents.get(doc); + if (stored === undefined) throw new Error(`cannot open ${doc}`); + return stored; + } + + function readOne(doc: string): UnionSubject[] { + const broken = unreachable.get(doc); + if (broken !== undefined) throw new Error(`cannot reach ${doc}: ${broken}`); + const stored = 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)`. 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. + const props: Record = {}; + for (const [predicate, values] of properties) { + for (const value of values) { + (props[predicate] ??= []).push(value); + } + } + out.push({ subject, graph: doc as Nuri, props }); + } + return out; + } + + const docsImpl = { + async sparqlUpdate(sessionId: unknown, query: unknown, anchor?: unknown): Promise { + if (typeof query !== "string") throw new Error("[fake-polyfill] the query must be a string"); + const writer = userOfSession(sessionId); + if (writer !== currentUser) { + // A session belongs to ONE identity upstream, so these cannot disagree. + throw new Error( + `[fake-polyfill] session of ${writer} used while ${currentUser} is signed in`, + ); + } + if (typeof anchor !== "string") { + throw new Error( + "[fake-polyfill] docs.sparqlUpdate without an anchor: the write would not be " + + "scoped to a document. This package always anchors — see polyfill-adapter.ts.", + ); + } + const stored = require(anchor); + if (stored.owner !== writer) { + throw new Error( + `${writer} may not write ${anchor}: only a document's owner writes to it, and ` + + "holding its read key never grants a write", + ); + } + for (const triple of applyInsertData(query)) { + let properties = stored.subjects.get(triple.subject); + if (properties === undefined) { + properties = new Map(); + stored.subjects.set(triple.subject, properties); + } + const values = properties.get(triple.predicate); + // An INSERT of a triple already present is a no-op in RDF: a graph is a set. + if (values === undefined) properties.set(triple.predicate, [triple.value]); + else if (!values.includes(triple.value)) values.push(triple.value); + } + return []; + }, + + async sparqlQuery(_sessionId: unknown, query: unknown): Promise { + // Reached only if this package starts querying, which it does not: it reads + // through `readUnion`. The message splits the two reasons someone lands here, + // because one of them is an attempt to write through the read door. + if (typeof query === "string" && DESTRUCTIVE.test(blankLiterals(query))) { + throw new Error( + "[fake-polyfill] a destructive statement was routed through docs.sparqlQuery. " + + "An index only ever grows — there is no door for this. " + + `Query: ${JSON.stringify(query)}`, + ); + } + throw new Error( + "[fake-polyfill] docs.sparqlQuery is not modelled — this package reads through " + + "`readUnion`. Model it READ-ONLY here before using it.", + ); + }, + }; + + const inboxImpl = { + async postToDocument(doc: unknown, options: unknown): Promise { + const stored = require(String(doc)); + if (stored.deposits === undefined) { + throw new Error( + `[ng-eventually] inbox.postToDocument: this document has no inbox — either its ` + + `owner never opened one, or you cannot read the document: ${JSON.stringify(doc)}`, + ); + } + const opts = (options ?? {}) as PostOptions; + // "Defaults to the current polyfill user when the property is entirely absent." + const from = Object.hasOwn(opts, "from") ? (opts.from ?? null) : currentUser; + clock += 1; + stored.deposits.push({ from, payload: opts.payload ?? null, ts: clock }); + }, + + async readForDocument(doc: unknown): Promise { + const stored = require(String(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 !== currentUser) { + throw new Error( + `${currentUser} may not read the inbox of ${String(doc)}: you may DEPOSIT into ` + + "anyone's inbox, you may only READ your own", + ); + } + return [...stored.deposits].sort((a, b) => a.ts - b.ts); + }, + }; + + const storeRegistryImpl = { + async createEntityDoc(scope: unknown): Promise { + if (scope !== "public" && scope !== "mine") { + throw new Error(`[fake-polyfill] unknown scope ${JSON.stringify(scope)}`); + } + documentCount += 1; + const nuri = `did:ng:o:doc-${documentCount}` as Nuri; + documents.set(nuri, { nuri, owner: currentUser, subjects: new Map(), deposits: undefined }); + return nuri; + }, + + async openDocumentInbox(doc: unknown): Promise { + const stored = require(String(doc)); + if (stored.owner !== currentUser) { + throw new Error( + `${currentUser} may not open an inbox on ${String(doc)}: opening one publishes ` + + "the document's address, so a non-owner would route the owner's deposits to itself", + ); + } + stored.deposits ??= []; + return `${stored.nuri}:inbox`; + }, + }; + + async function readUnionImpl(docsLike: unknown): Promise { + if (unionFailure !== undefined) throw new Error(unionFailure); + const list = Array.isArray(docsLike) ? docsLike : []; + const out: UnionSubject[] = []; + for (const doc of [...new Set(list.filter(Boolean))]) { + try { + out.push(...readOne(String(doc))); + } catch (error) { + // `readDoc` is `try {…} catch { return [] }`: a failing document is skipped + // and never aborts the batch, so failure and emptiness arrive identical. + console.error("[fake-polyfill] read failed for", doc, String(error)); + } + } + return out; + } + + function userOfSession(sessionId: unknown): string { + const id = String(sessionId); + const user = id.startsWith("session:") ? id.slice("session:".length) : undefined; + if (user === undefined) throw new Error(`[fake-polyfill] not a session id: ${id}`); + return user; + } + + function record(entry: string, args: readonly unknown[]): void { + calls.push({ entry, args }); + } + + /** + * A namespace whose every member is recorded, and whose UNKNOWN members throw by + * name rather than arriving as `undefined`. `Object.hasOwn` and not `impl[name]`, + * because a plain object literal inherits `toString` and friends — the same trap + * `valuesOf` guards against in `src/index-document.ts`. + */ + function namespace(name: string, impl: Record unknown>): unknown { + return new Proxy(impl, { + get(target, property) { + if (typeof property === "symbol" || property === "then") return undefined; + const entry = `${name}.${property}`; + return (...args: unknown[]) => { + record(entry, args); + if (!Object.hasOwn(target, property)) { + throw new Error( + `[fake-polyfill] ${entry} is not modelled. The adapter reached for a polyfill ` + + "entry this fake knows nothing about, so nothing here can vouch for what it " + + "does. Model it (read-only if it queries) before using it.", + ); + } + return (target[property] as (...a: unknown[]) => unknown)(...args); + }; + }, + }); + } + + mock.module("@ng-eventually/polyfill", () => ({ + docs: namespace("docs", docsImpl), + inbox: namespace("inbox", inboxImpl), + storeRegistry: namespace("storeRegistry", storeRegistryImpl), + async readUnion(docsLike: unknown) { + record("readUnion", [docsLike]); + return readUnionImpl(docsLike); + }, + })); + + return { + calls, + signIn(user: string) { + currentUser = user; + }, + sessionId() { + return `session:${currentUser}`; + }, + breakReadsOf(doc: string, reason: string) { + unreachable.set(doc, reason); + }, + healReadsOf(doc: string) { + unreachable.delete(doc); + }, + breakReadUnion(reason: string) { + unionFailure = reason; + }, + healReadUnion() { + unionFailure = undefined; + }, + contentsOf(doc: string) { + const stored = require(doc); + const out: { subject: string; predicate: string; values: string[] }[] = []; + for (const [subject, properties] of stored.subjects) { + for (const [predicate, values] of properties) out.push({ subject, predicate, values }); + } + return out; + }, + }; +} diff --git a/test/units.test.ts b/test/units.test.ts index 647ede5..8b34b1f 100644 --- a/test/units.test.ts +++ b/test/units.test.ts @@ -78,17 +78,12 @@ test("isIriSafe agrees with escapeIri on what needs encoding", () => { // --- 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 " + - '{ "2026-01-02" } }', +test("buildInsertTriple writes the anchored INSERT DATA, with NO GRAPH clause", () => { + // The document is named ONCE, as `docs.sparqlUpdate`'s anchor, never inside the + // statement — the polyfill's canonical shape (`src/surface/inbox.ts`). The builder + // takes no graph parameter at all, so the statement cannot name a second one. + expect(buildInsertTriple("did:ng:o:object", "urn:ng-helpers:index:value", "2026-01-02")).toBe( + 'INSERT DATA { "2026-01-02" }', ); }); @@ -106,31 +101,28 @@ function unescapedQuotes(query: string): number { 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 { "c', + '" } ; DROP ALL ; INSERT DATA { "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 { ')).toBe(true); - expect(query.endsWith('" } }')).toBe(true); + expect(query.startsWith("INSERT DATA { ")).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> " + - '{ "v" } }', + 'INSERT DATA { "v" }', ); }); @@ -140,7 +132,7 @@ test("every builder emits an INSERT and nothing else", async () => { 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"))); + emitted.push(String(builder("did:ng:o:s", "urn:p", "v"))); } expect(emitted.length).toBeGreaterThan(0); // not vacuously true for (const query of emitted) {