biz-a-cli 2.3.80-15280 → 2.3.80-15292
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/bin/app.js +379 -0
- package/engine/domain/appConfig.js +318 -3
- package/engine/domain/applicationConfig.js +3 -0
- package/engine/ext/live.js +30 -8
- package/package.json +1 -1
package/bin/app.js
CHANGED
|
@@ -11,10 +11,19 @@ import {
|
|
|
11
11
|
constants as cryptoConstants,
|
|
12
12
|
} from "node:crypto";
|
|
13
13
|
import path, { basename } from "node:path";
|
|
14
|
+
import os from "node:os";
|
|
14
15
|
import { env } from "../envs/env.js";
|
|
15
16
|
import { prepareScript, encryptScript } from "./script.js";
|
|
16
17
|
import { spawn } from "node:child_process";
|
|
17
18
|
import { newMigration, runMigrations } from "./migrate.js";
|
|
19
|
+
import {
|
|
20
|
+
APP_CONFIG_NAME,
|
|
21
|
+
extractConfigVersion,
|
|
22
|
+
getChunkedUpsertBlocks,
|
|
23
|
+
getUpsertBlock,
|
|
24
|
+
isSemanticVersion,
|
|
25
|
+
parseConfigForVersion,
|
|
26
|
+
} from "../engine/domain/appConfig.js";
|
|
18
27
|
|
|
19
28
|
const getKeyFolderPath = () => {
|
|
20
29
|
const scriptPath =
|
|
@@ -76,6 +85,20 @@ const addCommandOptions = {
|
|
|
76
85
|
demandOption: false,
|
|
77
86
|
default: false,
|
|
78
87
|
},
|
|
88
|
+
publish: {
|
|
89
|
+
describe:
|
|
90
|
+
"Config mode only (directory contains application.js): after saving, publish the domain DDL then the application config — the same as the admin App Config Publish button",
|
|
91
|
+
type: "boolean",
|
|
92
|
+
demandOption: false,
|
|
93
|
+
default: false,
|
|
94
|
+
},
|
|
95
|
+
forceFull: {
|
|
96
|
+
describe:
|
|
97
|
+
"With --publish: re-sync EVERY entity, ignoring the per-entity DDL hash cache. Use after an out-of-band schema change, or to get a complete redundant-index report",
|
|
98
|
+
type: "boolean",
|
|
99
|
+
demandOption: false,
|
|
100
|
+
default: false,
|
|
101
|
+
},
|
|
79
102
|
};
|
|
80
103
|
|
|
81
104
|
const removeCommandOptions = {
|
|
@@ -287,6 +310,321 @@ const getFileList = ({ workingDir = process.cwd(), files = null } = {}) => {
|
|
|
287
310
|
});
|
|
288
311
|
};
|
|
289
312
|
|
|
313
|
+
/* ============================================================================
|
|
314
|
+
CONFIG MODE — `biza add` on a directory containing application.js uploads SYS$CONFIG rows
|
|
315
|
+
instead of packing an encrypted app bundle, replacing the admin App Config paste/Save/Publish
|
|
316
|
+
round-trip. Entirely metadata-driven (platform code stays application-agnostic): the domain
|
|
317
|
+
NAME comes from each file's metadata.name and the VERSION from metadata.version.
|
|
318
|
+
============================================================================ */
|
|
319
|
+
|
|
320
|
+
const APPLICATION_CONFIG_FILE = "application.js";
|
|
321
|
+
/* Parity with the admin editor (appConfig.component.ts executeBlockBodyLimit): the DataSnap
|
|
322
|
+
`block` call body must stay under 64KB, so an oversized config is upserted in chunks. */
|
|
323
|
+
const EXECUTE_BLOCK_BODY_LIMIT = 2 ** 16;
|
|
324
|
+
|
|
325
|
+
const findApplicationConfigFile = (fileNames = []) =>
|
|
326
|
+
fileNames.find(
|
|
327
|
+
(fileName) =>
|
|
328
|
+
path.basename(String(fileName)).toLowerCase() === APPLICATION_CONFIG_FILE,
|
|
329
|
+
) || null;
|
|
330
|
+
|
|
331
|
+
/* Does this directory ship configs rather than app scripts? Checked BEFORE the jest gate: that
|
|
332
|
+
gate exists to validate app scripts before they are bundled, and a config directory has nothing
|
|
333
|
+
to test — worse, jest exits non-zero there ("Could not find a config file", after traversing up),
|
|
334
|
+
which would abort the upload outright. */
|
|
335
|
+
const isConfigModeDir = (workingDir, files = null) => {
|
|
336
|
+
try {
|
|
337
|
+
return !!findApplicationConfigFile(
|
|
338
|
+
getFileList({ workingDir: path.resolve(workingDir), files }),
|
|
339
|
+
);
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/* Run one upsert block against DataSnap. Deliberately NOT db/db.js executeBlock: that path runs
|
|
346
|
+
flattenSql(), whose `--` comment stripping mangles a config upsert — the whole config is embedded
|
|
347
|
+
as a single-line quoted literal, so the first `--` ANYWHERE in the config text (a JS comment, a
|
|
348
|
+
CSS custom property, an HTML template) silently truncates the rest of the statement and Firebird
|
|
349
|
+
reports "Unexpected end of command". getUpsertBlock already emits single-line SQL, so there is
|
|
350
|
+
nothing to flatten. This mirrors what the admin editor sends (dataService.executeBlock). */
|
|
351
|
+
const executeConfigBlock = async (blockSql, { server, apiPort, dbIndex }) => {
|
|
352
|
+
const url = `${server}:${apiPort}/fina/rest/TOrmMethod/%22block%22`;
|
|
353
|
+
const data = JSON.stringify({
|
|
354
|
+
_parameters: [
|
|
355
|
+
JSON.stringify({ dbIndex, method: "block", object: blockSql }),
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
const res = await axios.post(url, data, {
|
|
359
|
+
headers: { "content-type": "text/plain" },
|
|
360
|
+
});
|
|
361
|
+
const parsed =
|
|
362
|
+
typeof res.data === "string" ? JSON.parse(res.data) : res.data;
|
|
363
|
+
if (parsed && parsed.error) {
|
|
364
|
+
throw new Error(parsed.error);
|
|
365
|
+
}
|
|
366
|
+
return parsed;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const estimateExecuteBlockBodyLength = (blockSql, dbIndex) =>
|
|
370
|
+
JSON.stringify({
|
|
371
|
+
_parameters: [
|
|
372
|
+
JSON.stringify({ dbIndex, method: "block", object: blockSql }),
|
|
373
|
+
],
|
|
374
|
+
}).length;
|
|
375
|
+
|
|
376
|
+
/* One upsert block when it fits, else halve the chunk size until every block fits. Mirrors
|
|
377
|
+
appConfig.component.ts resolveUpsertBlocksByBodyLimit. */
|
|
378
|
+
const resolveUpsertBlocks = (name, version, content, dbIndex) => {
|
|
379
|
+
const singleBlock = getUpsertBlock(name, version, content);
|
|
380
|
+
if (
|
|
381
|
+
estimateExecuteBlockBodyLength(singleBlock, dbIndex) <=
|
|
382
|
+
EXECUTE_BLOCK_BODY_LIMIT
|
|
383
|
+
) {
|
|
384
|
+
return [singleBlock];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let chunkSize = EXECUTE_BLOCK_BODY_LIMIT;
|
|
388
|
+
while (chunkSize > 0) {
|
|
389
|
+
const blocks = getChunkedUpsertBlocks(name, version, content, chunkSize);
|
|
390
|
+
if (
|
|
391
|
+
blocks.every(
|
|
392
|
+
(block) =>
|
|
393
|
+
estimateExecuteBlockBodyLength(block, dbIndex) <=
|
|
394
|
+
EXECUTE_BLOCK_BODY_LIMIT,
|
|
395
|
+
)
|
|
396
|
+
) {
|
|
397
|
+
return blocks;
|
|
398
|
+
}
|
|
399
|
+
chunkSize = Math.floor(chunkSize / 2);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
throw new Error(
|
|
403
|
+
`Failed to split "${name}" into executeBlock-sized chunks.`,
|
|
404
|
+
);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/* PURE-ish (reads files, no network): resolve every config in the directory and validate the
|
|
408
|
+
WHOLE set before anything is uploaded, so a stray lib or a typo'd metadata can never leave a
|
|
409
|
+
half-applied config in SYS$CONFIG. Every problem is collected and reported at once, each
|
|
410
|
+
naming its file. */
|
|
411
|
+
const collectConfigUploads = ({ workingDir, fileNames = [] }) => {
|
|
412
|
+
const problems = [];
|
|
413
|
+
const readConfig = (fileName) =>
|
|
414
|
+
fs
|
|
415
|
+
.readFileSync(
|
|
416
|
+
path.isAbsolute(fileName)
|
|
417
|
+
? fileName
|
|
418
|
+
: path.join(workingDir, fileName),
|
|
419
|
+
)
|
|
420
|
+
.toString();
|
|
421
|
+
|
|
422
|
+
const applicationFile = findApplicationConfigFile(fileNames);
|
|
423
|
+
let application = null;
|
|
424
|
+
if (applicationFile) {
|
|
425
|
+
const content = readConfig(applicationFile);
|
|
426
|
+
let version = null;
|
|
427
|
+
try {
|
|
428
|
+
version = extractConfigVersion(content);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
problems.push(
|
|
431
|
+
`${path.basename(applicationFile)}: ${error?.message ?? error}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (!version) {
|
|
435
|
+
problems.push(
|
|
436
|
+
`${path.basename(applicationFile)}: metadata.version is required.`,
|
|
437
|
+
);
|
|
438
|
+
} else if (!isSemanticVersion(version)) {
|
|
439
|
+
problems.push(
|
|
440
|
+
`${path.basename(applicationFile)}: metadata.version "${version}" must be semantic (x.y.z).`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
application = { name: APP_CONFIG_NAME, version, content };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const domains = [];
|
|
447
|
+
const seenNames = new Map();
|
|
448
|
+
for (const fileName of fileNames) {
|
|
449
|
+
const baseName = path.basename(String(fileName));
|
|
450
|
+
if (baseName.toLowerCase() === APPLICATION_CONFIG_FILE) continue;
|
|
451
|
+
/* menu.json is a bundle-only concept; it has no place in a config upload. */
|
|
452
|
+
if (!baseName.toLowerCase().endsWith(".js")) continue;
|
|
453
|
+
|
|
454
|
+
const content = readConfig(fileName);
|
|
455
|
+
let parsed = null;
|
|
456
|
+
try {
|
|
457
|
+
parsed = parseConfigForVersion(content);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
problems.push(`${baseName}: ${error?.message ?? error}`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const name = String(parsed?.metadata?.name ?? "").trim();
|
|
464
|
+
const version = String(parsed?.metadata?.version ?? "").trim();
|
|
465
|
+
if (!name) {
|
|
466
|
+
problems.push(
|
|
467
|
+
`${baseName}: metadata.name is required (expected a domain config).`,
|
|
468
|
+
);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!version) {
|
|
472
|
+
problems.push(`${baseName}: metadata.version is required.`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (!isSemanticVersion(version)) {
|
|
476
|
+
problems.push(
|
|
477
|
+
`${baseName}: metadata.version "${version}" must be semantic (x.y.z).`,
|
|
478
|
+
);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (seenNames.has(name)) {
|
|
482
|
+
problems.push(
|
|
483
|
+
`${baseName}: metadata.name "${name}" is already declared by ${seenNames.get(name)}.`,
|
|
484
|
+
);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
seenNames.set(name, baseName);
|
|
488
|
+
domains.push({ name, version, content, fileName: baseName });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (problems.length > 0) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`Config upload aborted, nothing was uploaded:\n - ${problems.join("\n - ")}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return { application, domains };
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
/* Upsert the configs, then optionally run the same publish the admin button does. Publish order
|
|
501
|
+
matches appConfig.service.ts publishEditorConfigs: every domain DDL publish first (sequential),
|
|
502
|
+
then the application-config publish (which regenerates template scaffolding + rebuilds the
|
|
503
|
+
bundle). */
|
|
504
|
+
async function uploadConfigs({
|
|
505
|
+
workingDir,
|
|
506
|
+
fileNames,
|
|
507
|
+
server,
|
|
508
|
+
apiPort,
|
|
509
|
+
dbIndex,
|
|
510
|
+
sub,
|
|
511
|
+
publish = false,
|
|
512
|
+
forceFull = false,
|
|
513
|
+
}) {
|
|
514
|
+
const { application, domains } = collectConfigUploads({
|
|
515
|
+
workingDir,
|
|
516
|
+
fileNames,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const apiConfig = {
|
|
520
|
+
url: `${server}:${apiPort}`,
|
|
521
|
+
dbindex: dbIndex,
|
|
522
|
+
timeout: 60000,
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const saveOne = async ({ name, version, content }) => {
|
|
526
|
+
const blocks = resolveUpsertBlocks(name, version, content, dbIndex);
|
|
527
|
+
for (const block of blocks) {
|
|
528
|
+
await executeConfigBlock(block, { server, apiPort, dbIndex });
|
|
529
|
+
}
|
|
530
|
+
console.log(
|
|
531
|
+
`saved ${name}@${version}${blocks.length > 1 ? ` (${blocks.length} chunks)` : ""}`,
|
|
532
|
+
);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
if (application) {
|
|
536
|
+
await saveOne(application);
|
|
537
|
+
}
|
|
538
|
+
for (const domain of domains) {
|
|
539
|
+
await saveOne(domain);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!publish) {
|
|
543
|
+
return {
|
|
544
|
+
success: true,
|
|
545
|
+
data: {
|
|
546
|
+
applicationConfig: application,
|
|
547
|
+
domains: domains.map(({ name, version }) => ({ name, version })),
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/* Dynamic import: engine/domain/system.js dynamically imports THIS file, so a static import
|
|
553
|
+
of the publish entry points would close that cycle. */
|
|
554
|
+
const { publishConfigs } = await import("../engine/domain/appConfig.js");
|
|
555
|
+
const { publishApplicationConfig } = await import(
|
|
556
|
+
"../engine/domain/applicationConfig.js"
|
|
557
|
+
);
|
|
558
|
+
/* system.js publishApp reads argv.server / argv.port / argv.dbindex / argv.subdomain. */
|
|
559
|
+
const publishArgv = {
|
|
560
|
+
server,
|
|
561
|
+
port: apiPort,
|
|
562
|
+
dbindex: dbIndex,
|
|
563
|
+
subdomain: sub,
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
for (const domain of domains) {
|
|
567
|
+
const result = await publishConfigs(
|
|
568
|
+
{
|
|
569
|
+
domainName: domain.name,
|
|
570
|
+
domainVersion: domain.version,
|
|
571
|
+
dbIndex,
|
|
572
|
+
url: apiConfig.url,
|
|
573
|
+
forceFull,
|
|
574
|
+
},
|
|
575
|
+
publishArgv,
|
|
576
|
+
);
|
|
577
|
+
if (!result || result.success !== true) {
|
|
578
|
+
throw new Error(
|
|
579
|
+
`Publish ${domain.name}@${domain.version} failed: ${result?.error ?? "no error message from server"}`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
console.log(`published ${domain.name}@${domain.version}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (application) {
|
|
586
|
+
/* publishApplicationConfig -> publishApp -> addApp({ workingDir: process.cwd() }), and
|
|
587
|
+
addApp folds getFileList(cwd) into the bundle. cwd is the CONFIG directory right now, so
|
|
588
|
+
without this the configs themselves would be packed as app scripts. Run the publish from
|
|
589
|
+
an empty scratch dir so only the generated templates are bundled. */
|
|
590
|
+
const scratchDir = fs.mkdtempSync(
|
|
591
|
+
path.join(os.tmpdir(), "biza-publish-"),
|
|
592
|
+
);
|
|
593
|
+
const previousCwd = process.cwd();
|
|
594
|
+
try {
|
|
595
|
+
process.chdir(scratchDir);
|
|
596
|
+
const result = await publishApplicationConfig(
|
|
597
|
+
{
|
|
598
|
+
applicationConfigName: application.name,
|
|
599
|
+
applicationConfigVersion: application.version,
|
|
600
|
+
dbIndex,
|
|
601
|
+
url: apiConfig.url,
|
|
602
|
+
subdomain: sub,
|
|
603
|
+
},
|
|
604
|
+
publishArgv,
|
|
605
|
+
);
|
|
606
|
+
if (!result || result.success !== true) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Publish ${application.name}@${application.version} failed: ${result?.error ?? "no error message from server"}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
console.log(`published ${application.name}@${application.version}`);
|
|
612
|
+
} finally {
|
|
613
|
+
process.chdir(previousCwd);
|
|
614
|
+
fs.rmSync(scratchDir, { force: true, recursive: true });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
success: true,
|
|
620
|
+
data: {
|
|
621
|
+
applicationConfig: application,
|
|
622
|
+
domains: domains.map(({ name, version }) => ({ name, version })),
|
|
623
|
+
published: true,
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
290
628
|
const parseApplicationConfigData = (rawConfig) => {
|
|
291
629
|
if (rawConfig == null) {
|
|
292
630
|
return null;
|
|
@@ -388,6 +726,8 @@ async function addApp({
|
|
|
388
726
|
sub,
|
|
389
727
|
files = null,
|
|
390
728
|
body = null,
|
|
729
|
+
publish = false,
|
|
730
|
+
forceFull = false,
|
|
391
731
|
} = {}) {
|
|
392
732
|
const oldCwd = process.cwd();
|
|
393
733
|
|
|
@@ -426,6 +766,23 @@ async function addApp({
|
|
|
426
766
|
new Set([...sourceFiles, ...bodySourceFiles]),
|
|
427
767
|
);
|
|
428
768
|
|
|
769
|
+
/* CONFIG MODE. An application.js in the working directory means the caller is shipping
|
|
770
|
+
SYS$CONFIG rows, not libs — upload the configs instead of packing a bundle. Guarded on
|
|
771
|
+
`body == null` so the publish path (publishApplicationConfig -> publishApp -> addApp with
|
|
772
|
+
generated template scripts) can never recurse back into config mode. */
|
|
773
|
+
if (body == null && findApplicationConfigFile(mergedSourceFiles)) {
|
|
774
|
+
return await uploadConfigs({
|
|
775
|
+
workingDir: process.cwd(),
|
|
776
|
+
fileNames: mergedSourceFiles,
|
|
777
|
+
server,
|
|
778
|
+
apiPort,
|
|
779
|
+
dbIndex,
|
|
780
|
+
sub,
|
|
781
|
+
publish,
|
|
782
|
+
forceFull,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
429
786
|
if (mergedSourceFiles.length > 0) {
|
|
430
787
|
const keys = await prepareKeys();
|
|
431
788
|
if (!keys) {
|
|
@@ -578,6 +935,8 @@ async function runUnitTests(options) {
|
|
|
578
935
|
dbIndex: options.dbIndex,
|
|
579
936
|
sub: options.sub,
|
|
580
937
|
files: options.files,
|
|
938
|
+
publish: options.publish,
|
|
939
|
+
forceFull: options.forceFull,
|
|
581
940
|
});
|
|
582
941
|
} else {
|
|
583
942
|
console.error("Biz-A Add aborted");
|
|
@@ -598,6 +957,21 @@ const buildCli = () =>
|
|
|
598
957
|
"Add Biz-A Application",
|
|
599
958
|
addCommandOptions,
|
|
600
959
|
async (commandOptions) => {
|
|
960
|
+
/* Config mode uploads SYS$CONFIG rows, not app scripts — skip the jest gate. */
|
|
961
|
+
if (isConfigModeDir(commandOptions.workingDir, commandOptions.files)) {
|
|
962
|
+
await addApp({
|
|
963
|
+
workingDir: path.resolve(commandOptions.workingDir),
|
|
964
|
+
verbose: commandOptions.verbose,
|
|
965
|
+
server: commandOptions.server,
|
|
966
|
+
apiPort: commandOptions.apiPort,
|
|
967
|
+
dbIndex: commandOptions.dbIndex,
|
|
968
|
+
sub: commandOptions.sub,
|
|
969
|
+
files: commandOptions.files,
|
|
970
|
+
publish: commandOptions.publish,
|
|
971
|
+
forceFull: commandOptions.forceFull,
|
|
972
|
+
});
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
601
975
|
await runUnitTests(commandOptions);
|
|
602
976
|
},
|
|
603
977
|
)
|
|
@@ -691,4 +1065,9 @@ export {
|
|
|
691
1065
|
removeCommandOptions,
|
|
692
1066
|
getFileList,
|
|
693
1067
|
addApp,
|
|
1068
|
+
findApplicationConfigFile,
|
|
1069
|
+
collectConfigUploads,
|
|
1070
|
+
resolveUpsertBlocks,
|
|
1071
|
+
uploadConfigs,
|
|
1072
|
+
isConfigModeDir,
|
|
694
1073
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { IDLE_SOCKET_TIMEOUT_MILLISECONDS } from "../../bin/hubEvent.js";
|
|
3
3
|
import { queryData, executeBlock, getGlobalConfig } from "../../db/db.js";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import * as firebirdDdl from "./firebird-ddl.js";
|
|
5
6
|
|
|
6
7
|
export const TBL_SYS_CONFIG = "SYS$CONFIG";
|
|
@@ -1113,7 +1114,7 @@ export const buildAppendOnlySyncPlan = (table, exceptionRows, triggerRows) => {
|
|
|
1113
1114
|
|
|
1114
1115
|
export const buildDomainDdlPreview = (domainConfigContent, defaultDbIndex) => {
|
|
1115
1116
|
const buildResult = buildDomainSchemaInternal(domainConfigContent, defaultDbIndex);
|
|
1116
|
-
const schema = buildResult.schema;
|
|
1117
|
+
const schema = pruneIndexesCoveredByForeignKeys(buildResult.schema);
|
|
1117
1118
|
return {
|
|
1118
1119
|
schema,
|
|
1119
1120
|
ddlSql: schema.tables.length > 0 ? generateCreateSchemaSql(schema, true) : "",
|
|
@@ -1637,6 +1638,37 @@ const applyDirectRelationship = (relationship, entityInfos, warnings) => {
|
|
|
1637
1638
|
});
|
|
1638
1639
|
};
|
|
1639
1640
|
|
|
1641
|
+
const indexColumnsKey = (columns) =>
|
|
1642
|
+
(columns ?? []).map((column) => String(column).trim().toUpperCase()).join(",");
|
|
1643
|
+
|
|
1644
|
+
/* Firebird creates an index for EVERY foreign key constraint automatically, while
|
|
1645
|
+
buildAttributeIndexes independently indexes any lookup / *_ID column — so an FK column used to
|
|
1646
|
+
end up with two identical indexes (71 such pairs in the live schema). The actual-side snapshot
|
|
1647
|
+
cannot even see the clash: indexesSql filters constraint-owned indexes out (`rc.RDB$INDEX_NAME
|
|
1648
|
+
IS NULL`), so the sync kept recreating its own copy forever.
|
|
1649
|
+
|
|
1650
|
+
Drop from the EXPECTED schema any plain index whose column set exactly matches a foreign key on
|
|
1651
|
+
the same table. Exact match only: the deliberate [FK, PK] covering index that
|
|
1652
|
+
applyDirectRelationship builds for joins is a superset and is kept, as are unique and
|
|
1653
|
+
expression indexes (those carry semantics the FK index does not). Idempotent. */
|
|
1654
|
+
export const pruneIndexesCoveredByForeignKeys = (schema) => {
|
|
1655
|
+
for (const table of schema?.tables ?? []) {
|
|
1656
|
+
const foreignKeyColumnSets = new Set(
|
|
1657
|
+
(table.foreignKeys ?? []).map((fk) => indexColumnsKey(fk.columns)),
|
|
1658
|
+
);
|
|
1659
|
+
if (foreignKeyColumnSets.size === 0) {
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
table.indexes = (table.indexes ?? []).filter((index) => {
|
|
1663
|
+
if (index?.unique === true || index?.expression) {
|
|
1664
|
+
return true;
|
|
1665
|
+
}
|
|
1666
|
+
return !foreignKeyColumnSets.has(indexColumnsKey(index?.columns));
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
return schema;
|
|
1670
|
+
};
|
|
1671
|
+
|
|
1640
1672
|
const applyManyToManyRelationship = (relationship, entityInfos, tableInfos, tables, warnings) => {
|
|
1641
1673
|
const fromInfo = entityInfos.get(relationship.from);
|
|
1642
1674
|
const toInfo = entityInfos.get(relationship.to);
|
|
@@ -1922,7 +1954,7 @@ const parseDomainConfigContent = (content) => {
|
|
|
1922
1954
|
}
|
|
1923
1955
|
};
|
|
1924
1956
|
|
|
1925
|
-
const enrichSchemaWithEntityIndices = (schema, domainConfigContent) => {
|
|
1957
|
+
export const enrichSchemaWithEntityIndices = (schema, domainConfigContent) => {
|
|
1926
1958
|
const parsed = parseDomainConfigContent(domainConfigContent);
|
|
1927
1959
|
const entities = parsed && typeof parsed === "object" ? parsed?.model?.entities : null;
|
|
1928
1960
|
if (!entities || typeof entities !== "object") {
|
|
@@ -2315,6 +2347,221 @@ const splitSqlStatements = (sqlBatch) => {
|
|
|
2315
2347
|
return statements.map((statement) => sanitizeSql(statement));
|
|
2316
2348
|
};
|
|
2317
2349
|
|
|
2350
|
+
|
|
2351
|
+
/* ============================================================================
|
|
2352
|
+
PER-ENTITY DDL HASH — publish used to introspect EVERY table on every run (loadTableSnapshot
|
|
2353
|
+
fires 8 queries per table, so ~640 round-trips for an 80-entity domain) even when nothing had
|
|
2354
|
+
changed. The expected definition of each table is hashed and the map persisted alongside the
|
|
2355
|
+
domain in SYS$CONFIG as `<domain>__DDL_HASH`; a table whose hash is unchanged is skipped without
|
|
2356
|
+
a single query. `forceFull` bypasses the cache — use it after any out-of-band schema change.
|
|
2357
|
+
============================================================================ */
|
|
2358
|
+
|
|
2359
|
+
/* Key order must not affect the hash, so serialize with sorted keys. */
|
|
2360
|
+
const stableStringify = (value) => {
|
|
2361
|
+
if (Array.isArray(value)) {
|
|
2362
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
2363
|
+
}
|
|
2364
|
+
if (value && typeof value === "object") {
|
|
2365
|
+
return `{${Object.keys(value)
|
|
2366
|
+
.sort()
|
|
2367
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
2368
|
+
.join(",")}}`;
|
|
2369
|
+
}
|
|
2370
|
+
return JSON.stringify(value ?? null);
|
|
2371
|
+
};
|
|
2372
|
+
|
|
2373
|
+
/* migrationMode is part of the key: the same table yields different DDL under safe vs aggressive. */
|
|
2374
|
+
const computeTableDdlHash = (table, migrationMode) =>
|
|
2375
|
+
createHash("sha256")
|
|
2376
|
+
.update(`${migrationMode}|${stableStringify(table)}`)
|
|
2377
|
+
.digest("hex")
|
|
2378
|
+
.slice(0, 32);
|
|
2379
|
+
|
|
2380
|
+
export const buildDdlHashConfigKey = (domainName, domainVersion) => ({
|
|
2381
|
+
name: `${domainName}__DDL_HASH`,
|
|
2382
|
+
version: domainVersion,
|
|
2383
|
+
});
|
|
2384
|
+
|
|
2385
|
+
const loadDdlHashes = async (domainName, domainVersion, config) => {
|
|
2386
|
+
const { name, version } = buildDdlHashConfigKey(domainName, domainVersion);
|
|
2387
|
+
try {
|
|
2388
|
+
const rows = await queryData(getConfigByNameVersionParameters(name, version), config);
|
|
2389
|
+
const raw = rows?.[0]?.data;
|
|
2390
|
+
if (!raw) {
|
|
2391
|
+
return {};
|
|
2392
|
+
}
|
|
2393
|
+
const parsed = JSON.parse(String(raw));
|
|
2394
|
+
return parsed && typeof parsed.tables === "object" && parsed.tables ? parsed.tables : {};
|
|
2395
|
+
} catch {
|
|
2396
|
+
/* Unreadable or corrupt cache must never fail a publish — degrade to a full sync. */
|
|
2397
|
+
return {};
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
|
|
2401
|
+
const writeDdlHashes = async (domainName, domainVersion, tables, config) => {
|
|
2402
|
+
const { name, version } = buildDdlHashConfigKey(domainName, domainVersion);
|
|
2403
|
+
const payload = JSON.stringify({ tables });
|
|
2404
|
+
await executeBlock(getUpsertBlock(name, version, payload), config);
|
|
2405
|
+
};
|
|
2406
|
+
|
|
2407
|
+
/* Indexes that exist in the DATABASE but no longer earn their keep. Report only — the DROPs are
|
|
2408
|
+
handed back so an operator can review them, never executed by the publish. */
|
|
2409
|
+
const collectRedundantIndexes = (table, snapshot) => {
|
|
2410
|
+
const findings = [];
|
|
2411
|
+
const foreignKeyColumnSets = new Map();
|
|
2412
|
+
for (const fk of snapshot?.foreignKeys ?? []) {
|
|
2413
|
+
foreignKeyColumnSets.set(indexColumnsKey(fk.columns), fk.name);
|
|
2414
|
+
}
|
|
2415
|
+
const expectedNames = new Set(
|
|
2416
|
+
(table.indexes ?? []).map((index) => String(index.name).trim().toUpperCase()),
|
|
2417
|
+
);
|
|
2418
|
+
const seenColumnSets = new Map();
|
|
2419
|
+
|
|
2420
|
+
for (const index of snapshot?.indexes ?? []) {
|
|
2421
|
+
const indexName = String(index.name ?? "").trim().toUpperCase();
|
|
2422
|
+
if (!indexName) {
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
const columnsKey = indexColumnsKey(index.columns);
|
|
2426
|
+
const owningForeignKey = foreignKeyColumnSets.get(columnsKey);
|
|
2427
|
+
if (owningForeignKey && index.unique !== true) {
|
|
2428
|
+
findings.push({
|
|
2429
|
+
table: table.name,
|
|
2430
|
+
indexName,
|
|
2431
|
+
columns: index.columns ?? [],
|
|
2432
|
+
reason: `duplicates the index Firebird maintains for foreign key ${owningForeignKey}`,
|
|
2433
|
+
dropSql: `DROP INDEX ${indexName};`,
|
|
2434
|
+
});
|
|
2435
|
+
continue;
|
|
2436
|
+
}
|
|
2437
|
+
const twin = seenColumnSets.get(columnsKey);
|
|
2438
|
+
if (twin) {
|
|
2439
|
+
findings.push({
|
|
2440
|
+
table: table.name,
|
|
2441
|
+
indexName,
|
|
2442
|
+
columns: index.columns ?? [],
|
|
2443
|
+
reason: `indexes the same columns as ${twin}`,
|
|
2444
|
+
dropSql: `DROP INDEX ${indexName};`,
|
|
2445
|
+
});
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
seenColumnSets.set(columnsKey, indexName);
|
|
2449
|
+
if (!expectedNames.has(indexName)) {
|
|
2450
|
+
findings.push({
|
|
2451
|
+
table: table.name,
|
|
2452
|
+
indexName,
|
|
2453
|
+
columns: index.columns ?? [],
|
|
2454
|
+
reason: "not declared by the domain config",
|
|
2455
|
+
dropSql: `DROP INDEX ${indexName};`,
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
return findings;
|
|
2460
|
+
};
|
|
2461
|
+
|
|
2462
|
+
|
|
2463
|
+
/* ORPHAN COLUMNS — a column that exists in the DATABASE but is no longer declared by the config.
|
|
2464
|
+
A rename in the config is an ADD, not a rename: safe mode adds the new column and leaves the old
|
|
2465
|
+
one in place forever (ST_PRC_ROUTE_MAP kept FINA_ITEM_CATEGORY_ID in its old slot long after it
|
|
2466
|
+
became ST_ITEM_CATEGORY_ID). Always reported; dropped only under forceFull.
|
|
2467
|
+
|
|
2468
|
+
The guards and the statement ORDER mirror the aggressive branch of buildTableSyncPlan: a primary
|
|
2469
|
+
key column or one another table's FK points at is never touched, and dependent constraints and
|
|
2470
|
+
indexes come down before the column itself. */
|
|
2471
|
+
const collectOrphanColumns = (table, snapshot) => {
|
|
2472
|
+
const expectedColumns = new Set(
|
|
2473
|
+
(table.columns ?? []).map((column) => String(column.name).trim().toUpperCase()),
|
|
2474
|
+
);
|
|
2475
|
+
const primaryKeyColumns = new Set(
|
|
2476
|
+
(snapshot?.primaryKey ?? []).map((value) => String(value).trim().toUpperCase()),
|
|
2477
|
+
);
|
|
2478
|
+
const findings = [];
|
|
2479
|
+
|
|
2480
|
+
for (const actualColumn of snapshot?.columns ?? []) {
|
|
2481
|
+
const columnName = String(actualColumn.name).trim().toUpperCase();
|
|
2482
|
+
if (expectedColumns.has(columnName)) {
|
|
2483
|
+
continue;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
const base = { table: table.name, column: columnName, dropped: false, dropStatements: [] };
|
|
2487
|
+
|
|
2488
|
+
if (primaryKeyColumns.has(columnName)) {
|
|
2489
|
+
findings.push({ ...base, reason: "part of the primary key - requires manual migration" });
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
const incomingReferences = (snapshot?.incomingForeignKeys ?? []).filter((fk) =>
|
|
2494
|
+
(fk.referenceColumns ?? []).some((column) => String(column).trim().toUpperCase() === columnName),
|
|
2495
|
+
);
|
|
2496
|
+
if (incomingReferences.length > 0) {
|
|
2497
|
+
const names = incomingReferences.map((fk) => `${fk.name} (${fk.tableName})`).join(", ");
|
|
2498
|
+
findings.push({
|
|
2499
|
+
...base,
|
|
2500
|
+
reason: `referenced by foreign key ${names} - requires manual migration`,
|
|
2501
|
+
});
|
|
2502
|
+
continue;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const dropStatements = [];
|
|
2506
|
+
for (const fk of snapshot?.foreignKeys ?? []) {
|
|
2507
|
+
if ((fk.columns ?? []).some((column) => String(column).trim().toUpperCase() === columnName)) {
|
|
2508
|
+
dropStatements.push(`ALTER TABLE ${table.name} DROP CONSTRAINT ${fk.name};`);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
for (const uniqueKey of snapshot?.uniqueKeys ?? []) {
|
|
2512
|
+
if ((uniqueKey.columns ?? []).some((column) => String(column).trim().toUpperCase() === columnName)) {
|
|
2513
|
+
dropStatements.push(`ALTER TABLE ${table.name} DROP CONSTRAINT ${uniqueKey.name};`);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
for (const index of snapshot?.indexes ?? []) {
|
|
2517
|
+
if ((index.columns ?? []).some((column) => String(column).trim().toUpperCase() === columnName)) {
|
|
2518
|
+
dropStatements.push(`DROP INDEX ${index.name};`);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
dropStatements.push(`ALTER TABLE ${table.name} DROP ${actualColumn.name};`);
|
|
2522
|
+
|
|
2523
|
+
findings.push({
|
|
2524
|
+
...base,
|
|
2525
|
+
reason: "not declared by the domain config",
|
|
2526
|
+
dropStatements,
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
return findings;
|
|
2531
|
+
};
|
|
2532
|
+
|
|
2533
|
+
/* Under forceFull, actually drop the orphans — but never one that still holds data. Dropping a
|
|
2534
|
+
populated column cannot be undone, and a genuine rename ghost is empty by definition, so a
|
|
2535
|
+
non-zero count means the assumption does not hold and a human should look. */
|
|
2536
|
+
const dropOrphanColumns = async (orphans, tableDbIndex, ctx) => {
|
|
2537
|
+
for (const orphan of orphans) {
|
|
2538
|
+
if (orphan.dropStatements.length === 0) {
|
|
2539
|
+
continue;
|
|
2540
|
+
}
|
|
2541
|
+
let rowsWithData = 0;
|
|
2542
|
+
try {
|
|
2543
|
+
const countRows = await execRawSql(
|
|
2544
|
+
`SELECT COUNT(${orphan.column}) AS FILLED FROM ${orphan.table};`,
|
|
2545
|
+
tableDbIndex,
|
|
2546
|
+
ctx.config,
|
|
2547
|
+
);
|
|
2548
|
+
rowsWithData = Number(countRows?.[0]?.["filled"] ?? countRows?.[0]?.["FILLED"] ?? 0);
|
|
2549
|
+
} catch {
|
|
2550
|
+
/* Could not establish emptiness -> do not drop. */
|
|
2551
|
+
orphan.reason = "could not verify the column is empty - not dropped";
|
|
2552
|
+
continue;
|
|
2553
|
+
}
|
|
2554
|
+
orphan.rowsWithData = rowsWithData;
|
|
2555
|
+
if (rowsWithData > 0) {
|
|
2556
|
+
orphan.reason = `still holds data in ${rowsWithData} row(s) - not dropped, drop it manually if it is truly dead`;
|
|
2557
|
+
continue;
|
|
2558
|
+
}
|
|
2559
|
+
await executeSqlStatements(orphan.dropStatements, tableDbIndex, ctx.config, ctx);
|
|
2560
|
+
orphan.dropped = true;
|
|
2561
|
+
orphan.reason = "not declared by the domain config - dropped (was empty)";
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
|
|
2318
2565
|
const syncTable = async (table, ctx) => {
|
|
2319
2566
|
const tableDbIndex = resolveExecutionDbIndex(table?.dbIndex, ctx.config);
|
|
2320
2567
|
const exists = await loadTableExists(table.name, tableDbIndex, ctx.config);
|
|
@@ -2352,6 +2599,16 @@ const syncTable = async (table, ctx) => {
|
|
|
2352
2599
|
}
|
|
2353
2600
|
|
|
2354
2601
|
const snapshot = await loadTableSnapshot(table.name, true, tableDbIndex, ctx.config);
|
|
2602
|
+
if (Array.isArray(ctx.redundantIndexes)) {
|
|
2603
|
+
ctx.redundantIndexes.push(...collectRedundantIndexes(table, snapshot));
|
|
2604
|
+
}
|
|
2605
|
+
if (Array.isArray(ctx.orphanColumns)) {
|
|
2606
|
+
const orphans = collectOrphanColumns(table, snapshot);
|
|
2607
|
+
if (ctx.dropOrphanColumns === true) {
|
|
2608
|
+
await dropOrphanColumns(orphans, tableDbIndex, ctx);
|
|
2609
|
+
}
|
|
2610
|
+
ctx.orphanColumns.push(...orphans);
|
|
2611
|
+
}
|
|
2355
2612
|
const compareResult = compareTableStructure(table, snapshot);
|
|
2356
2613
|
const generatorSyncPlan = await loadGeneratorSyncPlan(table, tableDbIndex, ctx.config);
|
|
2357
2614
|
await applyGeneratorSyncPlan(table, generatorSyncPlan, tableDbIndex, ctx.config, ctx);
|
|
@@ -2552,16 +2809,71 @@ export const publishConfigs = async (payload = {}, argv = {}) => {
|
|
|
2552
2809
|
return { success: true, domainName, domainVersion, tablesProcessed: 0, report };
|
|
2553
2810
|
}
|
|
2554
2811
|
|
|
2812
|
+
/* An explicitly declared index can duplicate a foreign key just as the auto-generated one
|
|
2813
|
+
does, and enrichSchemaWithEntityIndices runs after the preview — so prune once more here,
|
|
2814
|
+
with the full picture. Idempotent. */
|
|
2815
|
+
pruneIndexesCoveredByForeignKeys(preview.schema);
|
|
2816
|
+
|
|
2555
2817
|
const orderedTables = orderTablesByDependency(preview.schema.tables);
|
|
2818
|
+
const redundantIndexes = [];
|
|
2819
|
+
const orphanColumns = [];
|
|
2556
2820
|
const ctx = {
|
|
2557
2821
|
config,
|
|
2558
2822
|
migrationMode,
|
|
2559
2823
|
executedBlockSignatures: new Set(),
|
|
2560
2824
|
report,
|
|
2825
|
+
redundantIndexes,
|
|
2826
|
+
orphanColumns,
|
|
2561
2827
|
};
|
|
2562
2828
|
|
|
2829
|
+
/* Skip tables whose expected definition is byte-identical to the last successful publish:
|
|
2830
|
+
no snapshot, no comparison, no DDL. forceFull re-syncs everything. */
|
|
2831
|
+
const forceFull = payload?.forceFull === true;
|
|
2832
|
+
/* forceFull is the only mode that removes orphan columns — a targeted cleanup, NOT the
|
|
2833
|
+
blanket destructiveness of migrationMode 'aggressive'. */
|
|
2834
|
+
ctx.dropOrphanColumns = forceFull;
|
|
2835
|
+
const storedHashes = forceFull ? {} : await loadDdlHashes(domainName, domainVersion, config);
|
|
2836
|
+
const nextHashes = { ...storedHashes };
|
|
2837
|
+
let tablesSkipped = 0;
|
|
2838
|
+
let tablesProcessed = 0;
|
|
2839
|
+
|
|
2563
2840
|
for (const table of orderedTables) {
|
|
2841
|
+
const tableKey = String(table.name).trim().toUpperCase();
|
|
2842
|
+
const tableHash = computeTableDdlHash(table, migrationMode);
|
|
2843
|
+
if (!forceFull && storedHashes[tableKey] === tableHash) {
|
|
2844
|
+
tablesSkipped++;
|
|
2845
|
+
continue;
|
|
2846
|
+
}
|
|
2564
2847
|
await syncTable(table, ctx);
|
|
2848
|
+
nextHashes[tableKey] = tableHash;
|
|
2849
|
+
tablesProcessed++;
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
/* Only rewrite the cache when it actually moved — an unchanged publish should cost nothing. */
|
|
2853
|
+
if (stableStringify(nextHashes) !== stableStringify(storedHashes)) {
|
|
2854
|
+
await writeDdlHashes(domainName, domainVersion, nextHashes, config);
|
|
2855
|
+
}
|
|
2856
|
+
if (tablesSkipped > 0) {
|
|
2857
|
+
console.log(
|
|
2858
|
+
`[AppConfig Publish] ${tablesSkipped} table(s) unchanged since the last publish - skipped. Use forceFull to re-sync everything.`,
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
if (orphanColumns.length > 0) {
|
|
2862
|
+
const droppedCount = orphanColumns.filter((item) => item.dropped).length;
|
|
2863
|
+
console.log(
|
|
2864
|
+
`[AppConfig Publish] ${orphanColumns.length} orphan column(s) found, ${droppedCount} dropped${forceFull ? "" : " (run with forceFull to drop the empty ones)"}:`,
|
|
2865
|
+
);
|
|
2866
|
+
orphanColumns.forEach((item) =>
|
|
2867
|
+
console.log(` ${item.table}.${item.column} - ${item.reason}`),
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
if (redundantIndexes.length > 0) {
|
|
2871
|
+
console.log(
|
|
2872
|
+
`[AppConfig Publish] ${redundantIndexes.length} redundant index(es) found (REPORT ONLY, nothing dropped):`,
|
|
2873
|
+
);
|
|
2874
|
+
redundantIndexes.forEach((item) =>
|
|
2875
|
+
console.log(` ${item.table}.${item.indexName} - ${item.reason} ${item.dropSql}`),
|
|
2876
|
+
);
|
|
2565
2877
|
}
|
|
2566
2878
|
|
|
2567
2879
|
if (report.length > 0) {
|
|
@@ -2572,7 +2884,10 @@ export const publishConfigs = async (payload = {}, argv = {}) => {
|
|
|
2572
2884
|
success: true,
|
|
2573
2885
|
domainName,
|
|
2574
2886
|
domainVersion,
|
|
2575
|
-
tablesProcessed
|
|
2887
|
+
tablesProcessed,
|
|
2888
|
+
tablesSkipped,
|
|
2889
|
+
redundantIndexes,
|
|
2890
|
+
orphanColumns,
|
|
2576
2891
|
report,
|
|
2577
2892
|
};
|
|
2578
2893
|
} catch (error) {
|
|
@@ -60,6 +60,9 @@ export const normalizeMenuNode = (menu) => {
|
|
|
60
60
|
caption,
|
|
61
61
|
link: Array.isArray(menu?.link) ? menu.link : inferredLink,
|
|
62
62
|
subMenu: normalizedChildren,
|
|
63
|
+
// Preserve a generic external-link leaf: a menu node may carry an externalUrl (opened in a new tab
|
|
64
|
+
// by the client) instead of an internal route. App-agnostic passthrough (only present when set).
|
|
65
|
+
...(typeof menu?.externalUrl === "string" && menu.externalUrl.trim().length > 0 ? { externalUrl: menu.externalUrl } : {}),
|
|
63
66
|
};
|
|
64
67
|
};
|
|
65
68
|
|
package/engine/ext/live.js
CHANGED
|
@@ -109,25 +109,47 @@ export function createLiveExtDeps({
|
|
|
109
109
|
save({ [table]: record }, cfg(tenant)),
|
|
110
110
|
);
|
|
111
111
|
|
|
112
|
+
// A script reads its FINA connection from `data.arguments`, not from the worker config: the
|
|
113
|
+
// generated payment scripts (createPayment / callbackPG) build `{ dbindex, url:
|
|
114
|
+
// http://hostname:port }` out of it, and persistFlipLink passes that on to lib.db.executeBlock,
|
|
115
|
+
// where the worker MERGES it over its own config. So an empty `arguments` did not merely leave
|
|
116
|
+
// them unconfigured — it pointed them at http://undefined:undefined. Same shape scheduler
|
|
117
|
+
// scripts get from datalib.getInputData, minus `smtp`: nothing on the external path sends mail,
|
|
118
|
+
// so mail credentials have no business crossing this boundary.
|
|
119
|
+
const scriptArguments = (config) => {
|
|
120
|
+
const parts = String(config.url || "").split(":");
|
|
121
|
+
return {
|
|
122
|
+
hostname: (parts[1] || "").replace("//", ""),
|
|
123
|
+
port: Number(parts[2]),
|
|
124
|
+
dbindex: config.dbindex,
|
|
125
|
+
finaDbIndex: config.finaDbIndex,
|
|
126
|
+
subdomain: config.subdomain,
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
112
130
|
// CLI-backed op: run the op's OWN named script (execCLIScriptWorker — the safe named-run
|
|
113
131
|
// path, NOT hubEvent dispatch) with a server-derived principal (cliInvoke). Op params ride
|
|
114
132
|
// in as inert data; the script never sees caller roles.
|
|
115
133
|
const runCliScript = (tenant, op, params, claims) =>
|
|
116
|
-
executeCliOp(op, params, claims, (scriptRef, p, principal) =>
|
|
117
|
-
execCLIScriptWorker
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
134
|
+
executeCliOp(op, params, claims, (scriptRef, p, principal) => {
|
|
135
|
+
// Must carry the FINA `url` (execCLIScriptWorker + datalib build request URLs from
|
|
136
|
+
// it) — same config the projection/intake paths use, or the worker throws Invalid URL.
|
|
137
|
+
const workerConfig = {
|
|
138
|
+
...cfg(tenant),
|
|
139
|
+
finaDbIndex: tenant.finaDbIndex,
|
|
140
|
+
};
|
|
141
|
+
return execCLIScriptWorker(
|
|
142
|
+
workerConfig,
|
|
121
143
|
{ searchBy: "SCRIPT_NAME", searchValue: scriptRef },
|
|
122
144
|
{
|
|
123
|
-
arguments:
|
|
145
|
+
arguments: scriptArguments(workerConfig),
|
|
124
146
|
body: { params: p, principal },
|
|
125
147
|
path: "/ext-op",
|
|
126
148
|
query: {},
|
|
127
149
|
headers: { "user-agent": "biz-a-ext" },
|
|
128
150
|
},
|
|
129
|
-
)
|
|
130
|
-
);
|
|
151
|
+
);
|
|
152
|
+
});
|
|
131
153
|
|
|
132
154
|
const manifestStore = createManifestStore({
|
|
133
155
|
resolveTenant,
|