From 7300c097f78226ea1ff851a0668f7aafebaff483 Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 20 Aug 2026 11:51:30 +0100 Subject: [PATCH 1/3] i3x: DatasetStore serves Data Access datasets as i3X objects Datasets are ConfigDB objects (Dataset class + one structure app per type), so the store builds its object set from three list calls and class membership, re-polled on a timer. Each dataset sits under a synthetic Datasets folder with a shared Dataset ObjectType carrying a real schema. /value returns a descriptor: the dataset type, its structure, and a DCAT-shaped content block with the Data Access export href. Structure configs are read per request so a growing union stays current. HasComponent walks union -> members, session -> source dataset, device stream -> device (via an injected resolver). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MtATkkjtiFF8L6z98NFD8A --- acs-i3x/lib/constants.ts | 12 ++ acs-i3x/lib/datasets.ts | 312 ++++++++++++++++++++++++++++++++++ acs-i3x/test/datasets.test.ts | 185 ++++++++++++++++++++ 3 files changed, 509 insertions(+) create mode 100644 acs-i3x/lib/datasets.ts create mode 100644 acs-i3x/test/datasets.test.ts 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/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"]); +}); From 30ceb31b61ba3de7c38c8224fb96da845c7e870c Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 20 Aug 2026 11:51:30 +0100 Subject: [PATCH 2/3] i3x: API layer consults the dataset store on every object route Objects, types, list, value, related and history all fall back to the DatasetStore. Dataset history answers 404 pointing at the content href in the value descriptor - the i3X history shape cannot express a multi-metric dataset. related routes become async for the store's per-request ConfigDB reads. I3X_DATA_ACCESS_EXTERNAL_URL configures the public export base; unset means descriptors carry no content block. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MtATkkjtiFF8L6z98NFD8A --- acs-i3x/bin/api.ts | 12 ++++++ acs-i3x/lib/api-v1.ts | 94 +++++++++++++++++++++++++++++++++++-------- acs-i3x/lib/routes.ts | 2 + 3 files changed, 92 insertions(+), 16 deletions(-) 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/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/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; }) { From 047031d884b46c3b7c721130e084e51476de09dd Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 20 Aug 2026 11:51:30 +0100 Subject: [PATCH 3/3] i3x docs: dataset design section and TID L5 (multi-metric history gap) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MtATkkjtiFF8L6z98NFD8A --- acs-i3x/docs/design.md | 25 +++++++++++++++++++++++++ acs-i3x/docs/to-improve.md | 1 + 2 files changed, 26 insertions(+) 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 |