diff --git a/acs-i3x/bin/api.ts b/acs-i3x/bin/api.ts index 3a0623416..99705f9fa 100644 --- a/acs-i3x/bin/api.ts +++ b/acs-i3x/bin/api.ts @@ -9,6 +9,7 @@ import { WebAPI } from "@amrc-factoryplus/service-api"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { routes } from "../lib/routes.js"; import { ObjectTree } from "../lib/object-tree.js"; +import { DatasetStore } from "../lib/datasets.js"; import { ValueCache } from "../lib/value-cache.js"; import { History } from "../lib/history.js"; import { SubscriptionManager } from "../lib/subscriptions.js"; @@ -52,6 +53,16 @@ const subscriptions = new SubscriptionManager({ ttl: parseInt(env.I3X_SUBSCRIPTION_TTL || "300000"), }); +// Data Access datasets served as i3X objects. The external URL is the +// public base consumers can reach; without it descriptors carry no +// content block (the dataset graph still resolves). +const datasets = await new DatasetStore({ + fplus, + namespaceUri: env.I3X_NAMESPACE_URI || "https://example.com", + dataAccessUrl: env.I3X_DATA_ACCESS_EXTERNAL_URL, + resolve: id => objectTree.getObject(id), +}).init(); + // Build RAG engine (graph + search index) const i3xRag = new I3xRag(objectTree, valueCache, history); i3xRag.init(); @@ -81,6 +92,7 @@ const api = await new WebAPI({ valueCache, history, subscriptions, + datasets, mcpServer, maxDepthCap: parseInt(env.I3X_MAX_DEPTH_CAP || "0"), }), diff --git a/acs-i3x/docs/design.md b/acs-i3x/docs/design.md index 219cad36e..b249a627b 100644 --- a/acs-i3x/docs/design.md +++ b/acs-i3x/docs/design.md @@ -168,6 +168,31 @@ Pure translation functions between Factory+ and i3X data shapes: - `wrapError(message)` → `{ success: false, error: { message } }` - `wrapBulkResponse(results)` → ordered bulk response with per-item success/error +### datasets.ts + +Serves Data Access datasets as i3X objects (FPLUS-44). Datasets live in +the ConfigDB (Dataset class membership plus one structure App entry per +type: SparkplugSrc, SessionLimits or UnionComponents), so the store +learns about them from three list calls and a class-members read, +re-polled on a timer. Each dataset appears under a synthetic "Datasets" +folder with a shared Dataset ObjectType and a real schema. + +A dataset's `/value` is a descriptor, not data: the dataset type, its +structure (source device, coverage window, or union members), and a +DCAT-shaped `content` block with the Data Access export href +(`I3X_DATA_ACCESS_EXTERNAL_URL`; omitted when unset). Structure configs +are read per request so a growing union is always current. + +`/related` with `HasComponent` walks the graph: union → members, +session window → source dataset, device stream → the device object +(resolved from the ObjectTree). `/history` on a dataset answers 404 +pointing at the content href — the i3X history shape cannot express a +multi-metric dataset (TID L5). + +Deliberately separate from ObjectTree: the device pipeline (reactive +refresh, UNS preservation, diffing) is untouched; APIv1 consults the +store as a fallback on every object route. + ### quality.ts Derives i3X quality from device/metric state: diff --git a/acs-i3x/docs/to-improve.md b/acs-i3x/docs/to-improve.md index 393bfc281..d41365c1a 100644 --- a/acs-i3x/docs/to-improve.md +++ b/acs-i3x/docs/to-improve.md @@ -23,3 +23,4 @@ Tracked deviations from the i3X spec and known areas for improvement. | L2 | ObjectTypes without schemas | Return empty `{"type": "object"}` schema for ConfigDB classes missing a ConfigSchema app entry | Either require schemas or synthesise from Sparkplug birth certificate structure | Most real devices will have schemas; this is an edge case | Open | | L3 | SSE streaming scope | SSE subscriptions only stream values from devices publishing to UNS (requires UNS ingester + ISA-95 config) | Stream from all devices, including those only on Sparkplug | Could subscribe to `spBv1.0/#` directly, or use the historian's Sparkplug data. Needs design work — Sparkplug uses protobuf + aliases which requires birth certificate tracking | Open | | L4 | Device identity | Device elementId uses ConfigDB UUID, sub-objects use Instance_UUID. Ideally these would be consistent | Use Instance_UUID throughout, or provide a mapping endpoint | Too late to change for PoC without breaking the parent chain from ConfigDB | Open | +| L5 | Multi-metric history | The i3X history shape is one metric per object (`{ elementId, values: VQT[] }`). Compositions return an empty array (`lib/history.ts`), and a Data Access dataset's contents cannot be served through `/history` at all — dataset objects answer 404 there and point at the content href in their `/value` descriptor instead | A multi-series or tabular history shape in the spec | This is a spec limitation, not just an implementation gap. The dataset descriptor (`lib/datasets.ts`) is the evidence: a typed, multi-metric, time-bounded collection the history format cannot express. Candidate spec proposal (FPLUS-45 tracks the deliberate spec-breaking experiment) | Open | diff --git a/acs-i3x/lib/api-v1.ts b/acs-i3x/lib/api-v1.ts index 67a2be77d..4d1b7f344 100644 --- a/acs-i3x/lib/api-v1.ts +++ b/acs-i3x/lib/api-v1.ts @@ -14,6 +14,7 @@ import type { ObjectTree } from "./object-tree.js"; import type { ValueCache } from "./value-cache.js"; import type { History } from "./history.js"; import type { SubscriptionManager } from "./subscriptions.js"; +import type { DatasetStore } from "./datasets.js"; import validator from 'validator'; interface APIv1Opts { @@ -21,6 +22,8 @@ interface APIv1Opts { valueCache: ValueCache; history: History; subscriptions: SubscriptionManager; + /** Data Access datasets served as i3X objects (optional). */ + datasets?: DatasetStore; /** * Server-imposed cap on composition `maxDepth`. 0 means no cap * (default). When set, requests for a deeper traversal are @@ -67,6 +70,7 @@ export class APIv1 { private valueCache: ValueCache; private history: History; private subscriptions: SubscriptionManager; + private datasets: DatasetStore | null; private maxDepthCap: number; /** @@ -77,6 +81,7 @@ export class APIv1 { this.valueCache = opts.valueCache; this.history = opts.history; this.subscriptions = opts.subscriptions; + this.datasets = opts.datasets ?? null; this.maxDepthCap = opts.maxDepthCap ?? 0; this.routes = Router(); @@ -135,11 +140,11 @@ export class APIv1 { api.post("/objects/list", this.list_objects.bind(this)); api.post("/objects/value", asyncHandler(this.value_objects.bind(this))); api.post("/objects/history", asyncHandler(this.history_objects.bind(this))); - api.post("/objects/related", this.related_objects.bind(this)); + api.post("/objects/related", asyncHandler(this.related_objects.bind(this))); api.get("/objects/:elementId/value", asyncHandler(this.get_object_value.bind(this))); api.get("/objects/:elementId/history", asyncHandler(this.get_object_history.bind(this))); - api.get("/objects/:elementId/related", this.get_object_related.bind(this)); + api.get("/objects/:elementId/related", asyncHandler(this.get_object_related.bind(this))); api.get("/objects/:elementId", this.get_object.bind(this)); /* ---- Subscriptions ---- */ @@ -199,14 +204,33 @@ export class APIv1 { **/ get_object_types(req: Request, res: Response): void { const ns = req.query.namespaceUri as string | undefined; - res.json(this.objectTree.getObjectTypes(ns)); + const types = this.objectTree.getObjectTypes(ns); + if (this.datasets) { + const dt = this.datasets.getObjectType(); + if (ns === undefined || dt.namespaceUri === ns) types.push(dt); + } + res.json(types); + } + + /** ObjectType lookup across the device tree and the dataset store. */ + private lookupObjectType(elementId: string) { + const t = this.objectTree.getObjectType(elementId); + if (t) return t; + const dt = this.datasets?.getObjectType(); + return dt?.elementId === elementId ? dt : undefined; + } + + /** Object lookup across the device tree and the dataset store. */ + private lookupObject(elementId: string) { + return this.objectTree.getObject(elementId) + ?? this.datasets?.getObject(elementId); } /** * GET /objecttypes/:elementId — returns one object type, or 404 if unknown. **/ get_object_type(req: Request, res: Response, next: NextFunction): void { - const result = this.objectTree.getObjectType(req.params.elementId); + const result = this.lookupObjectType(req.params.elementId); if (!result) return next(notFound(`Object type ${req.params.elementId} not found`)); res.json(result); } @@ -221,7 +245,7 @@ export class APIv1 { query_object_types(req: Request, res: Response): void { const { elementIds } = req.body; const results = (elementIds as string[]).map(id => { - const item = this.objectTree.getObjectType(id); + const item = this.lookupObjectType(id); if (item) { return { success: true, elementId: id, result: item }; } @@ -270,11 +294,16 @@ export class APIv1 { * GET /objects — lists objects with optional `typeElementId`, `root`, and `includeMetadata` filters. **/ get_objects(req: Request, res: Response): void { - res.json(this.objectTree.getObjects({ + const filter = { typeElementId: req.query.typeElementId as string | undefined, root: req.query.root === "true", + }; + const objects = this.objectTree.getObjects({ + ...filter, includeMetadata: req.query.includeMetadata === "true", - })); + }); + if (this.datasets) objects.push(...this.datasets.getObjects(filter)); + res.json(objects); } /** @@ -285,7 +314,7 @@ export class APIv1 { list_objects(req: Request, res: Response): void { const { elementIds, includeMetadata } = req.body; const results = (elementIds as string[]).map(id => { - const item = this.objectTree.getObject(id); + const item = this.lookupObject(id); if (item) { return { success: true, elementId: id, result: item }; } @@ -308,6 +337,12 @@ export class APIv1 { const { elementIds, maxDepth } = req.body; const { effective, clamped } = this.clampDepth(maxDepth ?? 1); const results = await Promise.all((elementIds as string[]).map(async (id) => { + // Datasets: the value is a descriptor built from the ConfigDB + if (this.datasets?.has(id)) { + const item = await this.datasets.getValue(id); + if (item) return { success: true, elementId: id, result: item }; + return { success: false, elementId: id, error: { code: 404, message: `No value for ${id}` } }; + } // Try UNS cache first (real-time), fall back to InfluxDB last() const cached = this.valueCache.getValue(id); if (cached) { @@ -348,6 +383,12 @@ export class APIv1 { const results = await Promise.all( (elementIds as string[]).map(async id => { try { + if (this.datasets?.has(id)) { + throw Object.assign(new Error( + "Dataset contents are not served over history; " + + "fetch the content href from the dataset's value"), + { status: 404 }); + } const values = await this.history.queryHistory(id, startTime, endTime, maxDepth); return { success: true, elementId: id, result: { elementId: id, values } }; } catch (err: any) { @@ -364,16 +405,20 @@ export class APIv1 { * filtered by `relationshiptype`. Per-id success/error envelope: * missing ids are reported as failures. */ - related_objects(req: Request, res: Response): void { + async related_objects(req: Request, res: Response): Promise { const { elementIds, relationshiptype } = req.body; - const results = (elementIds as string[]).map(id => { + const results = await Promise.all((elementIds as string[]).map(async id => { + if (this.datasets?.has(id)) { + const related = await this.datasets.getRelated(id, relationshiptype); + return { success: true, elementId: id, result: related ?? [] }; + } const obj = this.objectTree.getObject(id); if (!obj) { return { success: false, elementId: id, error: { code: 404, message: `Object ${id} not found` } }; } const related = this.objectTree.getRelated(id, relationshiptype); return { success: true, elementId: id, result: related }; - }); + })); const allSuccess = results.every(r => r.success); ((res as any)._originalJson || res.json.bind(res))({ success: allSuccess, results }); } @@ -386,6 +431,13 @@ export class APIv1 { */ async get_object_value(req: Request, res: Response, next: NextFunction): Promise { const id = req.params.elementId; + // Datasets: the value is a descriptor built from the ConfigDB + if (this.datasets?.has(id)) { + const result = await this.datasets.getValue(id); + if (!result) return next(notFound(`No value for ${id}`)); + res.json(result); + return; + } const obj = this.objectTree.getObject(id); // Try UNS cache first (real-time), fall back to InfluxDB last() const cached = this.valueCache.getValue(id); @@ -415,6 +467,11 @@ export class APIv1 { if (!startTime || !endTime) { return next(badRequest("startTime and endTime query parameters are required")); } + if (this.datasets?.has(req.params.elementId)) { + return next(notFound( + "Dataset contents are not served over history; " + + "fetch the content href from the dataset's value")); + } const values = await this.history.queryHistory(req.params.elementId, startTime, endTime); res.json({ elementId: req.params.elementId, values }); } @@ -422,18 +479,23 @@ export class APIv1 { /** * GET /objects/:elementId/related — related objects, optionally filtered by `relationshiptype`. 404 if the source object is unknown. **/ - get_object_related(req: Request, res: Response, next: NextFunction): void { - const obj = this.objectTree.getObject(req.params.elementId); - if (!obj) return next(notFound(`Object ${req.params.elementId} not found`)); + async get_object_related(req: Request, res: Response, next: NextFunction): Promise { + const id = req.params.elementId; const rt = req.query.relationshiptype as string | undefined; - res.json(this.objectTree.getRelated(req.params.elementId, rt)); + if (this.datasets?.has(id)) { + res.json(await this.datasets.getRelated(id, rt) ?? []); + return; + } + const obj = this.objectTree.getObject(id); + if (!obj) return next(notFound(`Object ${id} not found`)); + res.json(this.objectTree.getRelated(id, rt)); } /** * GET /objects/:elementId — returns one object, or 404 if unknown. **/ get_object(req: Request, res: Response, next: NextFunction): void { - const result = this.objectTree.getObject(req.params.elementId); + const result = this.lookupObject(req.params.elementId); if (!result) return next(notFound(`Object ${req.params.elementId} not found`)); res.json(result); } diff --git a/acs-i3x/lib/constants.ts b/acs-i3x/lib/constants.ts index c3957c34d..4f40b7769 100644 --- a/acs-i3x/lib/constants.ts +++ b/acs-i3x/lib/constants.ts @@ -17,3 +17,15 @@ export const DEVICE_CLASS_UUID = "18773d6d-a70d-443a-b29a-3f158319529 export const SCHEMA_APP_UUID = "b16e85fb-53c2-49f9-8d83-cdf6763304ba"; export const INFO_APP_UUID = "64a8bfa9-7772-45c4-9d1a-9e6290690957"; export const DEVICE_INFORMATION_APP_UUID = "a98ffed5-c613-4e70-bfd3-efeee250ade5"; + +// Namespace for synthetic v5 UUIDs (metric path segments without an +// Instance_UUID, and the dataset folder / object type). +export const I3X_UUID_NAMESPACE = "11ad7b32-1d32-4c4a-b0c9-fa049208939a"; + +// Data Access dataset representation in the ConfigDB (see +// acs-data-access/lib/constants.js). Membership of the Dataset class +// plus one structure App entry per dataset type. +export const DATASET_CLASS_UUID = "c31d3cbd-01cd-4833-8014-c4512aef1e5c"; +export const DATASET_APP_SPARKPLUG_SRC = "f5d550c4-2831-11f1-b0b0-83fda3035799"; +export const DATASET_APP_SESSION_LIMITS = "8754c000-3778-4ae6-b2b8-bbcd959bb775"; +export const DATASET_APP_UNION_COMPONENTS = "1c4ca454-de38-44d9-92fb-aa5218bfa257"; diff --git a/acs-i3x/lib/datasets.ts b/acs-i3x/lib/datasets.ts new file mode 100644 index 000000000..d03fd5838 --- /dev/null +++ b/acs-i3x/lib/datasets.ts @@ -0,0 +1,312 @@ +/* + * Copyright (c) University of Sheffield AMRC 2026. + */ + +/* + * DatasetStore — exposes Data Access datasets as i3X objects. + * + * Data Access datasets live in the ConfigDB: membership of the Dataset + * class, with the structure held in one per-type App entry + * (SparkplugSrc, SessionLimits or UnionComponents). This store watches + * the class membership and serves each dataset as an i3X object under + * a synthetic "Datasets" folder, with: + * + * - /value → a descriptor: the dataset type, its structure, and a + * DCAT-shaped `content` block pointing at the Data + * Access export. The time series itself stays out of + * band — i3X serves the metadata, Data Access serves + * the payload. (The i3X history shape is one metric + * per object, so a multi-metric dataset cannot be + * served conformantly through /history.) + * - /related → HasComponent resolves a union's members and a + * session window's source dataset, so the dataset + * graph is walkable over pure i3X. + * + * Structure configs are fetched on demand rather than held in memory: + * union membership grows while a dataset is live, and a per-request + * read is always current. Class membership itself is cheap to poll and + * is refreshed on a timer (notify-driven refresh can replace this the + * same way L1 was resolved for devices). + * + * Deliberately separate from ObjectTree: the device pipeline (reactive + * refresh, UNS preservation, diffing) stays untouched, and the API + * layer consults this store as a fallback. + */ + +import { v5 as uuidv5 } from "uuid"; + +import type { I3xObject, I3xObjectType, I3xVqt } from "./types/i3x.js"; +import { toI3xObject, toI3xObjectType, toI3xVqt } from "./mapping.js"; +import { + RelType, + DATASET_CLASS_UUID, + DATASET_APP_SPARKPLUG_SRC, + DATASET_APP_SESSION_LIMITS, + DATASET_APP_UNION_COMPONENTS, + I3X_UUID_NAMESPACE, +} from "./constants.js"; + +export type DatasetType = "SparkplugSrc" | "SessionLimits" | "Union"; + +/** Synthetic elementIds, deterministic so restarts keep identity. */ +export const DATASET_OBJECTTYPE_ID = + uuidv5("i3x:objecttype:dataset", I3X_UUID_NAMESPACE); +export const DATASET_FOLDER_ID = + uuidv5("i3x:folder:datasets", I3X_UUID_NAMESPACE); + +/** JSON schema for the Dataset ObjectType (the /value descriptor). */ +const DATASET_SCHEMA = { + title: "Dataset", + description: "A Data Access dataset: a named, typed collection of " + + "recorded time-series data. The value is a descriptor; the " + + "content block points at the export.", + type: "object", + properties: { + datasetType: { enum: ["SparkplugSrc", "SessionLimits", "Union"] }, + source: { + type: "string", format: "uuid", + description: "SparkplugSrc: the device recorded. " + + "SessionLimits: the dataset the window is over.", + }, + coverage: { + type: "object", + properties: { + from: { type: ["string", "null"], format: "date-time" }, + to: { type: ["string", "null"], format: "date-time" }, + }, + }, + members: { + type: "array", items: { type: "string", format: "uuid" }, + description: "Union: the component dataset UUIDs, " + + "resolved recursively at export.", + }, + content: { + type: "object", + description: "Where and how to fetch the recorded data.", + properties: { + href: { type: "string", format: "uri" }, + method: { type: "string" }, + mediaType: { type: "string" }, + profile: { type: "string" }, + auth: { enum: ["basic", "none"] }, + }, + }, + }, + required: ["datasetType"], +} as const; + +interface DatasetStoreOpts { + fplus: any; + namespaceUri: string; + /** Public base URL of the Data Access service, for content hrefs. + * Omitted → descriptors carry no content block. */ + dataAccessUrl?: string; + /** Membership re-poll interval in ms; 0 disables the timer. */ + pollInterval?: number; + /** Resolves elementIds outside this store (the device tree), so a + * SparkplugSrc dataset's HasComponent can return its device. */ + resolve?: (elementId: string) => I3xObject | undefined; +} + +export class DatasetStore { + private fplus: any; + private namespaceUri: string; + private dataAccessUrl: string | null; + private pollInterval: number; + private log: (msg: string, ...args: any[]) => void; + + private resolve: (elementId: string) => I3xObject | undefined; + private folder: I3xObject; + private objectType: I3xObjectType; + private types: Map = new Map(); + private objects: Map = new Map(); + private timer: NodeJS.Timeout | null = null; + + constructor (opts: DatasetStoreOpts) { + this.fplus = opts.fplus; + this.namespaceUri = opts.namespaceUri; + this.dataAccessUrl = opts.dataAccessUrl?.replace(/\/+$/, "") ?? null; + this.pollInterval = opts.pollInterval ?? 60000; + this.resolve = opts.resolve ?? (() => undefined); + this.log = opts.fplus.debug?.bound?.("datasets") + ?? ((..._a: any[]) => {}); + + this.folder = toI3xObject( + DATASET_FOLDER_ID, "Datasets", "isa95-level", "/", true); + this.objectType = toI3xObjectType( + DATASET_OBJECTTYPE_ID, "Dataset", this.namespaceUri, + DATASET_CLASS_UUID, DATASET_SCHEMA); + } + + async init (): Promise { + await this.load(); + if (this.pollInterval > 0) { + this.timer = setInterval( + () => void this.load().catch(err => + this.log("dataset reload failed: %s", err)), + this.pollInterval); + this.timer.unref?.(); + } + return this; + } + + stop (): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + /** + * Rebuild the membership maps: which UUIDs are datasets, and which + * structure app each one carries. Three list calls, no per-dataset + * fetches. Swapped in atomically, same discipline as ObjectTree. + */ + private async load (): Promise { + const cdb = this.fplus.ConfigDB; + const [members, spark, sessions, unions] = await Promise.all([ + cdb.class_members(DATASET_CLASS_UUID).catch(() => []), + cdb.list_configs(DATASET_APP_SPARKPLUG_SRC).catch(() => []), + cdb.list_configs(DATASET_APP_SESSION_LIMITS).catch(() => []), + cdb.list_configs(DATASET_APP_UNION_COMPONENTS).catch(() => []), + ]); + const typeOf = new Map(); + for (const u of spark) typeOf.set(u, "SparkplugSrc"); + for (const u of sessions) typeOf.set(u, "SessionLimits"); + for (const u of unions) typeOf.set(u, "Union"); + + const types = new Map(); + const objects = new Map(); + for (const uuid of members as string[]) { + const type = typeOf.get(uuid); + if (!type) continue; // dataset with no structure yet + types.set(uuid, type); + objects.set(uuid, toI3xObject( + uuid, + `${type} dataset ${uuid.slice(0, 8)}`, + DATASET_OBJECTTYPE_ID, + DATASET_FOLDER_ID, + false)); + } + this.types = types; + this.objects = objects; + this.log("datasets loaded: %d of %d class members typed", + objects.size, (members as string[]).length); + } + + /* ---- Store surface consulted by the API layer ---- */ + + has (elementId: string): boolean { + return this.objects.has(elementId) + || elementId === DATASET_FOLDER_ID; + } + + getObject (elementId: string): I3xObject | undefined { + if (elementId === DATASET_FOLDER_ID) return this.folder; + return this.objects.get(elementId); + } + + getObjects (opts?: { typeElementId?: string; root?: boolean }): I3xObject[] { + if (opts?.typeElementId !== undefined + && opts.typeElementId !== DATASET_OBJECTTYPE_ID) return []; + if (opts?.root) { + return opts?.typeElementId === undefined ? [this.folder] : []; + } + const all = [...this.objects.values()]; + return opts?.typeElementId === undefined + ? [this.folder, ...all] : all; + } + + getObjectType (): I3xObjectType { + return this.objectType; + } + + /** + * The dataset's /value: a descriptor of what it is plus where the + * recorded data can be fetched. Reads the structure config fresh so + * a growing union is always current. + */ + async getValue (elementId: string): Promise { + const type = this.types.get(elementId); + if (!type) return null; + const cdb = this.fplus.ConfigDB; + const value: Record = { datasetType: type }; + + if (type === "SparkplugSrc") { + const cfg = await cdb.get_config( + DATASET_APP_SPARKPLUG_SRC, elementId).catch(() => null); + if (cfg?.source) value.source = cfg.source; + value.coverage = { from: null, to: null }; + } + else if (type === "SessionLimits") { + const cfg = await cdb.get_config( + DATASET_APP_SESSION_LIMITS, elementId).catch(() => null); + if (cfg?.source) value.source = cfg.source; + value.coverage = { from: cfg?.from ?? null, to: cfg?.to ?? null }; + } + else { + const cfg = await cdb.get_config( + DATASET_APP_UNION_COMPONENTS, elementId).catch(() => null); + value.members = Array.isArray(cfg) ? cfg : []; + } + + if (this.dataAccessUrl) { + value.content = { + href: `${this.dataAccessUrl}/v1/data/${elementId}`, + method: "POST", + mediaType: "application/zip", + profile: "csv-per-device", + auth: "basic", + }; + } + return toI3xVqt(value, "Good", new Date()); + } + + /** + * HasParent/HasChildren mirror the folder tree. HasComponent + * resolves what the dataset is made of: a union's members, or a + * session window's source dataset. ComponentOf is not served (a + * reverse index over every union would be needed; the export path + * resolves forwards only, so does this). + */ + async getRelated (elementId: string, relationshipType?: string): + Promise { + if (elementId === DATASET_FOLDER_ID) { + const result: I3xObject[] = []; + if (relationshipType === undefined + || relationshipType === RelType.HasChildren) + result.push(...this.objects.values()); + return result; + } + const type = this.types.get(elementId); + if (!type) return null; + + const result: I3xObject[] = []; + if (relationshipType === undefined + || relationshipType === RelType.HasParent) + result.push(this.folder); + + if (relationshipType === undefined + || relationshipType === RelType.HasComponent) { + const cdb = this.fplus.ConfigDB; + const componentIds: string[] = []; + if (type === "Union") { + const cfg = await cdb.get_config( + DATASET_APP_UNION_COMPONENTS, elementId).catch(() => null); + if (Array.isArray(cfg)) componentIds.push(...cfg); + } + else { + /* A session window's component is its source dataset; + * a device stream's component is the device itself. */ + const app = type === "SessionLimits" + ? DATASET_APP_SESSION_LIMITS : DATASET_APP_SPARKPLUG_SRC; + const cfg = await cdb.get_config(app, elementId) + .catch(() => null); + if (cfg?.source) componentIds.push(cfg.source); + } + for (const id of componentIds) { + const obj = this.objects.get(id) ?? this.resolve(id); + if (obj) result.push(obj); + } + } + return result; + } +} diff --git a/acs-i3x/lib/routes.ts b/acs-i3x/lib/routes.ts index 0a51f9c04..7fe0588c7 100644 --- a/acs-i3x/lib/routes.ts +++ b/acs-i3x/lib/routes.ts @@ -6,12 +6,14 @@ import type { ObjectTree } from "./object-tree.js"; import type { ValueCache } from "./value-cache.js"; import type { History } from "./history.js"; import type { SubscriptionManager } from "./subscriptions.js"; +import type { DatasetStore } from "./datasets.js"; export function routes(opts: { objectTree: ObjectTree; valueCache: ValueCache; history: History; subscriptions: SubscriptionManager; + datasets?: DatasetStore; mcpServer?: McpServer; maxDepthCap?: number; }) { diff --git a/acs-i3x/test/datasets.test.ts b/acs-i3x/test/datasets.test.ts new file mode 100644 index 000000000..484a6fab8 --- /dev/null +++ b/acs-i3x/test/datasets.test.ts @@ -0,0 +1,185 @@ +/* + * Copyright (c) University of Sheffield AMRC 2026. + */ + +/* + * DatasetStore — Data Access datasets served as i3X objects. + */ + +import { jest } from "@jest/globals"; + +import { + DatasetStore, + DATASET_OBJECTTYPE_ID, + DATASET_FOLDER_ID, +} from "../lib/datasets.js"; +import { + RelType, + DATASET_CLASS_UUID, + DATASET_APP_SPARKPLUG_SRC, + DATASET_APP_SESSION_LIMITS, + DATASET_APP_UNION_COMPONENTS, +} from "../lib/constants.js"; + +const MACHINE = "bf62a1c3-8afe-4eaa-af79-12ae88a7384c"; +const SESSION = "1539d17f-6111-463a-a92b-592f81e15fd7"; +const UNION = "27cb40bf-1fa4-4e69-b0d7-dc933282618b"; +const DEVICE = "d2de2fa1-fccf-488a-8ddd-ec3da0e3c909"; + +/** A ConfigDB shaped like the demo cluster: one device stream, one + * session window over it, one union containing the session. */ +function mockFplus() { + const configs: Record> = { + [DATASET_APP_SPARKPLUG_SRC]: { + [MACHINE]: { source: DEVICE }, + }, + [DATASET_APP_SESSION_LIMITS]: { + [SESSION]: { + source: MACHINE, + from: "2026-08-20T08:18:46.729Z", + to: "2026-08-20T08:23:46.729Z", + }, + }, + [DATASET_APP_UNION_COMPONENTS]: { + [UNION]: [SESSION], + }, + }; + return { + ConfigDB: { + class_members: jest.fn(async (klass: string) => + klass === DATASET_CLASS_UUID + ? [MACHINE, SESSION, UNION, "no-structure-yet"] + : []), + list_configs: jest.fn(async (app: string) => + Object.keys(configs[app] ?? {})), + get_config: jest.fn(async (app: string, obj: string) => { + const cfg = configs[app]?.[obj]; + if (cfg === undefined) throw new Error("404"); + return cfg; + }), + }, + debug: { bound: () => (..._a: any[]) => {} }, + configs, + }; +} + +const makeStore = (fplus = mockFplus(), extra = {}) => + new DatasetStore({ + fplus, + namespaceUri: "https://test/i3x", + dataAccessUrl: "https://da.test/", + pollInterval: 0, + ...extra, + }).init(); + +test("datasets load as objects under the folder; untyped members are skipped", async () => { + const store = await makeStore(); + expect(store.getObject(MACHINE)).toMatchObject({ + elementId: MACHINE, + typeElementId: DATASET_OBJECTTYPE_ID, + parentId: DATASET_FOLDER_ID, + isComposition: false, + }); + expect(store.getObject("no-structure-yet")).toBeUndefined(); + expect(store.getObject(DATASET_FOLDER_ID)).toMatchObject({ + displayName: "Datasets", parentId: "/", isComposition: true }); + + const all = store.getObjects(); + expect(all.map(o => o.elementId).sort()) + .toEqual([DATASET_FOLDER_ID, SESSION, MACHINE, UNION].sort()); + expect(store.getObjects({ root: true })) + .toEqual([store.getObject(DATASET_FOLDER_ID)]); + expect(store.getObjects({ typeElementId: DATASET_OBJECTTYPE_ID })) + .toHaveLength(3); + expect(store.getObjects({ typeElementId: "something-else" })) + .toEqual([]); +}); + +test("a device stream's value describes the source and carries the export href", async () => { + const store = await makeStore(); + const vqt = await store.getValue(MACHINE); + expect(vqt).toMatchObject({ + quality: "Good", + value: { + datasetType: "SparkplugSrc", + source: DEVICE, + coverage: { from: null, to: null }, + content: { + href: `https://da.test/v1/data/${MACHINE}`, + method: "POST", + mediaType: "application/zip", + profile: "csv-per-device", + auth: "basic", + }, + }, + }); +}); + +test("a session window's value carries its coverage; a union lists members", async () => { + const store = await makeStore(); + expect((await store.getValue(SESSION))?.value).toMatchObject({ + datasetType: "SessionLimits", + source: MACHINE, + coverage: { + from: "2026-08-20T08:18:46.729Z", + to: "2026-08-20T08:23:46.729Z", + }, + }); + expect((await store.getValue(UNION))?.value).toMatchObject({ + datasetType: "Union", + members: [SESSION], + }); + expect(await store.getValue("not-a-dataset")).toBeNull(); +}); + +test("no configured Data Access URL means no content block", async () => { + const store = await makeStore(mockFplus(), { dataAccessUrl: undefined }); + const vqt = await store.getValue(MACHINE); + expect((vqt?.value as any).content).toBeUndefined(); +}); + +test("union membership is read fresh, so appends show without a reload", async () => { + const fplus = mockFplus(); + const store = await makeStore(fplus); + fplus.configs[DATASET_APP_UNION_COMPONENTS][UNION] = [SESSION, MACHINE]; + expect((await store.getValue(UNION))?.value).toMatchObject({ + members: [SESSION, MACHINE] }); +}); + +test("HasComponent walks the dataset graph down to the device", async () => { + const store = await makeStore(mockFplus(), { + resolve: (id: string) => id === DEVICE + ? { elementId: DEVICE, displayName: "CNC", + typeElementId: "schema", parentId: "/", + isComposition: true, isExtended: false } + : undefined, + }); + const unionRel = await store.getRelated(UNION, RelType.HasComponent); + expect(unionRel?.map(o => o.elementId)).toEqual([SESSION]); + + const sessionRel = await store.getRelated(SESSION, RelType.HasComponent); + expect(sessionRel?.map(o => o.elementId)).toEqual([MACHINE]); + + /* The device stream's component is the device — resolved from the + * object tree, not the store. */ + const machineRel = await store.getRelated(MACHINE, RelType.HasComponent); + expect(machineRel?.map(o => o.elementId)).toEqual([DEVICE]); +}); + +test("unfiltered related includes the folder parent; the folder lists children", async () => { + const store = await makeStore(); + const rel = await store.getRelated(UNION); + expect(rel?.some(o => o.elementId === DATASET_FOLDER_ID)).toBe(true); + const kids = await store.getRelated(DATASET_FOLDER_ID); + expect(kids?.map(o => o.elementId).sort()) + .toEqual([SESSION, MACHINE, UNION].sort()); + expect(await store.getRelated("not-a-dataset")).toBeNull(); +}); + +test("the Dataset ObjectType carries a real schema", async () => { + const store = await makeStore(); + const t = store.getObjectType(); + expect(t.elementId).toBe(DATASET_OBJECTTYPE_ID); + expect((t.schema as any).properties.datasetType.enum) + .toEqual(["SparkplugSrc", "SessionLimits", "Union"]); +});