@uns-kit/api 3.0.18 → 3.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,12 +7,12 @@ For the MQTT topic registry published by the API plugin, see `../../docs/uns-top
7
7
 
8
8
  ## uns-kit in context
9
9
 
10
- | Package | Description |
11
- | --- | --- |
10
+ | Package | Description |
11
+ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
12
12
  | [`@uns-kit/core`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-core) | Base runtime (UnsProxyProcess, MQTT helpers, config tooling, gRPC gateway). |
13
- | [`@uns-kit/api`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-api) | Express plugin — HTTP endpoints, JWT/JWKS auth, Swagger, UNS metadata. |
14
- | [`@uns-kit/cron`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence. |
15
- | [`@uns-kit/cli`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cli) | CLI for scaffolding new UNS applications. |
13
+ | [`@uns-kit/api`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-api) | Express plugin — HTTP endpoints, JWT/JWKS auth, Swagger, UNS metadata. |
14
+ | [`@uns-kit/cron`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cron) | Cron-driven scheduler that emits UNS events on a fixed cadence. |
15
+ | [`@uns-kit/cli`](https://github.com/uns-openhub/uns-kit/tree/main/packages/uns-cli) | CLI for scaffolding new UNS applications. |
16
16
 
17
17
  ## Installation
18
18
 
@@ -32,6 +32,7 @@ npm install @uns-kit/api
32
32
  4. Listen to `apiGetEvent` / `apiPostEvent` to handle incoming requests.
33
33
 
34
34
  Every registered endpoint is:
35
+
35
36
  - Automatically secured with JWT/JWKS or a shared secret.
36
37
  - Published to the UNS controller as an API metadata record (topic, host, path, method).
37
38
  - Added to the Swagger spec served at `/<processName>/<instanceName>/swagger.json`.
@@ -39,10 +40,10 @@ Every registered endpoint is:
39
40
  ## GET endpoint example
40
41
 
41
42
  ```ts
42
- import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
43
- import type { UnsEvents } from "@uns-kit/core";
44
43
  import "@uns-kit/api";
45
44
  import { type UnsProxyProcessWithApi } from "@uns-kit/api";
45
+ import type { UnsEvents } from "@uns-kit/core";
46
+ import UnsProxyProcess from "@uns-kit/core/uns/uns-proxy-process";
46
47
 
47
48
  const config = await ConfigFile.loadConfig();
48
49
  const proc = new UnsProxyProcess(config.infra.host!, { processName: config.uns.processName }) as UnsProxyProcessWithApi;
