226 lines
8.5 KiB
TypeScript
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,
|
|
};
|
|
}
|