453 lines
18 KiB
TypeScript
453 lines
18 KiB
TypeScript
import { mock } from "bun:test";
|
|
import type { Nuri } 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 constants
|
|
*
|
|
* A mock that always answers the same thing tests one state of the world, and a
|
|
* statement guarded by what a read returned would never run under it. This one
|
|
* answers what was actually written, so `contentsOf` and `depositsIn` report what
|
|
* the adapter really left behind rather than what a test arranged.
|
|
*
|
|
* ## 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 (`inbox.postToDocument`);
|
|
* - depositing into a document whose owner never opened an inbox THROWS, rather
|
|
* than going nowhere;
|
|
* - opening an inbox is refused to anyone but the document's owner
|
|
* (`openDocumentInbox`: doing so publishes the document's address);
|
|
* - a document with no inbox has no address to read — a state, not an error.
|
|
*
|
|
* ## 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. This package reads nothing at all, so `readUnion` is not
|
|
* modelled either: reaching for it is the same red. 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 { <s> <p> "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 <plainNuri>` 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<string, string> = { 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<string, Map<string, string[]>>;
|
|
/** `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;
|
|
/** Every triple in a document, read from outside the adapter. */
|
|
contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[];
|
|
/**
|
|
* What is waiting in a document's inbox, oldest first — or `null` when its owner
|
|
* never opened one. An INSPECTION, not an entry of the polyfill: this package
|
|
* never reads an inbox, so nothing it does may depend on being able to.
|
|
*/
|
|
depositsIn(doc: string): readonly Deposit[] | null;
|
|
}
|
|
|
|
/**
|
|
* 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<string, StoredDocument>();
|
|
const calls: RecordedCall[] = [];
|
|
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;
|
|
}
|
|
|
|
const docsImpl = {
|
|
async sparqlUpdate(sessionId: unknown, query: unknown, anchor?: unknown): Promise<unknown> {
|
|
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<never> {
|
|
// Reached only if this package starts querying, which it does not: it reads
|
|
// nothing at all. 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 does not read. " +
|
|
"Model it READ-ONLY here before using it.",
|
|
);
|
|
},
|
|
};
|
|
|
|
const inboxImpl = {
|
|
async postToDocument(doc: unknown, options: unknown): Promise<void> {
|
|
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 });
|
|
},
|
|
|
|
};
|
|
|
|
const storeRegistryImpl = {
|
|
async createEntityDoc(scope: unknown): Promise<Nuri> {
|
|
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<string> {
|
|
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 ??= [];
|
|
// Idempotent within a page: asking again for a document that already has one
|
|
// resolves that same address rather than opening a second inbox. The address
|
|
// goes no further — this package drops it, which is the point of `openInbox`
|
|
// returning nothing.
|
|
return `${stored.nuri}:inbox`;
|
|
},
|
|
};
|
|
|
|
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, and an
|
|
* inherited member is not a modelled entry.
|
|
*/
|
|
function namespace(name: string, impl: Record<string, (...args: never[]) => 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),
|
|
}));
|
|
|
|
return {
|
|
calls,
|
|
signIn(user: string) {
|
|
// This fake has ONE signed-in identity at a time, as a page does: a polyfill
|
|
// session IS one identity, and no call takes an identifier.
|
|
currentUser = user;
|
|
},
|
|
sessionId() {
|
|
return `session:${currentUser}`;
|
|
},
|
|
depositsIn(doc: string) {
|
|
const stored = require(doc);
|
|
if (stored.deposits === undefined) return null;
|
|
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
|
|
},
|
|
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;
|
|
},
|
|
};
|
|
}
|