Files
ng-eventually/packages/polyfill/test/orm-fake.ts
T
2026-09-21 11:41:31 +02:00

226 lines
8.5 KiB
TypeScript

/**
* orm-fake - a stand-in for the SDK's shape query, `RdfOrmSubscription.getOrCreate`.
*
* Not a `*.test.ts`, so `bun test` does not pick it up: it is the injection two suites
* share, and it is the seam the package is built on - an application hands `configure`
* the real ORM, and a suite hands it this.
*
* -- Why a small ENGINE and not a canned answer ----------------------------
* `useShapeQuery` no longer interprets a scope: `graphs`, `subjects` and the
* whole-dataset marker go to the engine verbatim. A fake that ignored them and returned
* a fixed list would make every scope test green while proving nothing - the failure
* mode `test-the-case-space` is about. So this answers the way
* `schema_shape_to_sparql` / `query_quads_for_shape` do, and the three readings of the
* axes are real here:
*
* - `graphs: []` (or absent) -> NO objects. `graphs` carrying `did:ng:i` or `""` ->
* every other entry is discarded and the graph filter drops entirely. Otherwise the
* filter is LITERAL - a graph is in the list or it is not, no prefix matching.
* - `subjects: []` -> no filtering; otherwise literal, over ROOT subjects.
* - the shape -> every predicate of `schema[shape]` must be present on the subject,
* and a predicate carrying `literals` must match one of them. The object is
* projected under each predicate's `readablePredicate`, plus `@id` and `@graph`.
*
* -- What it does NOT invent -----------------------------------------------
* Readiness resolves on ONE path - a delivery - exactly as upstream's `resolveReady`
* runs only on the arrival of initial data. Nothing here ever REJECTS `readyPromise`,
* because nothing upstream does: `orm_start_graph` failing is caught and logged, and a
* refused read is an eternal pending. A suite that wants the pending-for-ever state
* asks for it by not delivering, which is the state the real thing produces.
*
* {@link FakeOrm.refuse} is the other real failure: `getOrCreate` THROWS at the call.
*/
import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../src/model/types";
import type { Scope } from "@ng-org/orm";
import type { BaseType, Predicate, ShapeType } from "@ng-org/shex-orm";
/** Upstream's whole-dataset marker; `""` means the same thing. */
const ENTIRE_DATASET = "did:ng:i";
/** One triple in the dataset this fake engine answers from. */
export interface OrmQuad {
graph: string;
subject: string;
predicate: string;
object: string;
}
type Listener = (changes: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void;
interface OpenQuery {
shape: ShapeType<BaseType>;
scope: Scope;
listeners: Set<Listener>;
items: BaseType[];
delivered: boolean;
closed: boolean;
resolveReady: () => void;
}
export interface FakeOrm {
/** What goes into `configure({ ormSubscription })`. */
query: OrmSubscriptionLike;
/** Add a triple to the dataset. After a delivery this also pushes, as an engine patch
* reaching the signal object does. */
seed(graph: string, subject: string, predicate: string, object: string): void;
/** Deliver the initial payload to every query still waiting for one - what resolves
* readiness upstream, and the only thing that does. */
deliver(): void;
/** Make every SUBSEQUENT `getOrCreate` throw. The one failure this path really has. */
refuse(message: string): void;
/** Stop refusing. */
answer(): void;
/** The scopes this fake was asked for, in order - so a suite can assert the scope
* reached the SDK exactly as the caller wrote it. */
asked: Array<{ shape: string; scope: Scope }>;
/** How many queries are open right now (`close()` decrements). */
openCount(): number;
}
/** The literal values a predicate constrains its object to, if any. */
function literalsOf(p: Predicate): string[] {
const out: string[] = [];
for (const dt of p.dataTypes ?? []) {
for (const lit of dt.literals ?? []) if (typeof lit === "string") out.push(lit);
}
return out;
}
/** Does `subject` in `graph` satisfy every predicate of the shape? If so, the object. */
function project(
quads: OrmQuad[],
graph: string,
subject: string,
predicates: Predicate[],
): BaseType | null {
const own = quads.filter((q) => q.graph === graph && q.subject === subject);
const out: Record<string, unknown> = { "@id": subject, "@graph": graph };
for (const p of predicates) {
const wanted = literalsOf(p);
const values = own
.filter((q) => q.predicate === p.iri)
.map((q) => q.object)
.filter((o) => wanted.length === 0 || wanted.includes(o));
if (values.length === 0) return null; // a required triple pattern with no match
out[p.readablePredicate] = values.length === 1 ? values[0] : values;
}
return out as BaseType;
}
/** The objects the engine would return for one shape in one scope. */
function answerFor(quads: OrmQuad[], shape: ShapeType<BaseType>, scope: Scope): BaseType[] {
const graphs = (typeof scope.graphs === "string" ? [scope.graphs] : (scope.graphs ?? [])).map(
(g) => (g === "" ? ENTIRE_DATASET : g),
);
if (graphs.length === 0) return []; // upstream: no objects are returned
const everywhere = graphs.includes(ENTIRE_DATASET);
const subjects = scope.subjects ?? [];
const predicates = shape.schema[shape.shape]?.predicates ?? [];
const seen = new Set<string>();
const out: BaseType[] = [];
for (const q of quads) {
if (!everywhere && !graphs.includes(q.graph)) continue;
if (subjects.length > 0 && !subjects.includes(q.subject)) continue;
const key = q.graph + "/" + q.subject;
if (seen.has(key)) continue;
seen.add(key);
const obj = project(quads, q.graph, q.subject, predicates);
if (obj !== null) out.push(obj);
}
return out;
}
/**
* A fake shape query over an in-memory dataset.
*
* `autoDeliver` (the default) hands every query its initial payload on the next tick,
* which is the convenient mode for suites that are not about readiness. Turn it off and
* call {@link FakeOrm.deliver} to hold the pending state open.
*/
export function makeOrm(
options: { autoDeliver?: boolean; source?: () => OrmQuad[] } = {},
): FakeOrm {
const quads: OrmQuad[] = [];
/** Where the engine reads from - its own dataset, or one a suite already holds (the
* fake wallet's, so the objects read back are the ones the surface really wrote). */
const dataset = options.source ?? (() => quads);
const open = new Set<OpenQuery>();
const asked: Array<{ shape: string; scope: Scope }> = [];
const autoDeliver = options.autoDeliver !== false;
let refusal: string | null = null;
function recompute(q: OpenQuery): void {
if (!q.delivered || q.closed) return;
q.items = answerFor(dataset(), q.shape, q.scope);
for (const l of q.listeners) l({ adds: q.items, updates: [], removes: [] });
}
function deliverOne(q: OpenQuery): void {
if (q.delivered || q.closed) return;
q.delivered = true;
q.items = answerFor(dataset(), q.shape, q.scope);
q.resolveReady();
for (const l of q.listeners) l({ adds: q.items, updates: [], removes: [] });
}
const query = (<T extends BaseType>(
shapeType: ShapeType<T>,
scope: Scope,
): OrmSubscriptionHandle => {
if (refusal !== null) throw new Error(refusal);
const shape = shapeType as unknown as ShapeType<BaseType>;
asked.push({ shape: shape.shape, scope });
let resolveReady!: () => void;
const readyPromise = new Promise<void>((r) => (resolveReady = r));
const record: OpenQuery = {
shape,
scope,
listeners: new Set(),
items: [],
delivered: false,
closed: false,
resolveReady,
};
open.add(record);
if (autoDeliver) setTimeout(() => deliverOne(record), 0);
return {
readyPromise,
get signalObject(): Iterable<unknown> {
return record.items;
},
addChangeListener(listener: Listener): void {
record.listeners.add(listener);
},
removeChangeListener(listener: Listener): void {
record.listeners.delete(listener);
},
close(): void {
record.closed = true;
record.listeners.clear();
open.delete(record);
},
};
}) as OrmSubscriptionLike;
return {
query,
seed(graph, subject, predicate, object): void {
quads.push({ graph, subject, predicate, object });
for (const q of open) recompute(q);
},
deliver(): void {
for (const q of [...open]) deliverOne(q);
},
refuse(message): void {
refusal = message;
},
answer(): void {
refusal = null;
},
asked,
openCount: () => open.size,
};
}