@@ -54,23 +55,16 @@ const api = await proc.createApiProxy("my-service", {
54
55
 
55
56
  // Register a GET endpoint
56
57
  // Signature: api.get(topic, asset, objectType, objectId, attribute, options?)
57
- await api.get(
58
- "enterprise/site/area/line/",
59
- "line-3-furnace",
60
- "energy-resource",
61
- "main-bus",
62
- "current",
63
- {
64
- tags: ["Energy"],
65
- apiDescription: "Current reading for line-3-furnace main-bus",
66
- queryParams: [
67
- { name: "from", type: "string", required: false, description: "Start of time range (ISO 8601)", chatCanonical: "from" },
68
- { name: "to", type: "string", required: false, description: "End of time range (ISO 8601)", chatCanonical: "to" },
69
- { name: "limit", type: "number", required: false, description: "Maximum records", chatCanonical: "limit", defaultValue: 100 },
70
- ],
71
- chatDefaults: { limit: 100 },
72
- }
73
- );
58
+ await api.get("enterprise/site/area/line/", "line-3-furnace", "energy-resource", "main-bus", "current", {
59
+ tags: ["Energy"],
60
+ apiDescription: "Current reading for line-3-furnace main-bus",
61
+ queryParams: [
62
+ { name: "from", type: "string", required: false, description: "Start of time range (ISO 8601)", chatCanonical: "from" },
63
+ { name: "to", type: "string", required: false, description: "End of time range (ISO 8601)", chatCanonical: "to" },
64
+ { name: "limit", type: "number", required: false, description: "Maximum records", chatCanonical: "limit", defaultValue: 100 },
65
+ ],
66
+ chatDefaults: { limit: 100 },
67
+ });
74
68
 
75
69
  // Handle incoming GET requests
76
70
  api.event.on("apiGetEvent", (event: UnsEvents["apiGetEvent"]) => {
@@ -85,29 +79,22 @@ api.event.on("apiGetEvent", (event: UnsEvents["apiGetEvent"]) => {
85
79
  ```ts
86
80
  // Register a POST endpoint
87
81
  // Signature: api.post(topic, asset, objectType, objectId, attribute, options?)
88
- await api.post(
89
- "enterprise/site/area/line/",
90
- "line-3-furnace",
91
- "energy-resource",
92
- "main-bus",
93
- "setpoint",
94
- {
95
- tags: ["Energy"],
96
- apiDescription: "Write a new setpoint for line-3-furnace main-bus",
97
- requestBody: {
98
- description: "Setpoint payload",
99
- required: true,
100
- schema: {
101
- type: "object",
102
- required: ["value"],
103
- properties: {
104
- value: { type: "number", description: "Target setpoint value" },
105
- unit: { type: "string", description: "Unit of measurement, e.g. A" },
106
- },
82
+ await api.post("enterprise/site/area/line/", "line-3-furnace", "energy-resource", "main-bus", "setpoint", {
83
+ tags: ["Energy"],
84
+ apiDescription: "Write a new setpoint for line-3-furnace main-bus",
85
+ requestBody: {
86
+ description: "Setpoint payload",
87
+ required: true,
88
+ schema: {
89
+ type: "object",
90
+ required: ["value"],
91
+ properties: {
92
+ value: { type: "number", description: "Target setpoint value" },
93
+ unit: { type: "string", description: "Unit of measurement, e.g. A" },
107
94
  },
108
95
  },
109
- }
110
- );
96
+ },
97
+ });
111
98
 
112
99
  // Handle incoming POST requests — body is pre-parsed JSON
113
100
  api.event.on("apiPostEvent", (event: UnsEvents["apiPostEvent"]) => {
@@ -122,6 +109,7 @@ api.event.on("apiPostEvent", (event: UnsEvents["apiPostEvent"]) => {
122
109
  ```ts
123
110
  import "@uns-kit/api";
124
111
  import {
112
+ type UnsProxyProcessWithApi,
125
113
  defineDataCatalogField,
126
114
  defineDataCatalogOfferSource,
127
115
  defineDataCatalogQueryParam,
@@ -129,7 +117,6 @@ import {
129
117
  defineServiceApi,
130
118
  projectRowsForDataCatalogSchema,
131
119
  registerApiCatalog,
132
- type UnsProxyProcessWithApi,
133
120
  } from "@uns-kit/api";
134
121
  import { ConfigFile, UnsProxyProcess } from "@uns-kit/core";
135
122
 
@@ -232,19 +219,17 @@ const dataOfferSources = {
232
219
  description: "Browse production orders.",
233
220
  method: "GET",
234
221
  tags: ["orders"],
235
- queryParams: [
236
- defineDataCatalogQueryParam("status", "Optional status filter"),
237
- ],
222
+ queryParams: [defineDataCatalogQueryParam("status", "Optional status filter")],
238
223
  schema: productionOrdersSchema,
239
224
  response: {
240
225
  statusCode: "200",
241
226
  contentType: "application/json",
242
227
  },
243
228
  handler: async (event: any) => {
244
- const statusFilter = String(event.req.query.status ?? "").trim().toLowerCase();
245
- const filteredRows = statusFilter
246
- ? orderRows.filter((row) => row.STATUS.toLowerCase() === statusFilter)
247
- : orderRows;
229
+ const statusFilter = String(event.req.query.status ?? "")
230
+ .trim()
231
+ .toLowerCase();
232
+ const filteredRows = statusFilter ? orderRows.filter((row) => row.STATUS.toLowerCase() === statusFilter) : orderRows;
248
233
  const data = projectRowsForDataCatalogSchema(filteredRows, productionOrderRowSchema);
249
234
 
250
235
  event.res.json({
@@ -262,6 +247,32 @@ await registerApiCatalog(api, {
262
247
  });
263
248
  ```
264
249
 
250
+ ## Stream a Data Catalog Parquet export
251
+
252
+ `writeSchemaRowsToParquetStream` accepts a synchronous or asynchronous row
253
+ iterator and writes one Parquet row group at a time. It never collects the whole
254
+ source in memory. Pass an already bounded/paged history or database iterator;
255
+ the writer cannot reduce memory used by a source that loads every row first.
256
+
257
+ ```ts
258
+ import { writeSchemaRowsToParquetStream } from "@uns-kit/api";
259
+
260
+ const filePath = await writeSchemaRowsToParquetStream({
261
+ schema: rowSchema,
262
+ rows: historyRows(), // AsyncIterable<Record<string, unknown>>
263
+ rowGroupSize: 2_000,
264
+ signal: requestAbortSignal,
265
+ });
266
+ // Stream filePath to the caller, then remove it when the response closes.
267
+ ```
268
+
269
+ The helper creates the output directory when needed, writes to a unique
270
+ incomplete file, and renames it only after success. An error or cancellation
271
+ removes the incomplete file and rejects the promise. The caller owns the
272
+ completed file and its cleanup. The older `writeSchemaRowsToParquet` still
273
+ returns `null` on failure for existing applications, but now also accepts a
274
+ row iterator and the same row-group options.
275
+
265
276
  ## Endpoint signature
266
277
 
267
278
  ```
@@ -269,22 +280,22 @@ api.get(topic, asset, objectType, objectId, attribute, options?)
269
280
  api.post(topic, asset, objectType, objectId, attribute, options?)
270
281
  ```
271
282
 
272
- | Parameter | Type | Description |
273
- |---|---|---|
274
- | `topic` | `UnsTopics` | UNS topic path prefix (e.g. `"enterprise/site/area/line/"`) |
275
- | `asset` | `UnsAsset` | Asset identifier (e.g. `"line-3-furnace"`) |
276
- | `objectType` | `UnsObjectType` | UNS object type (e.g. `"energy-resource"`) |
277
- | `objectId` | `UnsObjectId` | Object instance id (e.g. `"main-bus"`) |
278
- | `attribute` | `UnsAttribute` | Attribute name (e.g. `"current"`) |
279
- | `options` | `IGetEndpointOptions` / `IPostEndpointOptions` | Tags, description, query params / request body |
283
+ | Parameter | Type | Description |
284
+ | ------------ | ---------------------------------------------- | ----------------------------------------------------------- |
285
+ | `topic` | `UnsTopics` | UNS topic path prefix (e.g. `"enterprise/site/area/line/"`) |
286
+ | `asset` | `UnsAsset` | Asset identifier (e.g. `"line-3-furnace"`) |
287
+ | `objectType` | `UnsObjectType` | UNS object type (e.g. `"energy-resource"`) |
288
+ | `objectId` | `UnsObjectId` | Object instance id (e.g. `"main-bus"`) |
289
+ | `attribute` | `UnsAttribute` | Attribute name (e.g. `"current"`) |
290
+ | `options` | `IGetEndpointOptions` / `IPostEndpointOptions` | Tags, description, query params / request body |
280
291
 
281
292
  ## Auth options
282
293
 
283
- | Option | When to use |
284
- |---|---|
294
+ | Option | When to use |
295
+ | ----------------------- | ------------------------------------------------------------------------ |
285
296
  | `jwks.wellKnownJwksUrl` | Production — verifies RS256 tokens from the UNS controller JWKS endpoint |
286
- | `jwks.activeKidUrl` | Optional companion to JWKS — narrows which key ID is active |
287
- | `jwtSecret` | Development / simple deployments — symmetric secret |
297
+ | `jwks.activeKidUrl` | Optional companion to JWKS — narrows which key ID is active |
298
+ | `jwtSecret` | Development / simple deployments — symmetric secret |
288
299
 
289
300
  Requests that fail auth return `401 Unauthorized`. Requests whose token `accessRules` do not match the endpoint path return `403 Forbidden`.
290
301
 
package/dist/parquet.d.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import { type ColumnSource } from "hyparquet-writer";
2
2
  import type { DataCatalogSchemaRegistration } from "./api-interfaces.js";
3
3
  export type CatalogParquetColumn = Omit<ColumnSource, "data">;
4
- export declare function writeSchemaRowsToParquet(input: {
5
- rows: Array<Record<string, unknown>>;
4
+ export type CatalogParquetRows = Iterable<Record<string, unknown>> | AsyncIterable<Record<string, unknown>>;
5
+ export type CatalogParquetWriteInput = {
6
+ rows: CatalogParquetRows;
6
7
  schema: DataCatalogSchemaRegistration;
7
8
  outputDir?: string;
8
9
  fileName?: string;
9
- }): Promise<string | null>;
10
+ /** Maximum source rows buffered for one Parquet row group. */
11
+ rowGroupSize?: number;
12
+ signal?: AbortSignal;
13
+ };
14
+ /**
15
+ * Write catalog rows a group at a time. The input can be a database cursor or
16
+ * another async source; it is never collected into one array. A failed write
17
+ * leaves no incomplete file at the requested path.
18
+ */
19
+ export declare function writeSchemaRowsToParquetStream(input: CatalogParquetWriteInput): Promise<string>;
20
+ /** Legacy null-on-error interface retained for existing callers. */
21
+ export declare function writeSchemaRowsToParquet(input: CatalogParquetWriteInput): Promise<string | null>;
10
22
  export declare function buildParquetSchemaFromCatalogSchema(schema: DataCatalogSchemaRegistration): CatalogParquetColumn[];
package/dist/parquet.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import logger from "@uns-kit/core/logger.js";
2
2
  import { randomUUID } from "crypto";
3
- import fs from "fs";
3
+ import fs from "fs/promises";
4
4
  import { fileWriter, parquetWriteRows } from "hyparquet-writer";
5
5
  import os from "os";
6
6
  import path from "path";
@@ -12,30 +12,61 @@ const PARQUET_TYPE_MAP = {
12
12
  date: "TIMESTAMP",
13
13
  "date-time": "TIMESTAMP",
14
14
  };
15
- export async function writeSchemaRowsToParquet(input) {
15
+ /**
16
+ * Write catalog rows a group at a time. The input can be a database cursor or
17
+ * another async source; it is never collected into one array. A failed write
18
+ * leaves no incomplete file at the requested path.
19
+ */
20
+ export async function writeSchemaRowsToParquetStream(input) {
21
+ const outputDir = input.outputDir ?? path.join(os.tmpdir(), "uns-data-offers");
22
+ const fileName = input.fileName ?? `${randomUUID()}.parquet`;
23
+ if (fileName !== path.basename(fileName) || fileName === "." || fileName === "..") {
24
+ throw new Error("Parquet fileName must be a file name without a directory.");
25
+ }
26
+ const rowGroupSize = input.rowGroupSize ?? 2_000;
27
+ if (!Number.isSafeInteger(rowGroupSize) || rowGroupSize < 1 || rowGroupSize > 10_000) {
28
+ throw new Error("Parquet rowGroupSize must be between 1 and 10,000.");
29
+ }
30
+ const columns = buildParquetSchemaFromCatalogSchema(input.schema);
31
+ if (!columns.length) {
32
+ throw new Error(`Parquet schema '${input.schema.id}' has no supported fields.`);
33
+ }
34
+ const filePath = path.join(outputDir, fileName);
35
+ const incompletePath = `${filePath}.${randomUUID()}.partial`;
36
+ await fs.mkdir(outputDir, { recursive: true });
16
37
  try {
17
- const outputDir = input.outputDir ?? path.join(os.tmpdir(), "uns-data-offers");
18
- const fileName = input.fileName ?? `${randomUUID()}.parquet`;
19
- const filePath = path.join(outputDir, fileName);
20
- if (!fs.existsSync(outputDir)) {
21
- fs.mkdirSync(outputDir, { recursive: true });
22
- }
23
- const columns = buildParquetSchemaFromCatalogSchema(input.schema);
24
- if (!columns.length) {
25
- throw new Error(`Parquet schema '${input.schema.id}' has no supported fields.`);
26
- }
27
38
  await parquetWriteRows({
28
- writer: fileWriter(filePath),
29
- rows: input.rows.map((row) => normalizeParquetRow(row, columns)),
39
+ writer: fileWriter(incompletePath),
40
+ rows: normalizeParquetRows(input.rows, columns, input.signal),
30
41
  columns,
42
+ rowGroupSize,
31
43
  });
44
+ input.signal?.throwIfAborted();
45
+ await fs.rename(incompletePath, filePath);
32
46
  return filePath;
33
47
  }
48
+ catch (error) {
49
+ await fs.rm(incompletePath, { force: true });
50
+ throw error;
51
+ }
52
+ }
53
+ /** Legacy null-on-error interface retained for existing callers. */
54
+ export async function writeSchemaRowsToParquet(input) {
55
+ try {
56
+ return await writeSchemaRowsToParquetStream(input);
57
+ }
34
58
  catch (error) {
35
59
  logger.error("Failed to write schema-driven parquet:", error);
36
60
  return null;
37
61
  }
38
62
  }
63
+ async function* normalizeParquetRows(rows, columns, signal) {
64
+ for await (const row of rows) {
65
+ signal?.throwIfAborted();
66
+ yield normalizeParquetRow(row, columns);
67
+ }
68
+ signal?.throwIfAborted();
69
+ }
39
70
  export function buildParquetSchemaFromCatalogSchema(schema) {
40
71
  const columns = [];
41
72
  for (const field of schema.fields ?? []) {
@@ -61,7 +92,12 @@ function normalizeParquetRow(row, columns) {
61
92
  const normalized = {};
62
93
  for (const column of columns) {
63
94
  const value = row[column.name];
64
- normalized[column.name] = column.type === "INT64" && typeof value === "number" ? BigInt(value) : value instanceof Date ? new Date(value) : value;
95
+ normalized[column.name] =
96
+ column.type === "INT64" && typeof value === "number"
97
+ ? BigInt(value)
98
+ : column.type === "TIMESTAMP" && typeof value === "string"
99
+ ? new Date(value)
100
+ : value;
65
101
  }
66
102
  return normalized;
67
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uns-kit/api",
3
- "version": "3.0.18",
3
+ "version": "3.0.20",
4
4
  "description": "Express-powered API gateway plugin for UnsProxyProcess with JWT/JWKS support.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "multer": "^2.2.0",
38
38
  "hyparquet-writer": "^0.16.5",
39
39
  "uuid": "^14.0.1",
40
- "@uns-kit/core": "3.0.18"
40
+ "@uns-kit/core": "3.0.20"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/cookie-parser": "^1.4.10",