biz-a-cli 2.3.80-15235 → 2.3.80-15267

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.
@@ -1,7 +1,7 @@
1
1
  import axios from 'axios';
2
2
  import { prepareScript, encryptScript } from './script.js';
3
3
  import { IDLE_SOCKET_TIMEOUT_MILLISECONDS } from './hubEvent.js';
4
- import { CLIENT_ROOM } from './directHubEvent.js';
4
+ import { CLIENT_ROOM } from '../message-broker/clientEmitter.js';
5
5
 
6
6
  export async function getAppData(apiOpts){
7
7
  return await axios.request({
@@ -2,8 +2,8 @@ import { clientListener, RECONNECT_SOCKET_DELAY } from "./hubEvent.js";
2
2
  import { deploymentListenerForVSCode } from "./deployEvent.js";
3
3
  import { Tunnel as QuickTunnel } from "cloudflared";
4
4
  import { Server as ioServer } from "socket.io";
5
-
6
- export const CLIENT_ROOM = "clientRoom";
5
+ import { CLIENT_ROOM } from "../message-broker/clientEmitter.js";
6
+ import { ClientEmitter } from "../message-broker/clientEmitter.js";
7
7
 
8
8
  export async function localhostTunnel(port, notifier) {
9
9
  let tunnelUrl = "";
@@ -69,6 +69,26 @@ export function directHubEvent(serverSocket, argv) {
69
69
  setConnectListeners(clientSocket);
70
70
  clientListener(clientSocket, argv);
71
71
  clientSocket.join(CLIENT_ROOM);
72
+
73
+ // isolated Client in a room using clientInstanceId
74
+ const clientInstanceId =
75
+ clientSocket.handshake.query.clientInstanceId;
76
+ if (clientInstanceId) {
77
+ clientSocket.join(clientInstanceId);
78
+ clientSocket.on("ESB_ACK", (msgId) => {
79
+ ClientEmitter.acknowledgeMessage(msgId, clientInstanceId);
80
+ });
81
+ }
82
+
83
+ // isolated Client in a room using argv.subdomain and UserId
84
+ const userId = clientSocket.handshake.query.userId;
85
+ if (
86
+ userId !== undefined &&
87
+ userId !== null &&
88
+ String(userId) !== "-1"
89
+ ) {
90
+ clientSocket.join(`${argv.subdomain}_USER_${userId}`);
91
+ }
72
92
  } else if (clientSocket.handshake.query.isDeploy) {
73
93
  setConnectListeners(clientSocket);
74
94
  deploymentListenerForVSCode(clientSocket, argv);
@@ -89,7 +109,11 @@ export function createSocketServer(httpServer, cliIpAddress = "127.0.0.1") {
89
109
  /\.vscode-cdn\.net$/,
90
110
  ].concat(
91
111
  process.env.NODE_ENV === "production"
92
- ? [`http://${cliIpAddress}:4200`, "http://localhost:4200", "http://localhost"]
112
+ ? [
113
+ `http://${cliIpAddress}:4200`,
114
+ "http://localhost:4200",
115
+ "http://localhost",
116
+ ]
93
117
  : [`http://${cliIpAddress}:4200`, "http://localhost:4200"],
94
118
  ),
95
119
  },
package/bin/hub.js CHANGED
@@ -24,6 +24,7 @@ import { setGlobalConfig } from "../db/ds.js";
24
24
  import { CLI_Agent } from "../message-broker/agent.js";
25
25
  import { initBpmAgent } from "../engine/bpm/bpm-agent.js";
26
26
  import { bootMicroservices } from "../message-broker/orchestrator.js";
27
+ import { ClientEmitter } from "../message-broker/clientEmitter.js";
27
28
 
28
29
  const logger = createLogger({
29
30
  level: "info",
@@ -294,6 +295,8 @@ if (argv.mode.trim().toLowerCase() === "agent") {
294
295
  // directHubEvent(createSocketServer(server, argv.cliAddress().ip), argv);
295
296
  directHubEvent(ioServerInstance, argv);
296
297
 
298
+ ClientEmitter.init(ioServerInstance, socket, argv.subdomain);
299
+
297
300
  bootMicroservices(argv, ioServerInstance);
298
301
 
299
302
  if (argv.publish == true) {
package/bin/hubEvent.js CHANGED
@@ -10,6 +10,7 @@ const packageJson = require("../package.json");
10
10
  // import { pipeline } from 'node:stream'
11
11
  import { deploymentListenerForHubServer } from "./deployEvent.js";
12
12
  import { execCLIScriptWorker, workerStats } from "../worker/cliWorkerPool.js";
13
+ import { ClientEmitter } from "../message-broker/clientEmitter.js";
13
14
 
14
15
  export const IDLE_SOCKET_TIMEOUT_MILLISECONDS = 1000 * 28;
15
16
  export const RECONNECT_SOCKET_DELAY = 60 * 1000;
@@ -227,6 +228,12 @@ export const hubEvent = (socket, argv, notifier) => {
227
228
  });
228
229
  clientListener(socket, argv);
229
230
  deploymentListenerForHubServer(socket, argv);
231
+
232
+ // ESB client emitter. Catch the forwarded ACK from the Hub Server
233
+ socket.on("ESB_ACK", ({ msgId, clientInstanceId }) => {
234
+ ClientEmitter.acknowledgeMessage(msgId, clientInstanceId);
235
+ });
236
+
230
237
  return socket;
231
238
  };
232
239
 
@@ -298,7 +305,8 @@ const EngineRegistry = {
298
305
  "bpm/workflowRT": () => import("../engine/bpm/workflow-runtime.js"),
299
306
  "domain/system": () => import("../engine/domain/system.js"),
300
307
  "domain/appConfig": () => import("../engine/domain/appConfig.js"),
301
- "domain/applicationConfig": () => import("../engine/domain/applicationConfig.js"),
308
+ "domain/applicationConfig": () =>
309
+ import("../engine/domain/applicationConfig.js"),
302
310
  };
303
311
 
304
312
  const handleCliCommand = async (data, cb, argv) => {
@@ -357,12 +365,42 @@ const handleCliCommand = async (data, cb, argv) => {
357
365
  cb(null, { success: true, data: result });
358
366
  break;
359
367
  }
368
+ case "emitter": {
369
+ const requestData = data.data || {};
370
+ const { action, userIds, groupIds, eventName, payload } =
371
+ requestData;
372
+
373
+ if (action === "emitUsers" && Array.isArray(userIds)) {
374
+ ClientEmitter.emitUsers(userIds, eventName, payload).catch(
375
+ (err) => console.error("[ESB] emitUsers Error:", err),
376
+ );
377
+ } else if (
378
+ action === "emitUserGroups" &&
379
+ Array.isArray(groupIds)
380
+ ) {
381
+ ClientEmitter.emitUserGroups(
382
+ groupIds,
383
+ eventName,
384
+ payload,
385
+ ).catch((err) =>
386
+ console.error("[ESB] emitUserGroups Error:", err),
387
+ );
388
+ } else if (action === "broadcastClient") {
389
+ ClientEmitter.broadcastClient(eventName, payload);
390
+ }
360
391
 
392
+ cb(null, {
393
+ success: true,
394
+ message: `Handed over ${action} to ESB ClientEmitter`,
395
+ });
396
+ break;
397
+ }
361
398
  default:
362
399
  cb(`Unknown CLI command '${command}'`, null);
363
400
  }
364
401
  } catch (err) {
365
402
  cb(err.message || err, null);
403
+ // console.error("[CLICommand] Error:", err);
366
404
  }
367
405
  };
368
406
 
package/db/db.js CHANGED
@@ -17,7 +17,11 @@ export const save = async (payload, config = {}) => {
17
17
  return await adapter.save(payload, config);
18
18
  };
19
19
 
20
- export const deleteMD = async (id, masterTable, detailTables, config = {}) => {
20
+ export const remove = async (payload, config = {}) => {
21
+ return await adapter.remove(payload, config);
22
+ };
23
+
24
+ export const removeMD = async (id, masterTable, detailTables, config = {}) => {
21
25
  const payload = {
22
26
  id: id,
23
27
  master: masterTable,
@@ -26,10 +30,6 @@ export const deleteMD = async (id, masterTable, detailTables, config = {}) => {
26
30
  return await adapter.removeMD(payload, config);
27
31
  };
28
32
 
29
- export const remove = async (payload, config = {}) => {
30
- return await adapter.remove(payload, config);
31
- };
32
-
33
33
  export const list = async (payload, config = {}) => {
34
34
  return await adapter.list(payload, config);
35
35
  };
@@ -37,3 +37,7 @@ export const list = async (payload, config = {}) => {
37
37
  export const executeBlock = async (sqlString, config = {}) => {
38
38
  return await adapter.executeBlock(sqlString, config);
39
39
  };
40
+
41
+ export const ormGet = async (payload, config = {}) => {
42
+ return await adapter.ormGet(payload, config);
43
+ };
package/db/ds.js CHANGED
@@ -269,3 +269,17 @@ export const executeBlock = async (sqlString, config = {}) => {
269
269
  const data = buildDataSnapPayload(cfg, "block", flatSql);
270
270
  return await dsReq(url, data, cfg.timeout);
271
271
  };
272
+
273
+ export const ormGet = async (payload, config = {}) => {
274
+ const cfg = resolveConfig(config);
275
+ const url = buildDataSnapUrl(cfg, "orm");
276
+ const data = buildDataSnapPayload(cfg, "get", payload);
277
+
278
+ const res = await dsReq(url, data, cfg.timeout);
279
+
280
+ if (res && res.data) {
281
+ return typeof res.data === "string" ? JSON.parse(res.data) : res.data;
282
+ }
283
+
284
+ return res;
285
+ };
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  queryData,
3
3
  save as dbSave,
4
- deleteMD,
4
+ removeMD,
5
5
  getGlobalConfig,
6
+ ormGet,
6
7
  } from "../../db/db.js";
7
8
  import vm from "node:vm";
8
9
  import { execCLIScriptWorker } from "../../worker/cliWorkerPool.js";
9
10
  import axios from "axios";
11
+ import { ClientEmitter } from "../../message-broker/clientEmitter.js";
10
12
 
11
13
  // ================
12
14
  // INTERNAL HELPERS
@@ -98,6 +100,42 @@ async function evaluateGuardRule(guardType, guardRuleKey, entityData) {
98
100
  }
99
101
  }
100
102
 
103
+ async function processInstanceUpsertResult(
104
+ requesterPayload,
105
+ apiConfig,
106
+ result,
107
+ fallbackId,
108
+ ) {
109
+ if (result && result.success == "OK") {
110
+ try {
111
+ const savedId = result.data?.[0]?.ID || fallbackId;
112
+ if (savedId) {
113
+ const newRecord = await ormGet(
114
+ {
115
+ SYS$BPM_WF_INSTANCE: {
116
+ ID: savedId,
117
+ "*": "",
118
+ },
119
+ },
120
+ apiConfig,
121
+ );
122
+
123
+ ClientEmitter.broadcastClient(
124
+ "SYS$BPM_WF_INSTANCE_UPSERT",
125
+ newRecord,
126
+ );
127
+
128
+ ClientEmitter.notifyRequester(requesterPayload, newRecord);
129
+ }
130
+ } catch (esbError) {
131
+ console.error(
132
+ "[ESB Error] Failed to broadcast instance upsert :",
133
+ esbError,
134
+ );
135
+ }
136
+ }
137
+ }
138
+
101
139
  // ===============
102
140
  // ENGINE ENDPOINT
103
141
  // ===============
@@ -246,6 +284,9 @@ export async function initiate(payload) {
246
284
  { SYS$BPM_WF_INSTANCE: instanceModel },
247
285
  apiConfig,
248
286
  );
287
+
288
+ await processInstanceUpsertResult(payload, apiConfig, result, null);
289
+
249
290
  return result.data;
250
291
  }
251
292
 
@@ -389,6 +430,7 @@ export async function executeTransition(payload) {
389
430
  entityData = {},
390
431
  apiConfig,
391
432
  userId,
433
+ effectData = {},
392
434
  } = payload;
393
435
 
394
436
  const instParam = {
@@ -614,19 +656,49 @@ export async function executeTransition(payload) {
614
656
  };
615
657
 
616
658
  if (effects && effects.length > 0) {
617
- transitionPayload.SYS$BPM_EFFECT_LOG = effects.map((eff) => ({
618
- id: null,
619
- sys$bpm_wf_instance_id: Number(instanceId),
620
- sys$bpm_trans_def_id: transition.id,
621
- effect_type: eff.effect_type,
622
- event_name: eff.event_name,
623
- payload_evaluated: eff.payload_template || null,
624
- status: "PENDING",
625
- retry_count: 0,
626
- }));
659
+ transitionPayload.SYS$BPM_EFFECT_LOG = effects.map((eff) => {
660
+ let mergedPayload = {};
661
+
662
+ // 1. Parse the static template from the Workflow Definition
663
+ if (eff.payload_template) {
664
+ try {
665
+ mergedPayload = JSON.parse(eff.payload_template);
666
+ } catch (e) {}
667
+ }
668
+
669
+ // 2. Merge the dynamic context injected by the Biz-A Client (e.g., clientInstanceId)
670
+ if (effectData && Object.keys(effectData).length > 0) {
671
+ mergedPayload = { ...mergedPayload, ...effectData };
672
+ }
673
+
674
+ // 3. Stringify the result, or fall back to null if completely empty
675
+ const finalPayloadString =
676
+ Object.keys(mergedPayload).length > 0
677
+ ? JSON.stringify(mergedPayload)
678
+ : null;
679
+
680
+ return {
681
+ id: null,
682
+ sys$bpm_wf_instance_id: Number(instanceId),
683
+ sys$bpm_trans_def_id: transition.id,
684
+ effect_type: eff.effect_type,
685
+ event_name: eff.event_name,
686
+ payload_evaluated: finalPayloadString || null,
687
+ status: "PENDING",
688
+ retry_count: 0,
689
+ };
690
+ });
627
691
  }
628
692
 
629
693
  const result = await dbSave(transitionPayload, apiConfig);
694
+
695
+ await processInstanceUpsertResult(
696
+ payload,
697
+ apiConfig,
698
+ result,
699
+ Number(instanceId),
700
+ );
701
+
630
702
  return result.data;
631
703
  }
632
704
 
@@ -723,6 +795,14 @@ export async function terminate(payload) {
723
795
  };
724
796
 
725
797
  const result = await dbSave(ormPayload, apiConfig);
798
+
799
+ await processInstanceUpsertResult(
800
+ payload,
801
+ apiConfig,
802
+ result,
803
+ Number(instanceId),
804
+ );
805
+
726
806
  return result.data;
727
807
  }
728
808
 
@@ -735,13 +815,27 @@ export async function remove(payload) {
735
815
  "SYS$BPM_EFFECT_LOG",
736
816
  ];
737
817
 
738
- const result = await deleteMD(
818
+ const result = await removeMD(
739
819
  Number(instanceId),
740
820
  "SYS$BPM_WF_INSTANCE",
741
821
  detailTables,
742
822
  apiConfig,
743
823
  );
744
- return result.data;
824
+
825
+ if (result && result.success == "OK") {
826
+ try {
827
+ const data = [{ ID: instanceId }];
828
+ ClientEmitter.broadcastClient("SYS$BPM_WF_INSTANCE_REMOVE", data);
829
+ ClientEmitter.notifyRequester(payload, data);
830
+ } catch (esbError) {
831
+ console.error(
832
+ "[ESB Error] Failed to broadcast instance deletion:",
833
+ esbError,
834
+ );
835
+ }
836
+ }
837
+
838
+ return result;
745
839
  }
746
840
 
747
841
  export async function myTasks(payload) {
@@ -1233,7 +1327,9 @@ export async function executeEffect(effectData) {
1233
1327
  // instance when the effect carries no payloadTemplate.
1234
1328
  const scriptData = Object.assign(
1235
1329
  {},
1236
- parsedPayload && typeof parsedPayload === "object" && !Array.isArray(parsedPayload)
1330
+ parsedPayload &&
1331
+ typeof parsedPayload === "object" &&
1332
+ !Array.isArray(parsedPayload)
1237
1333
  ? parsedPayload
1238
1334
  : {},
1239
1335
  {
@@ -1242,10 +1338,17 @@ export async function executeEffect(effectData) {
1242
1338
  effectData.SYS$BPM_WF_INSTANCE_ID,
1243
1339
  },
1244
1340
  );
1341
+
1245
1342
  const scriptResult = await execCLIScriptWorker(
1246
1343
  apiConfig,
1247
1344
  eventName,
1248
- scriptData,
1345
+ // scriptData,
1346
+ {
1347
+ // to be same format as callback, live trigger and watcher, script data must be inside "body"
1348
+ body: scriptData,
1349
+ // You can even add mock headers if future scripts expect them
1350
+ headers: { "user-agent": "biz-a-bpm-engine" },
1351
+ },
1249
1352
  );
1250
1353
 
1251
1354
  // --- if cliScript of Effect need to run executeTransition ---
@@ -1,4 +1,36 @@
1
- import { save as dbSave, queryData, deleteMD } from "../../db/db.js";
1
+ import { save as dbSave, queryData, removeMD, ormGet } from "../../db/db.js";
2
+ import { ClientEmitter } from "../../message-broker/clientEmitter.js";
3
+
4
+ async function processUpsertResult(requesterPayload, apiConfig, result) {
5
+ if (result.success == "OK") {
6
+ try {
7
+ const savedId = result.data[0]?.ID || id;
8
+ if (savedId) {
9
+ const newRecord = await ormGet(
10
+ {
11
+ SYS$BPM_WORKFLOW_DEF: {
12
+ ID: savedId,
13
+ "*": "",
14
+ },
15
+ },
16
+ apiConfig,
17
+ );
18
+
19
+ ClientEmitter.broadcastClient(
20
+ "SYS$BPM_WORKFLOW_DEF_UPSERT",
21
+ newRecord,
22
+ );
23
+
24
+ ClientEmitter.notifyRequester(requesterPayload, newRecord);
25
+ }
26
+ } catch (esbError) {
27
+ console.error(
28
+ "[ESB Error] Failed to broadcast workflow upsert :",
29
+ esbError,
30
+ );
31
+ }
32
+ }
33
+ }
2
34
 
3
35
  export async function save(payload) {
4
36
  const { id, rawJsonString, apiConfig, userId } = payload;
@@ -48,6 +80,9 @@ export async function save(payload) {
48
80
  },
49
81
  apiConfig,
50
82
  );
83
+
84
+ await processUpsertResult(payload, apiConfig, result);
85
+
51
86
  return result.data;
52
87
  }
53
88
 
@@ -175,6 +210,9 @@ export async function publish(payload) {
175
210
  { SYS$BPM_WORKFLOW_DEF: workflowModel },
176
211
  apiConfig,
177
212
  );
213
+
214
+ await processUpsertResult(payload, apiConfig, result);
215
+
178
216
  return result.data;
179
217
  }
180
218
 
@@ -221,12 +259,25 @@ export async function remove(payload) {
221
259
  },
222
260
  ];
223
261
 
224
- const result = await deleteMD(
262
+ const result = await removeMD(
225
263
  Number(id),
226
264
  "SYS$BPM_WORKFLOW_DEF",
227
265
  detailTables,
228
266
  apiConfig,
229
267
  );
230
268
 
231
- return result.data;
269
+ if (result.success == "OK") {
270
+ try {
271
+ const data = [{ ID: id }];
272
+ ClientEmitter.broadcastClient("SYS$BPM_WORKFLOW_DEF_REMOVE", data);
273
+ ClientEmitter.notifyRequester(payload, data);
274
+ } catch (esbError) {
275
+ console.error(
276
+ "[ESB Error] Failed to broadcast workflow deletion:",
277
+ esbError,
278
+ );
279
+ }
280
+ }
281
+
282
+ return result;
232
283
  }
@@ -852,6 +852,93 @@ export const buildTableSyncPlan = (expected, actual, options) => {
852
852
  };
853
853
  };
854
854
 
855
+ const normalizeScopedDropIdentifier = (value, label) => {
856
+ const identifier = String(value ?? "").trim().toUpperCase();
857
+ if (!identifier || identifier.length > 31 || !/^[A-Z_][A-Z0-9_$]*$/.test(identifier)) {
858
+ throw new Error(`${label} contains an invalid Firebird identifier.`);
859
+ }
860
+ return identifier;
861
+ };
862
+
863
+ const collectionUsesColumn = (item, columnName) =>
864
+ (item?.columns ?? []).some((column) => String(column ?? "").trim().toUpperCase() === columnName);
865
+
866
+ const checkUsesColumn = (check, columnName) => {
867
+ if (collectionUsesColumn(check, columnName)) return true;
868
+ return new RegExp(`(^|[^A-Z0-9_$])${columnName}([^A-Z0-9_$]|$)`).test(String(check?.expression ?? "").toUpperCase());
869
+ };
870
+
871
+ const validateProtectedMasterDataColumn = (actual, tableName, columnName) => {
872
+ if (["ID", "SUSPEND"].includes(columnName)) {
873
+ throw new Error(`Column "${columnName}" is protected and cannot be dropped from ${tableName}.`);
874
+ }
875
+ const primaryKey = new Set(
876
+ (actual?.primaryKey ?? []).map((column) => String(column ?? "").trim().toUpperCase()),
877
+ );
878
+ if (primaryKey.has(columnName)) {
879
+ throw new Error(`Column "${columnName}" is part of the primary key in ${tableName}.`);
880
+ }
881
+ };
882
+
883
+ const getIncomingMasterDataReferences = (actual, columnName) => (actual?.incomingForeignKeys ?? []).filter((foreignKey) =>
884
+ (foreignKey?.referenceColumns ?? []).some((column) => String(column ?? "").trim().toUpperCase() === columnName));
885
+
886
+ export const buildMasterDataColumnDropPlan = (actual, requestedColumnNames) => {
887
+ const tableName = normalizeScopedDropIdentifier(actual?.tableName, "Table name");
888
+ const requestedColumns = [...new Set((requestedColumnNames ?? []).map((name) => normalizeScopedDropIdentifier(name, "Column name")))];
889
+ const actualColumns = new Set(
890
+ (actual?.columns ?? []).map((column) => String(column?.name ?? "").trim().toUpperCase()),
891
+ );
892
+ const statements = [];
893
+ const droppedColumns = [];
894
+ const alreadyMissingColumns = [];
895
+ for (const columnName of requestedColumns) {
896
+ validateProtectedMasterDataColumn(actual, tableName, columnName);
897
+ if (!actualColumns.has(columnName)) {
898
+ alreadyMissingColumns.push(columnName);
899
+ continue;
900
+ }
901
+
902
+ const incoming = getIncomingMasterDataReferences(actual, columnName);
903
+ if (incoming.length > 0) {
904
+ const names = incoming.map((item) => item.name).filter(Boolean).join(", ");
905
+ throw new Error(`Column "${columnName}" in ${tableName} is referenced by incoming foreign key ${names}.`);
906
+ }
907
+
908
+ const foreignKeys = (actual?.foreignKeys ?? []).filter((item) => collectionUsesColumn(item, columnName));
909
+ const checks = (actual?.checks ?? []).filter((item) => checkUsesColumn(item, columnName));
910
+ const uniqueKeys = (actual?.uniqueKeys ?? []).filter((item) => collectionUsesColumn(item, columnName));
911
+ const indexes = (actual?.indexes ?? []).filter((item) => collectionUsesColumn(item, columnName));
912
+ const removableNames = new Set(
913
+ [...foreignKeys, ...checks, ...uniqueKeys, ...indexes]
914
+ .flatMap((item) => [item?.name, item?.triggerName])
915
+ .map((name) => String(name ?? "").trim().toUpperCase())
916
+ .filter(Boolean),
917
+ );
918
+ const blockers = (actual?.columnDependencies ?? []).filter(
919
+ (dependency) =>
920
+ String(dependency?.columnName ?? "").trim().toUpperCase() === columnName &&
921
+ !removableNames.has(String(dependency?.dependentName ?? "").trim().toUpperCase()),
922
+ );
923
+ if (blockers.length > 0) {
924
+ const names = blockers.map((item) => item.dependentName).filter(Boolean).join(", ");
925
+ throw new Error(`Column "${columnName}" in ${tableName} has dependent database objects: ${names}.`);
926
+ }
927
+
928
+ foreignKeys.forEach((item) => statements.push(`ALTER TABLE ${tableName} DROP CONSTRAINT ${item.name};`));
929
+ checks.forEach((item) => statements.push(`ALTER TABLE ${tableName} DROP CONSTRAINT ${item.name};`));
930
+ uniqueKeys.forEach((item) => statements.push(`ALTER TABLE ${tableName} DROP CONSTRAINT ${item.name};`));
931
+ indexes.forEach((item) => statements.push(`DROP INDEX ${item.name};`));
932
+ statements.push(`ALTER TABLE ${tableName} DROP ${columnName};`);
933
+ droppedColumns.push(columnName);
934
+ }
935
+ return {
936
+ statements: [...new Set(statements.map((statement) => toSingleLine(statement)))],
937
+ droppedColumns,
938
+ alreadyMissingColumns,
939
+ };
940
+ };
941
+
855
942
  export const buildGeneratorStructureQueries = (table) => {
856
943
  const generatorName = toDbName(table.generatorName ?? buildObjectName(table.name, "GEN"));
857
944
  const triggerName = toDbName(table.triggerName ?? buildObjectName(table.name, "BI"));
@@ -979,6 +1066,51 @@ export const buildGeneratorSyncPlan = (table, generatorRows, triggerRows) => {
979
1066
  };
980
1067
  };
981
1068
 
1069
+ export const buildAppendOnlyStructureQueries = (table) => {
1070
+ const triggerName = toDbName(table.appendOnlyTriggerName ?? buildObjectName(table.name, "NOUPD"));
1071
+ return {
1072
+ exceptionSql: [
1073
+ "SELECT COUNT(*) AS EXCEPTION_EXISTS",
1074
+ "FROM RDB$EXCEPTIONS",
1075
+ `WHERE UPPER(TRIM(RDB$EXCEPTION_NAME)) = '${firebirdDdl.APPEND_ONLY_EXCEPTION_NAME}';`,
1076
+ ].join(" "),
1077
+ triggerSql: [
1078
+ "SELECT COUNT(*) AS TRIGGER_EXISTS",
1079
+ "FROM RDB$TRIGGERS T",
1080
+ `WHERE UPPER(TRIM(T.RDB$TRIGGER_NAME)) = '${triggerName}'`,
1081
+ " AND COALESCE(T.RDB$SYSTEM_FLAG, 0) = 0;",
1082
+ ].join(" "),
1083
+ };
1084
+ };
1085
+
1086
+ export const buildAppendOnlySyncPlan = (table, exceptionRows, triggerRows) => {
1087
+ if (!table?.appendOnly) {
1088
+ return { statements: [], differences: [] };
1089
+ }
1090
+ const triggerName = toDbName(table.appendOnlyTriggerName ?? buildObjectName(table.name, "NOUPD"));
1091
+ const statements = [];
1092
+ const differences = [];
1093
+
1094
+ const exceptionExists =
1095
+ Number(exceptionRows?.[0]?.["EXCEPTION_EXISTS"] ?? exceptionRows?.[0]?.["exception_exists"] ?? 0) === 1;
1096
+ const triggerExists =
1097
+ Number(triggerRows?.[0]?.["TRIGGER_EXISTS"] ?? triggerRows?.[0]?.["trigger_exists"] ?? 0) === 1;
1098
+
1099
+ if (!exceptionExists) {
1100
+ differences.push(`Exception "${firebirdDdl.APPEND_ONLY_EXCEPTION_NAME}" is missing.`);
1101
+ statements.push(firebirdDdl.buildCreateAppendOnlyExceptionStatement());
1102
+ }
1103
+ if (!triggerExists) {
1104
+ differences.push(`Append-only trigger "${triggerName}" is missing.`);
1105
+ statements.push(firebirdDdl.buildAppendOnlyTriggerStatement(table.name, triggerName));
1106
+ }
1107
+
1108
+ return {
1109
+ statements: [...new Set(statements.map((statement) => toSingleLine(statement)))],
1110
+ differences,
1111
+ };
1112
+ };
1113
+
982
1114
  export const buildDomainDdlPreview = (domainConfigContent, defaultDbIndex) => {
983
1115
  const buildResult = buildDomainSchemaInternal(domainConfigContent, defaultDbIndex);
984
1116
  const schema = buildResult.schema;
@@ -1286,6 +1418,11 @@ const buildEntityTable = (entityName, entity, defaultDbIndex) => {
1286
1418
  table.triggerName = buildObjectName(tableName, "BI");
1287
1419
  }
1288
1420
 
1421
+ if (entity?.appendOnly === true) {
1422
+ table.appendOnly = true;
1423
+ table.appendOnlyTriggerName = buildObjectName(tableName, "NOUPD");
1424
+ }
1425
+
1289
1426
  return {
1290
1427
  entityName,
1291
1428
  tableName,
@@ -1970,6 +2107,20 @@ const loadGeneratorSyncPlan = async (table, dbIndex, config) => {
1970
2107
  return buildGeneratorSyncPlan(table, generatorRows, triggerRows);
1971
2108
  };
1972
2109
 
2110
+ const loadAppendOnlySyncPlan = async (table, dbIndex, config) => {
2111
+ if (!table?.appendOnly) {
2112
+ return { statements: [], differences: [] };
2113
+ }
2114
+
2115
+ const queries = buildAppendOnlyStructureQueries(table);
2116
+ const [exceptionRows, triggerRows] = await Promise.all([
2117
+ execRawSql(queries.exceptionSql, dbIndex, config),
2118
+ execRawSql(queries.triggerSql, dbIndex, config),
2119
+ ]);
2120
+
2121
+ return buildAppendOnlySyncPlan(table, exceptionRows, triggerRows);
2122
+ };
2123
+
1973
2124
  const toFirebird25CompatibleDdl = (statement) => {
1974
2125
  const sanitizedStatement = sanitizeSql(statement);
1975
2126
  const notNullMatch = sanitizedStatement.match(
@@ -2100,6 +2251,22 @@ const applyGeneratorSyncPlan = async (table, generatorSyncPlan, dbIndex, config,
2100
2251
  return null;
2101
2252
  };
2102
2253
 
2254
+ const applyAppendOnlySyncPlan = async (table, appendOnlySyncPlan, dbIndex, config, ctx) => {
2255
+ appendDryRunReport(ctx.report, ctx.migrationMode, table.name, "append-only-sync", {
2256
+ statements: appendOnlySyncPlan.statements,
2257
+ warnings: [],
2258
+ differences: appendOnlySyncPlan.differences,
2259
+ });
2260
+ if (appendOnlySyncPlan.statements.length === 0) {
2261
+ return null;
2262
+ }
2263
+ if (appendOnlySyncPlan.differences.length > 0) {
2264
+ console.log(`[AppConfig Publish] Table ${table.name} append-only:`, appendOnlySyncPlan.differences);
2265
+ }
2266
+ await executeSqlStatements(appendOnlySyncPlan.statements, dbIndex, config, ctx);
2267
+ return null;
2268
+ };
2269
+
2103
2270
  const splitSqlStatements = (sqlBatch) => {
2104
2271
  const source = sqlBatch ?? "";
2105
2272
  const statements = [];
@@ -2166,6 +2333,9 @@ const syncTable = async (table, ctx) => {
2166
2333
  const generatorSyncPlan = await loadGeneratorSyncPlan(table, tableDbIndex, ctx.config);
2167
2334
  await applyGeneratorSyncPlan(table, generatorSyncPlan, tableDbIndex, ctx.config, ctx);
2168
2335
 
2336
+ const createAppendOnlyPlan = await loadAppendOnlySyncPlan(table, tableDbIndex, ctx.config);
2337
+ await applyAppendOnlySyncPlan(table, createAppendOnlyPlan, tableDbIndex, ctx.config, ctx);
2338
+
2169
2339
  const latestSnapshot = await loadTableSnapshot(table.name, true, tableDbIndex, ctx.config);
2170
2340
  const postCreateSyncPlan = buildTableSyncPlan(table, latestSnapshot, { migrationMode: ctx.migrationMode });
2171
2341
  if (postCreateSyncPlan.warnings.length > 0) {
@@ -2186,6 +2356,9 @@ const syncTable = async (table, ctx) => {
2186
2356
  const generatorSyncPlan = await loadGeneratorSyncPlan(table, tableDbIndex, ctx.config);
2187
2357
  await applyGeneratorSyncPlan(table, generatorSyncPlan, tableDbIndex, ctx.config, ctx);
2188
2358
 
2359
+ const existingAppendOnlyPlan = await loadAppendOnlySyncPlan(table, tableDbIndex, ctx.config);
2360
+ await applyAppendOnlySyncPlan(table, existingAppendOnlyPlan, tableDbIndex, ctx.config, ctx);
2361
+
2189
2362
  if (compareResult.isSame) {
2190
2363
  return null;
2191
2364
  }
@@ -2218,6 +2391,46 @@ export const resolveConfigFromPayload = (payload = {}, argv = {}) => {
2218
2391
  };
2219
2392
  };
2220
2393
 
2394
+ export const dropMasterDataColumns = async (payload = {}, argv = {}) => {
2395
+ try {
2396
+ const tableName = normalizeScopedDropIdentifier(payload?.tableName, "Table name");
2397
+ const columnNames = Array.isArray(payload?.columnNames) ? payload.columnNames : [];
2398
+ if (columnNames.length === 0) {
2399
+ return { success: true, tableName, droppedColumns: [], alreadyMissingColumns: [] };
2400
+ }
2401
+
2402
+ const config = resolveConfigFromPayload(payload, argv);
2403
+ const dbIndex = resolveExecutionDbIndex(payload?.dbIndex, config);
2404
+ const normalizedColumns = [
2405
+ ...new Set(columnNames.map((name) => normalizeScopedDropIdentifier(name, "Column name"))),
2406
+ ];
2407
+ const exists = await loadTableExists(tableName, dbIndex, config);
2408
+ if (!exists) {
2409
+ return {
2410
+ success: true,
2411
+ tableName,
2412
+ droppedColumns: [],
2413
+ alreadyMissingColumns: normalizedColumns,
2414
+ };
2415
+ }
2416
+
2417
+ const snapshot = await loadTableSnapshot(tableName, true, dbIndex, config);
2418
+ const plan = buildMasterDataColumnDropPlan(snapshot, normalizedColumns);
2419
+ await executeSqlStatements(plan.statements, dbIndex, config, {
2420
+ dryRun: false,
2421
+ executedBlockSignatures: new Set(),
2422
+ });
2423
+ return {
2424
+ success: true,
2425
+ tableName,
2426
+ droppedColumns: plan.droppedColumns,
2427
+ alreadyMissingColumns: plan.alreadyMissingColumns,
2428
+ };
2429
+ } catch (error) {
2430
+ return { success: false, error: error?.message ?? String(error) };
2431
+ }
2432
+ };
2433
+
2221
2434
  export const buildPublishStatusConfigKey = (domainName, domainVersion) => ({
2222
2435
  name: `${domainName}__PUBLISH_STATUS`,
2223
2436
  version: domainVersion,
@@ -77,6 +77,7 @@ export const buildTableStructureQueries = (tableName) => {
77
77
  checkConstraintsSql: [
78
78
  'SELECT',
79
79
  ' TRIM(rc.RDB$CONSTRAINT_NAME) AS CONSTRAINT_NAME,',
80
+ ' TRIM(cc.RDB$TRIGGER_NAME) AS TRIGGER_NAME,',
80
81
  ' TRIM(CAST(t.RDB$TRIGGER_SOURCE AS VARCHAR(4096))) AS CHECK_SOURCE',
81
82
  'FROM RDB$RELATION_CONSTRAINTS rc',
82
83
  'JOIN RDB$CHECK_CONSTRAINTS cc ON cc.RDB$CONSTRAINT_NAME = rc.RDB$CONSTRAINT_NAME',
@@ -236,6 +237,7 @@ export const buildStructureSnapshotFromRows = (input) => {
236
237
 
237
238
  export const generateExecuteBlockSql = (schema) => {
238
239
  const statements = [];
240
+ let hasAppendOnly = false;
239
241
 
240
242
  for (const table of schema.tables) {
241
243
  const createTable = buildCreateTableStatement(table);
@@ -287,6 +289,18 @@ export const generateExecuteBlockSql = (schema) => {
287
289
  ),
288
290
  );
289
291
  }
292
+
293
+ if (table.appendOnly) {
294
+ hasAppendOnly = true;
295
+ statements.push(
296
+ buildAppendOnlyTriggerStatement(table.name, resolveAppendOnlyTriggerName(table)),
297
+ );
298
+ }
299
+ }
300
+
301
+ if (hasAppendOnly) {
302
+ // the shared exception must exist before any NOUPD trigger references it
303
+ statements.unshift(buildCreateAppendOnlyExceptionStatement());
290
304
  }
291
305
 
292
306
  return []
@@ -471,6 +485,30 @@ const resolveIdentityValueExpression = (table, identityColumn, generatorName) =>
471
485
  return `GEN_ID(${normalizeIdentifier(generatorName)}, 1)`;
472
486
  };
473
487
 
488
+ // Shared exception raised by every append-only table's NOUPD trigger.
489
+ export const APPEND_ONLY_EXCEPTION_NAME = 'E_APPEND_ONLY';
490
+ const APPEND_ONLY_EXCEPTION_MESSAGE = 'This table is append-only; UPDATE and DELETE are not allowed.';
491
+
492
+ export const buildCreateAppendOnlyExceptionStatement = () => {
493
+ return `CREATE EXCEPTION ${APPEND_ONLY_EXCEPTION_NAME} '${APPEND_ONLY_EXCEPTION_MESSAGE}'`;
494
+ };
495
+
496
+ // A BEFORE UPDATE OR DELETE trigger that makes the table append-only (raises E_APPEND_ONLY).
497
+ export const buildAppendOnlyTriggerStatement = (tableName, triggerName) => {
498
+ const normalizedTableName = normalizeIdentifier(tableName);
499
+ const normalizedTriggerName = normalizeIdentifier(triggerName);
500
+ return [
501
+ `CREATE TRIGGER ${normalizedTriggerName} FOR ${normalizedTableName} ACTIVE`,
502
+ 'BEFORE UPDATE OR DELETE POSITION 0 AS BEGIN',
503
+ `EXCEPTION ${APPEND_ONLY_EXCEPTION_NAME};`,
504
+ 'END',
505
+ ].join(' ');
506
+ };
507
+
508
+ export const resolveAppendOnlyTriggerName = (table) => {
509
+ return normalizeIdentifier(table.appendOnlyTriggerName ?? `${table.name}_NOUPD`);
510
+ };
511
+
474
512
  const wrapCreateIfMissing = (statement) => {
475
513
  return [
476
514
  'EXECUTE BLOCK AS',
@@ -832,6 +870,7 @@ const groupCheckConstraints = (rows) => {
832
870
  return (rows ?? [])
833
871
  .map((row) => ({
834
872
  name: toUpperText(row['CONSTRAINT_NAME'] ?? row['constraint_name']),
873
+ triggerName: toUpperText(row['TRIGGER_NAME'] ?? row['trigger_name']),
835
874
  expression: normalizeExpression(
836
875
  row['CHECK_SOURCE'] ??
837
876
  row['check_source'] ??
@@ -0,0 +1,288 @@
1
+ import crypto from "node:crypto";
2
+ import { createLogger, transports, format } from "winston";
3
+ import { queryData } from "../db/db.js";
4
+
5
+ export const CLIENT_ROOM = "clientRoom";
6
+ const MAX_RETRIES = 3;
7
+ const RETRY_DELAY_MS = 10000; // 10 seconds
8
+
9
+ // 1. STRICT DLQ SCHEMA (Ready for DB imports later)
10
+ const dlqLogger = createLogger({
11
+ level: "error",
12
+ format: format.combine(format.timestamp(), format.json()),
13
+ transports: [new transports.File({ filename: "log/dlq.log" })],
14
+ });
15
+
16
+ class ClientEmitterService {
17
+ constructor() {
18
+ this.ioServerInstance = null;
19
+ this.hubSocket = null;
20
+ this.subdomain = null;
21
+
22
+ // Map<clientInstanceId, Map<msgId, PendingData>>
23
+ this.clientQueues = new Map();
24
+ }
25
+
26
+ init(ioServerInstance, hubSocket, subdomain) {
27
+ this.ioServerInstance = ioServerInstance;
28
+ this.hubSocket = hubSocket;
29
+ this.subdomain = subdomain;
30
+ }
31
+
32
+ /**
33
+ * Process the incoming ESB_ACK from the Angular Client
34
+ * PRUNE OLDER PENDING EVENTS
35
+ * Since we just received an ACK for this event, any older event in this queue is now officially a phantom/stale."
36
+ */
37
+ acknowledgeMessage(msgId, clientInstanceId) {
38
+ const queue = this.clientQueues.get(clientInstanceId);
39
+ if (queue && queue.has(msgId)) {
40
+ const ackedEvent = queue.get(msgId);
41
+
42
+ for (const [id, pending] of queue.entries()) {
43
+ if (pending.timestamp <= ackedEvent.timestamp) {
44
+ clearTimeout(pending.timer);
45
+ queue.delete(id);
46
+ }
47
+ }
48
+
49
+ if (queue.size === 0) this.clientQueues.delete(clientInstanceId); // Memory cleanup
50
+ }
51
+ }
52
+
53
+ /**
54
+ * TARGETED EMIT: Blasts to a specific client with Superseding Logic
55
+ */
56
+ emitClient(clientInstanceId, eventName, payload, attempt = 1) {
57
+ const msgId = attempt === 1 ? crypto.randomUUID() : payload.msgId;
58
+ const creationTime = attempt === 1 ? Date.now() : payload.timestamp;
59
+
60
+ const wrappedPayload =
61
+ attempt === 1
62
+ ? { msgId: msgId, timestamp: creationTime, data: payload }
63
+ : payload;
64
+
65
+ if (!this.clientQueues.has(clientInstanceId)) {
66
+ this.clientQueues.set(clientInstanceId, new Map());
67
+ }
68
+ const queue = this.clientQueues.get(clientInstanceId);
69
+
70
+ if (attempt === 1) {
71
+ // Supersede Logic: Kill older phantom retries of the exact same event type
72
+ for (const [existingMsgId, pending] of queue.entries()) {
73
+ if (
74
+ pending.eventName === eventName &&
75
+ pending.timestamp < creationTime
76
+ ) {
77
+ console.log(
78
+ `[ESB] Superseding older '${eventName}' event for ${clientInstanceId}`,
79
+ );
80
+ clearTimeout(pending.timer);
81
+ queue.delete(existingMsgId);
82
+ }
83
+ }
84
+ }
85
+
86
+ // SETUP THE RETRY TIMER
87
+ const timer = setTimeout(() => {
88
+ if (queue.has(msgId)) {
89
+ if (attempt <= MAX_RETRIES) {
90
+ console.log(
91
+ `[ESB] Retry ${attempt}/${MAX_RETRIES} for event '${eventName}' to ${clientInstanceId}`,
92
+ );
93
+ this.emitClient(
94
+ clientInstanceId,
95
+ eventName,
96
+ wrappedPayload,
97
+ attempt + 1,
98
+ );
99
+ } else {
100
+ // DLQ RECORD FORMAT
101
+ const dlqRecord = {
102
+ msgId: msgId,
103
+ clientInstanceId: clientInstanceId,
104
+ eventName: eventName,
105
+ createdAt: creationTime,
106
+ failedAt: Date.now(),
107
+ retryCount: MAX_RETRIES,
108
+ payload: wrappedPayload.data,
109
+ };
110
+
111
+ dlqLogger.error("ESB_DELIVERY_FAILURE", dlqRecord);
112
+
113
+ queue.delete(msgId);
114
+ if (queue.size === 0)
115
+ this.clientQueues.delete(clientInstanceId);
116
+ }
117
+ }
118
+ }, RETRY_DELAY_MS);
119
+
120
+ // Register the expectation and payload explicitly
121
+ queue.set(msgId, {
122
+ timer,
123
+ eventName,
124
+ timestamp: creationTime,
125
+ payload: wrappedPayload,
126
+ });
127
+
128
+ // FIRE TO THE DUAL CHANNELS
129
+ if (this.ioServerInstance) {
130
+ this.ioServerInstance
131
+ .to(clientInstanceId)
132
+ .emit(eventName, wrappedPayload);
133
+ }
134
+
135
+ if (this.hubSocket && this.hubSocket.connected) {
136
+ this.hubSocket.emit("emitToClient", {
137
+ clientInstanceId,
138
+ eventName,
139
+ payload: wrappedPayload,
140
+ });
141
+ }
142
+ }
143
+
144
+ broadcastClient(eventName, payload) {
145
+ const wrappedPayload = {
146
+ msgId: crypto.randomUUID(),
147
+ timestamp: Date.now(),
148
+ data: payload,
149
+ };
150
+
151
+ if (this.ioServerInstance)
152
+ this.ioServerInstance
153
+ .to(CLIENT_ROOM)
154
+ .emit(eventName, wrappedPayload);
155
+
156
+ if (this.hubSocket && this.hubSocket.connected) {
157
+ this.hubSocket.emit("broadcastToClient", {
158
+ eventName,
159
+ payload: wrappedPayload,
160
+ });
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Standardized Acknowledgment Emit
166
+ * Automatically checks if the frontend requested a targeted response.
167
+ */
168
+ notifyRequester(payload, data) {
169
+ if (payload && payload.clientInstanceId && payload.eventName) {
170
+ this.emitClient(payload.clientInstanceId, payload.eventName, data);
171
+ }
172
+ }
173
+
174
+ async emitUsers(userIds, eventName, payload) {
175
+ if (!Array.isArray(userIds) || userIds.length === 0) return;
176
+
177
+ // Use Set to natively guarantee unique values
178
+ let targetInstances = new Set();
179
+
180
+ // 1. Fetch instances from directHub
181
+ if (this.ioServerInstance && this.subdomain) {
182
+ for (const uid of userIds) {
183
+ const roomName = `${this.subdomain}_USER_${uid}`;
184
+ const sockets = await this.ioServerInstance
185
+ .in(roomName)
186
+ .fetchSockets();
187
+ sockets.forEach((s) => {
188
+ const cid = s.handshake?.query?.clientInstanceId;
189
+ if (cid !== undefined && cid !== null) {
190
+ targetInstances.add(cid);
191
+ }
192
+ });
193
+ }
194
+ }
195
+
196
+ // 2. Fetch instances from Hub Server
197
+ if (this.hubSocket && this.hubSocket.connected) {
198
+ try {
199
+ const cloudInstances = await new Promise((resolve, reject) => {
200
+ this.hubSocket
201
+ .timeout(5000)
202
+ .emit(
203
+ "clientInstancesByUserIds",
204
+ { userIds },
205
+ (err, res) => {
206
+ if (err) reject(err);
207
+ else resolve(res);
208
+ },
209
+ );
210
+ });
211
+
212
+ // Explicitly deduplicate the dual-channel overlap
213
+ if (Array.isArray(cloudInstances)) {
214
+ cloudInstances.forEach((cid) => {
215
+ if (!targetInstances.has(cid)) {
216
+ targetInstances.add(cid);
217
+ }
218
+ });
219
+ }
220
+ } catch (e) {
221
+ console.error(
222
+ "[ESB] Timeout/Error fetching instances from Hub Server",
223
+ e.message,
224
+ );
225
+ }
226
+ }
227
+
228
+ // 3. Blast the Targeted Emits
229
+ if (targetInstances.size > 0) {
230
+ // console.log(
231
+ // `[ESB] Routing '${eventName}' to ${targetInstances.size} active instance(s) for users: ${userIds.join(", ")}`,
232
+ // );
233
+ for (const cid of targetInstances) {
234
+ this.emitClient(cid, eventName, payload);
235
+ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * TARGETED EMIT BY GROUP: Queries the DB for User IDs, then hands off to emitUsers
241
+ */
242
+ async emitUserGroups(groupIds, eventName, payload) {
243
+ if (!Array.isArray(groupIds) || groupIds.length === 0) return;
244
+
245
+ try {
246
+ // 1. Construct the exact payload format for orm.pas
247
+ const dbPayload = {
248
+ distinct: "true", // Triggers the DISTINCT keyword in orm.pas
249
+ columns: [{ data: "SYS$UGMEMBER.USER_ID", key: "USER_ID" }],
250
+ filter: [
251
+ {
252
+ junction: "",
253
+ column: "SYS$UGMEMBER.SYS$USERGROUP_ID",
254
+ operator: "IN",
255
+ value1: `(${groupIds.join(",")})`, // Generates: IN (1,2,3)
256
+ },
257
+ ],
258
+ };
259
+
260
+ // 2. Execute the local LAN query via the DataSnap Adapter
261
+ const result = await queryData(dbPayload);
262
+ // 3. Extract the unique User IDs from the mapped result
263
+ if (Array.isArray(result) && result.length > 0) {
264
+ const extractedUserIds = result.map((row) => row.USER_ID);
265
+
266
+ // console.log(
267
+ // `[ESB] emitUserGroups mapped ${groupIds.length} group(s) to ${extractedUserIds.length} unique user(s).`,
268
+ // );
269
+
270
+ // 4. Hand off to the proven emitUsers Strict DLQ engine
271
+ if (extractedUserIds.length > 0) {
272
+ await this.emitUsers(extractedUserIds, eventName, payload);
273
+ }
274
+ } else {
275
+ // console.log(
276
+ // `[ESB] emitUserGroups found no active users for groups: ${groupIds.join(", ")}`,
277
+ // );
278
+ }
279
+ } catch (err) {
280
+ console.error(
281
+ "[ESB] emitUserGroups Query Error:",
282
+ err.message || err,
283
+ );
284
+ }
285
+ }
286
+ }
287
+
288
+ export const ClientEmitter = new ClientEmitterService();
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { workerEvents } from "../worker/cliWorkerPool.js";
3
+ import { ClientEmitter } from "./clientEmitter.js";
3
4
 
4
5
  export class CLI_Dispatcher {
5
6
  constructor(mode, ioServerInstance = null) {
@@ -52,31 +53,39 @@ export class CLI_Dispatcher {
52
53
  });
53
54
  }
54
55
 
55
- workerEvents.on("IPC_RELAY", (topic, payload) => {
56
- this.balanceAndDispatchEvent(topic, payload);
56
+ workerEvents.on("IPC_RELAY", (topic, data) => {
57
+ this.balanceAndDispatchEvent(topic, data);
57
58
  });
58
59
  }
59
60
 
60
61
  balanceAndDispatchEvent(topic, payload) {
61
62
  // --- EMIT TO SPECIFIC BIZ-A CLIENT ---
62
- // ex: this.lib.emitClient(scriptData.clientSockId, scriptData.eventUUIDName, { status: "Done!" });
63
+ // ex: this.lib.emitClient(scriptData.clientInstanceId, scriptData.eventName, { status: "Done!" });
63
64
  if (topic === "emitClient") {
64
- console.log(
65
- `\n[Future Dev] Target: Biz-A Client Socket ID [${payload?.clientSockId}]`,
66
- );
67
- console.log(`[Future Dev] Event: ${payload?.eventUUIDName}`);
68
- console.log(`[Future Dev] Payload:`, payload?.payload, `\n`);
69
- // TODO: this.agentNamespace.to(payload.clientSockId).emit(payload.eventUUIDName, payload.payload);
65
+ const { clientInstanceId, eventName, data } = payload;
66
+ ClientEmitter.emitClient(clientInstanceId, eventName, data);
70
67
  return;
71
68
  }
72
69
 
73
70
  // --- EMIT TO ALL BIZ-A CLIENT ---
74
71
  // ex: this.lib.broadcastClient("ui:alert", { message: "System maintenance in 5 mins" });
75
72
  if (topic === "broadcastClient") {
76
- console.log(`\n[Future Dev] Broadcast: ALL Clients in CLIENT_ROOM`);
77
- console.log(`[Future Dev] Event: ${payload?.topic}`);
78
- console.log(`[Future Dev] Payload:`, payload?.payload, `\n`);
79
- // TODO: this.agentNamespace.to("clientRoom").emit(payload.topic, payload.payload);
73
+ const { eventName, data } = payload;
74
+ ClientEmitter.broadcastClient(eventName, data);
75
+ return;
76
+ }
77
+
78
+ // --- EMIT BY USERS ---
79
+ if (topic === "emitUsers") {
80
+ const { userIds, eventName, data } = payload;
81
+ ClientEmitter.emitUsers(userIds, eventName, data);
82
+ return;
83
+ }
84
+
85
+ // --- EMIT BY USER GROUPS ---
86
+ if (topic === "emitUserGroups") {
87
+ const { groupIds, eventName, data } = payload;
88
+ ClientEmitter.emitUserGroups(groupIds, eventName, data);
80
89
  return;
81
90
  }
82
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15235",
3
+ "version": "2.3.80-15267",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",
@@ -5,6 +5,7 @@ import * as dayjs from "dayjs";
5
5
  import timezone from "dayjs/plugin/timezone.js";
6
6
  import utc from "dayjs/plugin/utc.js";
7
7
  import crypto from "crypto";
8
+ import { secretProvider } from "../security/secretProvider.js";
8
9
  import { createLogger, transports, format } from "winston";
9
10
  import { execCLIScriptWorker } from "../worker/cliWorkerPool.js";
10
11
 
@@ -199,6 +200,23 @@ export async function extractFunctionScript(
199
200
  axios: axiosInstance, //SCY BZ 4316
200
201
  dayjs: dayjs.default,
201
202
  crypto: crypto,
203
+ // Keyed HMAC by NAME. The key never enters the CLI script — it stays in the
204
+ // platform secret provider (file -> DPAPI -> Vault, swappable). Fails closed.
205
+ hmac: (name, data) => {
206
+ const { key } = secretProvider.resolveForSign(name);
207
+ return crypto.createHmac("sha256", key).update(String(data)).digest("hex");
208
+ },
209
+ // Rotation-aware verify: resolves the key by the row's stored keyId.
210
+ hmacVerify: (name, data, digest, keyId) => {
211
+ const { key } = secretProvider.resolveForVerify(name, keyId);
212
+ const expected = crypto
213
+ .createHmac("sha256", key)
214
+ .update(String(data))
215
+ .digest("hex");
216
+ const a = Buffer.from(expected, "hex");
217
+ const b = Buffer.from(String(digest || ""), "hex");
218
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
219
+ },
202
220
  log: logger,
203
221
  data: { sendModel, queryData, crudData, genId, getUrlAndParam },
204
222
  ...extraLib,
@@ -71,20 +71,38 @@ export const run = async (apiConfig, scriptIdentifier, scriptData) => {
71
71
  };
72
72
 
73
73
  // 2. Targeted Client UI Update
74
- lib.emitClient = (clientSockId, eventUUIDName, payload) => {
74
+ lib.emitClient = (clientInstanceId, eventName, data) => {
75
75
  workerpool.workerEmit({
76
76
  type: "IPC_RELAY",
77
77
  topic: "emitClient",
78
- payload: { clientSockId, eventUUIDName, payload },
78
+ payload: { clientInstanceId, eventName, data },
79
79
  });
80
80
  };
81
81
 
82
82
  // 3. Broadcast Client UI Update
83
- lib.broadcastClient = (topic, payload) => {
83
+ lib.broadcastClient = (eventName, data) => {
84
84
  workerpool.workerEmit({
85
85
  type: "IPC_RELAY",
86
86
  topic: "broadcastClient",
87
- payload: { topic, payload },
87
+ payload: { eventName, data },
88
+ });
89
+ };
90
+
91
+ // 4. Targeted by Users
92
+ lib.emitUsers = (userIds, eventName, data) => {
93
+ workerpool.workerEmit({
94
+ type: "IPC_RELAY",
95
+ topic: "emitUsers",
96
+ payload: { userIds, eventName, data },
97
+ });
98
+ };
99
+
100
+ // 5. Targeted by User Groups
101
+ lib.emitUserGroups = (groupIds, eventName, data) => {
102
+ workerpool.workerEmit({
103
+ type: "IPC_RELAY",
104
+ topic: "emitUserGroups",
105
+ payload: { groupIds, eventName, data },
88
106
  });
89
107
  };
90
108
  };
@@ -100,12 +118,15 @@ export const run = async (apiConfig, scriptIdentifier, scriptData) => {
100
118
  db.save(payload, mergeConfig(customCfg)),
101
119
  remove: (payload, customCfg) =>
102
120
  db.remove(payload, mergeConfig(customCfg)),
103
- deleteMD: (id, master, details, customCfg) =>
104
- db.deleteMD(id, master, details, mergeConfig(customCfg)),
121
+ removeMD: (id, master, details, customCfg) =>
122
+ db.removeMD(id, master, details, mergeConfig(customCfg)),
105
123
  list: (payload, customCfg) =>
106
124
  db.list(payload, mergeConfig(customCfg)),
107
125
  executeBlock: (sql, customCfg) =>
108
126
  db.executeBlock(sql, mergeConfig(customCfg)),
127
+ ormGet: (payload, customCfg) =>
128
+ db.ormGet(payload, mergeConfig(customCfg)),
129
+ getGlobalConfig: () => db.getGlobalConfig(),
109
130
  };
110
131
  };
111
132
 
@@ -15,7 +15,10 @@ export const cliScriptWorkerPool = workerpool.pool(
15
15
  workerType: "thread",
16
16
  emitStdStreams: true, // the worker will emit stdout and stderr events instead of passing it through to the parent streams
17
17
  workerThreadOpts: {
18
- env: { NODE_ENV: process.env.NODE_ENV || "production" },
18
+ // worker env is a whitelist (not the full process.env). No application secrets here:
19
+ // CLI scripts get keyed HMAC via lib.hmac(name,...), resolved by the platform secret
20
+ // provider from the secrets store. BIZA_SECRETS_FILE (optional) points at that store.
21
+ env: { NODE_ENV: process.env.NODE_ENV || "production", BIZA_SECRETS_FILE: process.env.BIZA_SECRETS_FILE },
19
22
  },
20
23
  },
21
24
  );