@uns-kit/cli 2.0.11 → 2.0.13

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/dist/index.js CHANGED
@@ -171,7 +171,6 @@ function printHelp() {
171
171
  " create <name> Scaffold a new UNS application\n" +
172
172
  " configure [dir] [features...] Configure multiple templates (--all, --overwrite)\n" +
173
173
  " configure-templates [dir] [templates...] Copy any template directory (--all, --overwrite)\n" +
174
- " configure-config [dir] Copy configuration example files\n" +
175
174
  " configure-devops [dir] Configure Azure DevOps tooling in an existing project\n" +
176
175
  " configure-vscode [dir] Add VS Code workspace configuration files\n" +
177
176
  " configure-codegen [dir] Copy GraphQL codegen template and dependencies\n" +
@@ -187,6 +186,8 @@ async function createProject(projectName) {
187
186
  await ensureTargetDir(targetDir);
188
187
  const templateDir = path.resolve(__dirname, "../templates/default");
189
188
  await copyTemplateDirectory(templateDir, targetDir, targetDir);
189
+ // Seed config examples automatically (previously required configure-config).
190
+ await configureConfigFiles(targetDir, { overwrite: false });
190
191
  const pkgName = normalizePackageName(projectName);
191
192
  await patchPackageJson(targetDir, pkgName);
192
193
  await patchConfigJson(targetDir, pkgName);
@@ -600,7 +601,6 @@ async function configureUnsReference(targetPath, options = {}) {
600
601
  const configureFeatureHandlers = {
601
602
  devops: configureDevops,
602
603
  vscode: configureVscode,
603
- config: configureConfigFiles,
604
604
  codegen: configureCodegen,
605
605
  api: configureApi,
606
606
  cron: configureCron,
@@ -612,7 +612,6 @@ const AVAILABLE_CONFIGURE_FEATURES = Object.keys(configureFeatureHandlers);
612
612
  const configureFeatureLabels = {
613
613
  devops: "Azure DevOps tooling",
614
614
  vscode: "VS Code workspace",
615
- config: "Configuration example files",
616
615
  codegen: "GraphQL codegen tooling",
617
616
  api: "UNS API resources",
618
617
  cron: "UNS cron resources",
@@ -808,8 +807,6 @@ const configureFeatureAliases = {
808
807
  "configure-devops": "devops",
809
808
  vscode: "vscode",
810
809
  "configure-vscode": "vscode",
811
- config: "config",
812
- "configure-config": "config",
813
810
  codegen: "codegen",
814
811
  "configure-codegen": "codegen",
815
812
  api: "api",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uns-kit/cli",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "Command line scaffolding tool for UNS applications",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,13 +26,13 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "azure-devops-node-api": "^15.1.1",
29
- "@uns-kit/core": "2.0.11"
29
+ "@uns-kit/core": "2.0.13"
30
30
  },
31
31
  "unsKitPackages": {
32
- "@uns-kit/core": "2.0.11",
33
- "@uns-kit/api": "2.0.11",
34
- "@uns-kit/cron": "2.0.11",
35
- "@uns-kit/temporal": "2.0.11"
32
+ "@uns-kit/core": "2.0.13",
33
+ "@uns-kit/api": "2.0.13",
34
+ "@uns-kit/cron": "2.0.13",
35
+ "@uns-kit/temporal": "2.0.13"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.build.json",
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Moving-window table load test.
3
+ *
4
+ * Publishes time buckets as UNS table messages with:
5
+ * - `time` (bucket identity; uses `intervalStart`)
6
+ * - `intervalStart` / `intervalEnd` (bucket boundaries)
7
+ * - `windowStart` / `windowEnd` (snapshot window boundaries)
8
+ *
9
+ * This is meant to validate `uns-archiver` ingest modes:
10
+ * - `dedup`: upsert by (eventId + time + symbols). Missing buckets can be marked via `deleted: true`.
11
+ * - `window_replace`: delete rows in the window, then insert the snapshot (omitting missing buckets).
12
+ *
13
+ * Run:
14
+ * pnpm exec tsx src/examples/table-window-load-test.ts
15
+ *
16
+ * Optional args:
17
+ * --run=all|1|2|3
18
+ * --mode=window_replace|dedup|append
19
+ * --bucketMinutes=30
20
+ * --windowHours=2
21
+ * --stepHours=1
22
+ * --anchorEnd=2026-02-04T12:00:00.000Z
23
+ * --dataGroup=production_window
24
+ * --delayMs=0
25
+ * --pause=true
26
+ */
27
+
28
+ import { randomUUID } from "node:crypto";
29
+ import * as readline from "node:readline/promises";
30
+ import { stdin as input, stdout as output } from "node:process";
31
+ import { ConfigFile, logger } from "@uns-kit/core";
32
+ import UnsMqttProxy from "@uns-kit/core/uns-mqtt/uns-mqtt-proxy.js";
33
+ import { registerAttributeDescriptions, registerObjectTypeDescriptions } from "@uns-kit/core/uns/uns-dictionary-registry.js";
34
+ import { UnsPacket } from "@uns-kit/core/uns/uns-packet.js";
35
+ import { UnsTopics } from "@uns-kit/core/uns/uns-topics.js";
36
+ import type { ISO8601, IUnsMessage, IUnsTableColumn } from "@uns-kit/core/uns/uns-interfaces.js";
37
+ import {
38
+ GeneratedAttributes,
39
+ GeneratedAttributeDescriptions,
40
+ GeneratedObjectTypes,
41
+ GeneratedObjectTypeDescriptions,
42
+ } from "../uns/uns-dictionary.generated.js";
43
+ import { resolveGeneratedAsset } from "../uns/uns-assets.js";
44
+ import { GeneratedPhysicalMeasurements } from "../uns/uns-measurements.generated.js";
45
+
46
+ type RunSelection = "all" | "1" | "2" | "3";
47
+ type IngestMode = "append" | "dedup" | "window_replace";
48
+
49
+ type CliArgs = {
50
+ run: RunSelection;
51
+ mode: IngestMode;
52
+ bucketMinutes: number;
53
+ windowHours: number;
54
+ stepHours: number;
55
+ anchorEnd: Date;
56
+ dataGroup: string;
57
+ delayMs: number;
58
+ pause: boolean;
59
+ };
60
+
61
+ const DEFAULTS: Omit<CliArgs, "anchorEnd"> & { anchorEnd?: Date } = {
62
+ run: "all",
63
+ mode: "window_replace",
64
+ bucketMinutes: 30,
65
+ windowHours: 2,
66
+ stepHours: 1,
67
+ dataGroup: "production_window",
68
+ delayMs: 0,
69
+ pause: true,
70
+ };
71
+
72
+ function parseArgs(argv: string[]): Partial<CliArgs> {
73
+ const map = new Map<string, string>();
74
+ for (let i = 0; i < argv.length; i++) {
75
+ const token = argv[i];
76
+ if (!token?.startsWith("--")) continue;
77
+ const raw = token.slice(2);
78
+ const eqIdx = raw.indexOf("=");
79
+ if (eqIdx !== -1) {
80
+ map.set(raw.slice(0, eqIdx), raw.slice(eqIdx + 1));
81
+ continue;
82
+ }
83
+ const next = argv[i + 1];
84
+ if (next && !next.startsWith("--")) {
85
+ map.set(raw, next);
86
+ i++;
87
+ } else {
88
+ map.set(raw, "true");
89
+ }
90
+ }
91
+
92
+ const run = map.get("run") ?? map.get("iteration");
93
+ const mode = map.get("mode");
94
+ const bucketMinutes = map.get("bucketMinutes") ?? map.get("bucket-minutes");
95
+ const windowHours = map.get("windowHours") ?? map.get("window-hours");
96
+ const stepHours = map.get("stepHours") ?? map.get("step-hours");
97
+ const anchorEnd = map.get("anchorEnd") ?? map.get("anchor-end");
98
+ const dataGroup = map.get("dataGroup") ?? map.get("data-group");
99
+ const delayMs = map.get("delayMs") ?? map.get("delay-ms");
100
+ const pause = map.get("pause");
101
+
102
+ const parsed: Partial<CliArgs> = {};
103
+ if (run && (run === "all" || run === "1" || run === "2" || run === "3")) parsed.run = run;
104
+ if (mode && (mode === "append" || mode === "dedup" || mode === "window_replace")) parsed.mode = mode;
105
+ if (bucketMinutes) parsed.bucketMinutes = Number(bucketMinutes);
106
+ if (windowHours) parsed.windowHours = Number(windowHours);
107
+ if (stepHours) parsed.stepHours = Number(stepHours);
108
+ if (dataGroup) parsed.dataGroup = dataGroup;
109
+ if (delayMs) parsed.delayMs = Number(delayMs);
110
+ if (pause !== undefined) {
111
+ const normalized = pause.trim().toLowerCase();
112
+ parsed.pause = !(normalized === "false" || normalized === "0" || normalized === "n" || normalized === "no");
113
+ }
114
+ if (anchorEnd) {
115
+ const d = new Date(anchorEnd);
116
+ if (!Number.isNaN(d.getTime())) parsed.anchorEnd = d;
117
+ }
118
+ return parsed;
119
+ }
120
+
121
+ function floorToHourUtc(date: Date): Date {
122
+ const d = new Date(date);
123
+ d.setUTCMinutes(0, 0, 0);
124
+ return d;
125
+ }
126
+
127
+ function sleep(ms: number): Promise<void> {
128
+ return new Promise((resolve) => setTimeout(resolve, ms));
129
+ }
130
+
131
+ function buildIterations(anchorEnd: Date, windowHours: number, stepHours: number): Array<{ idx: 1 | 2 | 3; startMs: number; endMs: number }> {
132
+ const stepMs = stepHours * 60 * 60 * 1000;
133
+ const windowMs = windowHours * 60 * 60 * 1000;
134
+ const end3 = anchorEnd.getTime();
135
+ const end2 = end3 - stepMs;
136
+ const end1 = end3 - 2 * stepMs;
137
+ return [
138
+ { idx: 1, startMs: end1 - windowMs, endMs: end1 },
139
+ { idx: 2, startMs: end2 - windowMs, endMs: end2 },
140
+ { idx: 3, startMs: end3 - windowMs, endMs: end3 },
141
+ ];
142
+ }
143
+
144
+ function buildColumns(producedKg: number, scrapKg: number): IUnsTableColumn[] {
145
+ return [
146
+ { name: "producedKg", type: "double", value: producedKg, uom: GeneratedPhysicalMeasurements.Kilogram },
147
+ { name: "scrapKg", type: "double", value: scrapKg, uom: GeneratedPhysicalMeasurements.Kilogram },
148
+ ];
149
+ }
150
+
151
+ function simulateProducedKg(bucketIndex: number, iteration: number): number {
152
+ const wave = Math.sin(bucketIndex / 10) * 3 + Math.sin(bucketIndex / 3) * 0.5;
153
+ const value = 100 + (bucketIndex % 50) * 0.2 + wave + iteration * 0.25;
154
+ return Number(value.toFixed(3));
155
+ }
156
+
157
+ function simulateScrapKg(producedKg: number, bucketIndex: number, iteration: number): number {
158
+ const ratio = 0.02 + (Math.sin(bucketIndex / 17) + 1) * 0.003;
159
+ const value = producedKg * ratio + iteration * 0.01;
160
+ return Number(value.toFixed(3));
161
+ }
162
+
163
+ function isMissingBucket(iteration: 1 | 2 | 3, bucketStartMs: number, windowStartMs: number, windowEndMs: number, bucketMs: number): boolean {
164
+ const oneHourMs = 60 * 60 * 1000;
165
+ const bucketIndex = Math.floor(bucketStartMs / bucketMs);
166
+ const inLastHour = bucketStartMs >= windowEndMs - oneHourMs;
167
+ const inFirstHour = bucketStartMs < windowStartMs + oneHourMs;
168
+ const missingEveryIter1 = 3;
169
+ const missingEveryIter2 = 4;
170
+
171
+ if (iteration === 1) return inLastHour && bucketIndex % missingEveryIter1 === 0;
172
+ if (iteration === 2) return inLastHour && bucketIndex % missingEveryIter2 === 0;
173
+ return inFirstHour && bucketIndex % missingEveryIter2 === 0;
174
+ }
175
+
176
+ async function main() {
177
+ const args = { ...DEFAULTS, ...parseArgs(process.argv.slice(2)) } as Omit<CliArgs, "anchorEnd"> & Partial<Pick<CliArgs, "anchorEnd">>;
178
+ const anchorEnd = args.anchorEnd ?? floorToHourUtc(new Date());
179
+ const cli: CliArgs = { ...args, anchorEnd };
180
+
181
+ if (!Number.isFinite(cli.bucketMinutes) || cli.bucketMinutes <= 0) {
182
+ throw new Error(`Invalid --bucketMinutes=${String(cli.bucketMinutes)}`);
183
+ }
184
+ if (!Number.isFinite(cli.windowHours) || cli.windowHours <= 0) {
185
+ throw new Error(`Invalid --windowHours=${String(cli.windowHours)}`);
186
+ }
187
+ if (!Number.isFinite(cli.stepHours) || cli.stepHours <= 0) {
188
+ throw new Error(`Invalid --stepHours=${String(cli.stepHours)}`);
189
+ }
190
+ if (!Number.isFinite(cli.delayMs) || cli.delayMs < 0) {
191
+ throw new Error(`Invalid --delayMs=${String(cli.delayMs)}`);
192
+ }
193
+
194
+ const config = await ConfigFile.loadConfig();
195
+ registerObjectTypeDescriptions(GeneratedObjectTypeDescriptions);
196
+ registerAttributeDescriptions(GeneratedAttributeDescriptions);
197
+
198
+ const topic: UnsTopics = "enterprise/site/area/line/";
199
+ const asset = resolveGeneratedAsset("asset");
200
+ const assetDescription = "Load test asset";
201
+ const objectType = GeneratedObjectTypes["process-segment"];
202
+ const objectId = "main";
203
+ const attribute = GeneratedAttributes["output-quantity"];
204
+
205
+ const outputHost = config.output?.host!;
206
+ const shouldPause = cli.pause && !!process.stdin.isTTY;
207
+
208
+ const rl = readline.createInterface({ input, output });
209
+ let mqttOutput: UnsMqttProxy | undefined;
210
+ try {
211
+ const proceed =
212
+ !process.stdin.isTTY
213
+ ? "y"
214
+ : await rl.question(`Run moving-window table load test on output broker ${outputHost}? (Y/n) `);
215
+ if (proceed.trim() && proceed.trim().toLowerCase() !== "y") {
216
+ logger.info("Aborted.");
217
+ return;
218
+ }
219
+
220
+ const processName = config.uns.processName!;
221
+ const instanceName = "tableWindowLoadTest";
222
+ const clientIdBase = config.output?.clientId ?? `${processName}-${instanceName}`;
223
+ const clientId = `${clientIdBase}-${randomUUID()}`;
224
+ mqttOutput = new UnsMqttProxy(
225
+ outputHost,
226
+ processName,
227
+ instanceName,
228
+ { publishThrottlingDelay: 0, clientId, defaultPublishOptions: { qos: 1 } },
229
+ true,
230
+ );
231
+ mqttOutput.event.on("unsProxyProducedTopics", (event) => {
232
+ void mqttOutput?.publishMessage(event.statusTopic, JSON.stringify(event.producedTopics), {
233
+ retain: true,
234
+ properties: { messageExpiryInterval: 120000 },
235
+ }).catch(() => undefined);
236
+ });
237
+ mqttOutput.event.on("mqttProxyStatus", async (event) => {
238
+ try {
239
+ const time = UnsPacket.formatToISO8601(new Date());
240
+ const unsMessage: IUnsMessage = { data: { time, value: event.value, uom: event.uom } };
241
+ const packet = await UnsPacket.unsPacketFromUnsMessage(unsMessage);
242
+ await mqttOutput?.publishMessage(event.statusTopic, JSON.stringify(packet));
243
+ } catch {
244
+ // ignore status publish errors in load tests
245
+ }
246
+ });
247
+ // Give the output proxy a moment to connect before publishing.
248
+ // (Same approach as `load-test-data.ts`.)
249
+ await sleep(1000);
250
+
251
+ const bucketMs = cli.bucketMinutes * 60 * 1000;
252
+ const iterations = buildIterations(cli.anchorEnd, cli.windowHours, cli.stepHours);
253
+
254
+ const selected = cli.run === "all" ? new Set([1, 2, 3]) : new Set([Number(cli.run)]);
255
+ const selectedIterations = iterations.filter((it) => selected.has(it.idx));
256
+ const firstIteration = selectedIterations[0]?.idx ?? 1;
257
+ logger.info(
258
+ `Publishing iterations=${Array.from(selected).join(",")} mode=${cli.mode} bucket=${cli.bucketMinutes}min window=${cli.windowHours}h step=${cli.stepHours}h anchorEnd=${cli.anchorEnd.toISOString()} dataGroup=${cli.dataGroup} pause=${shouldPause}`,
259
+ );
260
+
261
+ for (const it of iterations) {
262
+ if (!selected.has(it.idx)) continue;
263
+
264
+ const windowStartIso = UnsPacket.formatToISO8601(new Date(it.startMs)) as ISO8601;
265
+ const windowEndIso = UnsPacket.formatToISO8601(new Date(it.endMs)) as ISO8601;
266
+
267
+ if (shouldPause && it.idx !== firstIteration) {
268
+ await rl.question(
269
+ `[pause] Inspect QuestDB now (after previous iteration). Press Enter to run iteration ${it.idx} with window [${windowStartIso}, ${windowEndIso})...`,
270
+ );
271
+ }
272
+
273
+ const attrs: Array<{
274
+ attribute: typeof attribute;
275
+ table: {
276
+ dataGroup: string;
277
+ time: ISO8601;
278
+ intervalStart: ISO8601;
279
+ intervalEnd: ISO8601;
280
+ windowStart: ISO8601;
281
+ windowEnd: ISO8601;
282
+ deleted?: boolean;
283
+ columns: IUnsTableColumn[];
284
+ };
285
+ }> = [];
286
+
287
+ let published = 0;
288
+ let skipped = 0;
289
+ let tombstones = 0;
290
+
291
+ for (let startMs = it.startMs; startMs < it.endMs; startMs += bucketMs) {
292
+ const endMs = startMs + bucketMs;
293
+ const bucketStartIso = UnsPacket.formatToISO8601(new Date(startMs)) as ISO8601;
294
+ const bucketEndIso = UnsPacket.formatToISO8601(new Date(endMs)) as ISO8601;
295
+
296
+ const missing = isMissingBucket(it.idx, startMs, it.startMs, it.endMs, bucketMs);
297
+ if (missing) {
298
+ if (it.idx === 3 && cli.mode === "dedup") {
299
+ const bucketIndex = Math.floor(startMs / bucketMs);
300
+ const producedKg = simulateProducedKg(bucketIndex, it.idx);
301
+ const scrapKg = simulateScrapKg(producedKg, bucketIndex, it.idx);
302
+ attrs.push({
303
+ attribute,
304
+ table: {
305
+ dataGroup: cli.dataGroup,
306
+ time: bucketStartIso,
307
+ intervalStart: bucketStartIso,
308
+ intervalEnd: bucketEndIso,
309
+ windowStart: windowStartIso,
310
+ windowEnd: windowEndIso,
311
+ deleted: true,
312
+ columns: buildColumns(producedKg, scrapKg),
313
+ },
314
+ });
315
+ tombstones++;
316
+ published++;
317
+ } else {
318
+ skipped++;
319
+ }
320
+ continue;
321
+ }
322
+
323
+ const bucketIndex = Math.floor(startMs / bucketMs);
324
+ const producedKg = simulateProducedKg(bucketIndex, it.idx);
325
+ const scrapKg = simulateScrapKg(producedKg, bucketIndex, it.idx);
326
+ attrs.push({
327
+ attribute,
328
+ table: {
329
+ dataGroup: cli.dataGroup,
330
+ time: bucketStartIso,
331
+ intervalStart: bucketStartIso,
332
+ intervalEnd: bucketEndIso,
333
+ windowStart: windowStartIso,
334
+ windowEnd: windowEndIso,
335
+ columns: buildColumns(producedKg, scrapKg),
336
+ },
337
+ });
338
+ published++;
339
+ }
340
+
341
+ logger.info(
342
+ `Iteration ${it.idx}: window=[${windowStartIso}, ${windowEndIso}) publish=${published} skipped=${skipped} tombstones=${tombstones}`,
343
+ );
344
+
345
+ await mqttOutput.publishMqttMessage(
346
+ {
347
+ topic,
348
+ asset,
349
+ assetDescription,
350
+ objectType,
351
+ objectId,
352
+ attributes: attrs,
353
+ },
354
+ );
355
+
356
+ if (cli.delayMs > 0) {
357
+ await sleep(cli.delayMs);
358
+ }
359
+ }
360
+
361
+ // Give the worker queue a moment to publish before exiting.
362
+ await sleep(500);
363
+ } finally {
364
+ rl.close();
365
+ await mqttOutput?.stop();
366
+ }
367
+ }
368
+
369
+ main().catch((error) => {
370
+ const reason = error instanceof Error ? error : new Error(String(error));
371
+ logger.error(`Table window load test failed: ${reason.message}`);
372
+ process.exitCode = 1;
373
+ });
@@ -0,0 +1,47 @@
1
+ # Python bytecode
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ .venv/
8
+ venv/
9
+ env/
10
+ ENV/
11
+ .venv*/
12
+
13
+ # Packaging / build artifacts
14
+ build/
15
+ dist/
16
+ .eggs/
17
+ *.egg-info/
18
+ *.egg
19
+ pip-wheel-metadata/
20
+ .wheels/
21
+
22
+ # Tests / coverage
23
+ .pytest_cache/
24
+ .coverage*
25
+ htmlcov/
26
+ .hypothesis/
27
+
28
+ # Tool caches
29
+ .mypy_cache/
30
+ .ruff_cache/
31
+
32
+ # Logs
33
+ *.log
34
+
35
+ # Editor / OS
36
+ .idea/
37
+ .vscode/
38
+ .DS_Store
39
+ Thumbs.db
40
+ *.swp
41
+
42
+ # Local configuration
43
+ config.json
44
+ config-*.json
45
+
46
+ # Temp
47
+ /tmp/
@@ -0,0 +1,4 @@
1
+ {
2
+ "runtime": "python",
3
+ "entry": "main.py"
4
+ }