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 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: orderedTables.length,
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
 
@@ -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
- // Must carry the FINA `url` (execCLIScriptWorker + datalib build request URLs from
119
- // it) — same config the projection/intake paths use, or the worker throws Invalid URL.
120
- { ...cfg(tenant), finaDbIndex: tenant.finaDbIndex },
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15280",
3
+ "version": "2.3.80-15292",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",