biz-a-cli 2.3.80-15419 → 2.3.80

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.
Files changed (80) hide show
  1. package/bin/app.js +20 -255
  2. package/bin/hub.js +0 -16
  3. package/bin/hubEvent.js +5 -98
  4. package/bin/tunnelTarget.js +0 -4
  5. package/engine/bpm/workflow.js +6 -7
  6. package/engine/cc/configValidate.js +0 -53
  7. package/engine/cc/guides/V2-SCREEN-AUTHORING.md +512 -531
  8. package/engine/cc/layer3Behavior.js +1 -2
  9. package/engine/cc/layer3Schema.js +1 -3
  10. package/engine/cc/livePipelines.js +3 -9
  11. package/engine/cc/publish.js +105 -86
  12. package/engine/domain/appConfig.js +18 -108
  13. package/engine/domain/applicationConfig.js +12 -87
  14. package/engine/domain/dialect.js +0 -138
  15. package/engine/domain/domainNavigation.js +50 -86
  16. package/engine/domain/entityConfigLoader.js +4 -16
  17. package/engine/domain/numericScale.js +0 -2
  18. package/engine/domain/system.js +0 -2
  19. package/engine/ext/publicTheme.js +1 -8
  20. package/engine/orm/apiRoute.js +61 -187
  21. package/engine/orm/entityConfig.js +2 -32
  22. package/engine/orm/finaAuth.js +0 -0
  23. package/engine/orm/finaEndpoint.js +0 -19
  24. package/engine/orm/imgQuery.js +0 -92
  25. package/engine/orm/listQuery.js +1 -6
  26. package/package.json +1 -1
  27. package/worker/cliScriptWorker.js +0 -5
  28. package/engine/attachments/store.js +0 -195
  29. package/engine/domain/domainRegistration.js +0 -65
  30. package/engine/fina/approval.js +0 -141
  31. package/engine/fina/approvalService.js +0 -59
  32. package/engine/fina/attachments.js +0 -165
  33. package/engine/fina/client.js +0 -84
  34. package/engine/fina/config.js +0 -26
  35. package/engine/fina/context.js +0 -58
  36. package/engine/fina/document.js +0 -67
  37. package/engine/fina/documentWrite.js +0 -173
  38. package/engine/fina/documents/index.js +0 -28
  39. package/engine/fina/documents/jv.js +0 -105
  40. package/engine/fina/documents/jvWrite.js +0 -260
  41. package/engine/fina/documents/si.js +0 -40
  42. package/engine/fina/errors.js +0 -17
  43. package/engine/fina/features.js +0 -86
  44. package/engine/fina/guard.js +0 -102
  45. package/engine/fina/identity.js +0 -87
  46. package/engine/fina/live.js +0 -19
  47. package/engine/fina/lock.js +0 -35
  48. package/engine/fina/master.js +0 -331
  49. package/engine/fina/masters/coa.js +0 -104
  50. package/engine/fina/masters/currency.js +0 -39
  51. package/engine/fina/masters/department.js +0 -32
  52. package/engine/fina/masters/index.js +0 -21
  53. package/engine/fina/masters/project.js +0 -53
  54. package/engine/fina/period.js +0 -56
  55. package/engine/fina/print.js +0 -136
  56. package/engine/fina/readPolicy.js +0 -67
  57. package/engine/fina/recurring.js +0 -200
  58. package/engine/fina/route.js +0 -61
  59. package/engine/fina/routes/attachment.js +0 -41
  60. package/engine/fina/routes/document.js +0 -66
  61. package/engine/fina/routes/master.js +0 -26
  62. package/engine/fina/routes/period.js +0 -24
  63. package/engine/fina/routes/print.js +0 -19
  64. package/engine/fina/routes/recurring.js +0 -27
  65. package/engine/fina/routes/session.js +0 -23
  66. package/engine/fina/routes/setup.js +0 -58
  67. package/engine/fina/routes/submission.js +0 -46
  68. package/engine/fina/routes/template.js +0 -31
  69. package/engine/fina/settings.js +0 -159
  70. package/engine/fina/sql.js +0 -34
  71. package/engine/fina/submission.js +0 -290
  72. package/engine/fina/submissionCompare.js +0 -171
  73. package/engine/fina/submissionRepo.js +0 -114
  74. package/engine/fina/template.js +0 -118
  75. package/engine/fina/userMap.js +0 -79
  76. package/engine/orm/companyLocale.js +0 -74
  77. package/engine/orm/userApiDataset.js +0 -153
  78. package/migrations/postgres/1790600000000__fina.sql +0 -33
  79. package/migrations/postgres/1790700000000__attachment.sql +0 -29
  80. package/migrations/postgres/1790800000000__fina_jv_submission.sql +0 -41
@@ -6,47 +6,11 @@ import {
6
6
  buildPublishStatusConfigKey,
7
7
  writePublishStatus,
8
8
  saveConfigRow,
9
- appConfigNameFor,
10
9
  } from "./appConfig.js";
11
10
  import { execPostgres } from "./dialect.js";
12
11
  /* Dok. 5 section 3.3 -- a Layer 3 domain contributing its own menus, gated by its own activation
13
12
  setting. Read generically: no domain name and no domain code appear in this file. */
14
- import { mergeDomainNavigation, selectActiveDomainMenus, readActiveModules, resolveMenuLayout } from "./domainNavigation.js";
15
- import { entryPointsOf } from "../cc/appMenus.js";
16
-
17
- /*
18
- * Doc 8 (2026-10-10) — a SECONDARY application (`metadata.app`, stored as `APPLICATION_CONFIG.<app>`) beside the
19
- * tenant's host app. It has no Home of its own: `./main` is the tenant's, and the client loads `/main` by bare name
20
- * across every app, so a second app's main.js could take over the tenant's Home.
21
- */
22
- const isSecondaryApp = (parsedApplicationConfig) => {
23
- const app = parsedApplicationConfig?.metadata?.app;
24
- return app != null && app !== "";
25
- };
26
-
27
- /* Mirrors bin/app.js normalizeAppName: how the host's display name keys its SYS$APPS row. */
28
- const hostAppNameOf = (displayName) =>
29
- String(displayName ?? "")
30
- .trim()
31
- .replace(/\s+/g, "")
32
- .replace(/-/g, "")
33
- .toLowerCase();
34
-
35
- /*
36
- * The SYS$APPS row this config publishes to: its name, the display name the client shows on the app's menu node, and
37
- * the menu layout. Taken from the config BEING published — before this, addApp read them from the newest host
38
- * APPLICATION_CONFIG whatever was published, so a second app would have overwritten the host's row.
39
- */
40
- export const appMetadataOf = (parsedApplicationConfig) => {
41
- const metadata = parsedApplicationConfig?.metadata ?? {};
42
- const displayName = typeof metadata.name === "string" ? metadata.name.trim() : "";
43
- const configName = appConfigNameFor(metadata);
44
- return {
45
- appName: isSecondaryApp(parsedApplicationConfig) ? configName.slice(configName.indexOf(".") + 1) : hostAppNameOf(displayName),
46
- displayName,
47
- menuLayout: resolveMenuLayout(parsedApplicationConfig),
48
- };
49
- };
13
+ import { mergeDomainNavigation, selectActiveDomainMenus, readActiveModules } from "./domainNavigation.js";
50
14
 
51
15
  export const parseConfigData = (rawConfig) => {
52
16
  if (rawConfig == null) {
@@ -123,7 +87,6 @@ export const buildMenuStructureFromApplicationConfig = (parsedApplicationConfig)
123
87
  (menu) => !(menu?.caption === "Home" || (Array.isArray(menu?.link) && menu.link[0] === "./main")),
124
88
  );
125
89
 
126
- if (isSecondaryApp(parsedApplicationConfig)) return nonHomeMenus;
127
90
  return [{ menuName: "", caption: "Home", link: ["./main"], subMenu: [] }].concat(nonHomeMenus);
128
91
  };
129
92
 
@@ -135,20 +98,6 @@ export const normalizeTemplateFileName = (rawName) => {
135
98
  return normalized.endsWith(".js") ? normalized : `${normalized}.js`;
136
99
  };
137
100
 
138
- /*
139
- * Fold each domain's live menus into the application's navigation (domainNavigation.js), with the TYPES of that
140
- * domain's own entry points — Doc 8 M3: a leaf on a list entry point is a list link, so it opens the list and the
141
- * publish builds a list stub for it (collectFormTargets reads "./list"). `domainMenus`: [domainKey, {menus,
142
- * domainConfig}] pairs, a Map or an array.
143
- */
144
- export const foldDomainNavigation = (navigation, domainMenus) => {
145
- let folded = navigation ?? {};
146
- for (const [domainKey, { menus, domainConfig }] of domainMenus) {
147
- folded = mergeDomainNavigation({ applicationConfig: { navigation: folded }, domainKey, menus, entryPointTypes: entryPointsOf(domainConfig) });
148
- }
149
- return folded;
150
- };
151
-
152
101
  export const collectFormTargets = (menuNode, inheritedDomainKey = null) => {
153
102
  const targets = [];
154
103
  const currentDomainKey =
@@ -225,8 +174,7 @@ export const resolveDomainKeyByReference = (domainReference, domainMap) => {
225
174
  export const resolveTemplateTargets = (parsedDomainConfig, parsedApplicationConfig) => {
226
175
  const sourceMenus = Array.isArray(parsedApplicationConfig?.navigation?.menus) ? parsedApplicationConfig.navigation.menus : [];
227
176
  const resolvedTargets = [];
228
- /* A secondary app never ships a main.js (see isSecondaryApp): marking it seen skips it wherever it comes from. */
229
- const seenFileNames = new Set(isSecondaryApp(parsedApplicationConfig) ? ["main.js"] : []);
177
+ const seenFileNames = new Set();
230
178
 
231
179
  for (const menuNode of sourceMenus) {
232
180
  const menuTargets = collectFormTargets(menuNode);
@@ -536,9 +484,8 @@ ${sharedDomainConfigResolverScript}
536
484
  ensureDomainConfig((resolvedConfig) => {
537
485
  this.domainConfig = resolvedConfig || this.domainConfig;
538
486
  console.log('Template Result :', this.domainConfig);
539
- /* Doc 8 M3: wait for the app's beforeLoadData handlers (an older client answers nothing). */
540
- const waited = this.domainService.doBeforeLoadDataEvent({ config: this.domainConfig, entryPointName: "__ENTRY_POINT_NAME__", runtimeHost: this, id: String(id) });
541
- Promise.resolve(waited).then(() => resolve(), () => resolve());
487
+ this.domainService.doBeforeLoadDataEvent({ config: this.domainConfig, entryPointName: "__ENTRY_POINT_NAME__", runtimeHost: this, id: String(id) });
488
+ resolve();
542
489
  });
543
490
  });
544
491
  },
@@ -597,10 +544,7 @@ ${sharedDomainConfigResolverScript}
597
544
  ensureDomainConfig((resolvedConfig) => {
598
545
  this.domainConfig = resolvedConfig || this.domainConfig;
599
546
  console.log('Template Result :', this.domainConfig);
600
- /* Doc 8 M3: the app's beforeLoadData handlers are waited for below — a FINA master list learns FINA's
601
- database index in one, and the first load must not read the tenant's database instead. An older
602
- client answers nothing; a failed handler does not stop the list loading. */
603
- const waited = this.domainService.doBeforeLoadDataEvent({
547
+ this.domainService.doBeforeLoadDataEvent({
604
548
  config: this.domainConfig,
605
549
  entryPointName: "__ENTRY_POINT_NAME__",
606
550
  runtimeHost: this,
@@ -622,21 +566,13 @@ ${sharedDomainConfigResolverScript}
622
566
  this.buttons = rendered.buttons || [];
623
567
  this.filter = rendered.filter || [];
624
568
  this.distinct = !!rendered.distinct;
625
- /* Doc 8 M2: the database a list reads (a FINA list reads FINA), and the list engine options
626
- (search, print, selection) - handed over whole; an older client has no applyListOptions. */
627
- if (rendered.dbIndex !== undefined && rendered.dbIndex !== null) {
628
- this.dbIndex = rendered.dbIndex;
629
- }
630
- if (typeof this.applyListOptions === "function") {
631
- this.applyListOptions(rendered);
632
- }
633
569
  this.functions = Object.assign(this.functions || {}, rendered.functions || {});
634
570
  if (this.helper && typeof this.helper.bindFunctionInObj === "function") {
635
571
  this.helper.bindFunctionInObj(this.functions, this);
636
572
  }
637
573
  this._listDomainInitialized = true;
638
574
  }
639
- Promise.resolve(waited).then(() => resolve(), () => resolve());
575
+ resolve();
640
576
  });
641
577
  });
642
578
  },
@@ -823,15 +759,6 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
823
759
  throw new Error(`SYS$CONFIG record ${applicationConfigName}@${applicationConfigVersion} was not found.`);
824
760
  }
825
761
  let parsedApplicationConfig = parseConfigData(appRow.data) ?? {};
826
- /* Doc 8 (2026-10-10): the row's name and the app the config declares must agree — otherwise one app's config would
827
- publish into another app's SYS$APPS row. */
828
- const declaredConfigName = appConfigNameFor(parsedApplicationConfig?.metadata);
829
- if (declaredConfigName.toUpperCase() !== applicationConfigName.toUpperCase()) {
830
- throw new Error(
831
- `SYS$CONFIG record ${applicationConfigName}@${applicationConfigVersion} declares itself as ${declaredConfigName} ` +
832
- "(metadata.app); publish it under that name.",
833
- );
834
- }
835
762
 
836
763
  /* An app bundle publish is a full replace (addApp() rebuilds the whole bundle from scratch every call), so
837
764
  template.js scaffolding must be generated for EVERY domain registered in application.js's `domains` map, not
@@ -923,10 +850,7 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
923
850
  exec: execPostgres,
924
851
  dbIndex: Number(config?.dbindex ?? config?.dbIndex),
925
852
  });
926
- domainNavigation.set(domainKey, {
927
- menus: selectActiveDomainMenus({ menus: declaredMenus, activeModules }),
928
- domainConfig: parsedDomainConfig,
929
- });
853
+ domainNavigation.set(domainKey, selectActiveDomainMenus({ menus: declaredMenus, activeModules }));
930
854
  } catch (error) {
931
855
  logger.warn(
932
856
  `[navigation] ${domainKey}: could not resolve its menus, so it contributes none: ` +
@@ -938,7 +862,10 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
938
862
 
939
863
  /* Fold the domains' menus in BEFORE the bundle is built from this config. */
940
864
  if (domainNavigation.size > 0) {
941
- const navigation = foldDomainNavigation(parsedApplicationConfig?.navigation ?? {}, domainNavigation);
865
+ let navigation = parsedApplicationConfig?.navigation ?? {};
866
+ for (const [domainKey, menus] of domainNavigation) {
867
+ navigation = mergeDomainNavigation({ applicationConfig: { navigation }, domainKey, menus });
868
+ }
942
869
  parsedApplicationConfig = { ...parsedApplicationConfig, navigation };
943
870
  }
944
871
 
@@ -951,8 +878,6 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
951
878
  const publishBody = {
952
879
  verbose: false,
953
880
  fileList: mergedFileList.map(({ name, content }) => ({ name, content })),
954
- /* Doc 8 (2026-10-10): the SYS$APPS row is the one THIS config names (addApp's appMeta). */
955
- appMeta: appMetadataOf(parsedApplicationConfig),
956
881
  /* Doc 1 0b.3: addApp bundles the working dir's own files too; a caller inside the long-lived hub
957
882
  (ccpublish) passes an empty scratch dir instead of letting it use process.cwd(). */
958
883
  ...(payload?.workingDir ? { workingDir: payload.workingDir } : {}),
@@ -988,7 +913,7 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
988
913
  `get = function () { return ${JSON.stringify(parsedApplicationConfig)}; }`,
989
914
  config,
990
915
  );
991
- const counts = [...domainNavigation].map(([key, entry]) => `${key}: ${entry?.menus?.length ?? 0}`).join(", ");
916
+ const counts = [...domainNavigation].map(([key, menus]) => `${key}: ${menus.length}`).join(", ");
992
917
  logger.info(`[navigation] domain menus published (${counts})`);
993
918
  } catch (error) {
994
919
  /* The bundle is already published and correct; a menu that did not update is a re-publish
@@ -13,8 +13,6 @@
13
13
  */
14
14
  import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
- import { AsyncLocalStorage } from "node:async_hooks";
17
- import { logger } from "../../logger.js";
18
16
  import * as firebirdDdl from "./firebird-ddl.js";
19
17
  import * as postgresDdl from "./postgres-ddl.js";
20
18
  import {
@@ -166,9 +164,6 @@ const decorate = (error, statement) => {
166
164
  * anything at all.
167
165
  */
168
166
  export const execPostgres = async (sql, dbIndex) => {
169
- const ambient = openAmbient(dbIndex);
170
- if (ambient) return inSavepoint(ambient, () => runOn(ambient.client, sql), () => execPostgres(sql, dbIndex));
171
-
172
167
  const pool = await poolFactory(dbIndex);
173
168
 
174
169
  if (!hasTenantContext()) {
@@ -217,20 +212,6 @@ const lowerCaseKeys = (rows) =>
217
212
  write puts the master first and callers read `data[0].ID` as the master's key
218
213
  (engine/orm/executor.js runStatementsKeepingFirst). One implementation, two shapes. */
219
214
  export const execPostgresStatements = async (statements, dbIndex) => {
220
- /* Inside a held transaction the group is still atomic — one savepoint around it — but opens no BEGIN of its own. */
221
- const ambient = openAmbient(dbIndex);
222
- if (ambient) {
223
- return inSavepoint(
224
- ambient,
225
- async () => {
226
- const rowsets = [];
227
- for (const statement of statements ?? []) rowsets.push(await runOn(ambient.client, statement));
228
- return rowsets;
229
- },
230
- () => execPostgresStatements(statements, dbIndex),
231
- );
232
- }
233
-
234
215
  const pool = await poolFactory(dbIndex);
235
216
  const client = await pool.connect();
236
217
  const rowsets = [];
@@ -281,7 +262,6 @@ export const beginPostgresTransaction = async (
281
262
  dbIndex,
282
263
  { isolation = "READ COMMITTED", tenantId } = {},
283
264
  ) => {
284
- refuseNesting(dbIndex);
285
265
  const pool = await poolFactory(dbIndex);
286
266
  const client = await pool.connect();
287
267
 
@@ -341,121 +321,3 @@ export const beginPostgresTransaction = async (
341
321
  release,
342
322
  };
343
323
  };
344
-
345
- /*
346
- * Atomic publish (Agung 2026-10-08) — a transaction the engine's ordinary calls JOIN.
347
- *
348
- * A Control Center publish runs through ~20 functions (config rows, DDL, hash rows, SYS$APPS, workflows, draft marks)
349
- * that call `execPostgres` / `execPostgresStatements`, each its own auto-committed statement. A failure part-way left
350
- * the earlier steps in place (marketing@0.1.13 recorded after its DDL failed). Inside `withPostgresTransaction` those
351
- * same calls run on ONE held client, each in its own SAVEPOINT — so code that catches a failure and carries on (the
352
- * DDL retry loop, a 23505 → null) still can — and nothing is visible to anyone until the COMMIT. Calls are queued:
353
- * `loadTableSnapshot` fires eight at once, and interleaved savepoints would release each other.
354
- *
355
- * ⚠️ Only PostgreSQL DDL is transactional; nothing here runs on Firebird. A table locked by a long user query fails
356
- * the transaction after `lockTimeout` instead of hanging it — and everyone queued behind its locks.
357
- */
358
- const ambientTransactions = new AsyncLocalStorage();
359
- const LOCK_TIMEOUT = /^\d+(ms|s|min)?$/;
360
-
361
- const openAmbient = (dbIndex) => {
362
- const store = ambientTransactions.getStore();
363
- return store && !store.closed && String(store.dbIndex) === String(dbIndex) ? store : null;
364
- };
365
-
366
- const refuseNesting = (dbIndex) => {
367
- if (openAmbient(dbIndex)) throw new Error(`A transaction is already open on database ${dbIndex}; this work runs inside it.`);
368
- };
369
-
370
- const runOn = async (client, sql) => {
371
- try {
372
- const result = await client.query(sql);
373
- return lowerCaseKeys(result?.rows);
374
- } catch (error) {
375
- throw decorate(error, sql);
376
- }
377
- };
378
-
379
- /* One call = one savepoint, queued behind the calls before it. A call that only gets its turn after the transaction
380
- closed (async work outliving it) runs on the pool, as it would have outside. */
381
- const inSavepoint = (store, work, outside) => {
382
- const task = async () => {
383
- if (store.closed) return outside();
384
- const name = `biza_sp_${++store.savepoints}`;
385
- await store.client.query(`SAVEPOINT ${name}`);
386
- try {
387
- const out = await work();
388
- await store.client.query(`RELEASE SAVEPOINT ${name}`);
389
- return out;
390
- } catch (error) {
391
- try {
392
- await store.client.query(`ROLLBACK TO SAVEPOINT ${name}`);
393
- } catch {
394
- /* The transaction is unusable anyway; the original error is the one that matters. */
395
- }
396
- throw error;
397
- }
398
- };
399
- const result = store.queue.then(task, task);
400
- store.queue = result.catch(() => {});
401
- return result;
402
- };
403
-
404
- /* Run `fn` in one transaction on `dbIndex`: COMMIT when it returns, ROLLBACK when it throws (the error comes through).
405
- `afterCommit` work queued inside runs only after a successful COMMIT. */
406
- export const withPostgresTransaction = async (dbIndex, fn, { lockTimeout = "10s" } = {}) => {
407
- refuseNesting(dbIndex);
408
- if (lockTimeout !== null && !LOCK_TIMEOUT.test(String(lockTimeout))) throw new Error(`The lock timeout is a duration like "10s", not ${JSON.stringify(lockTimeout)}.`);
409
- const pool = await poolFactory(dbIndex);
410
- const client = await pool.connect();
411
- const store = { dbIndex, client, queue: Promise.resolve(), savepoints: 0, afterCommit: [], closed: false };
412
-
413
- try {
414
- await client.query("BEGIN");
415
- } catch (error) {
416
- /* No ROLLBACK: BEGIN failed, so there is nothing to roll back. */
417
- client.release();
418
- throw decorate(error, "BEGIN");
419
- }
420
-
421
- let out;
422
- try {
423
- if (lockTimeout !== null) await client.query(`SET LOCAL lock_timeout = '${lockTimeout}'`);
424
- await tenantContextApplier(client, { dbIndex });
425
- out = await ambientTransactions.run(store, fn);
426
- /* Work started inside and not awaited still belongs to this transaction. */
427
- await store.queue;
428
- await client.query("COMMIT");
429
- } catch (error) {
430
- store.closed = true;
431
- try {
432
- await client.query("ROLLBACK");
433
- } catch {
434
- /* Best-effort: the error that got us here is the useful one. */
435
- }
436
- client.release();
437
- throw error;
438
- }
439
- store.closed = true;
440
- client.release();
441
-
442
- for (const work of store.afterCommit) {
443
- try {
444
- await work();
445
- } catch (error) {
446
- logger.error(`[db] after-commit work failed: ${error?.message ?? error}`);
447
- }
448
- }
449
- return out;
450
- };
451
-
452
- /* Work that must not happen before the data it announces is visible (a broadcast to clients): inside an open
453
- transaction it waits for the COMMIT — and is dropped on a rollback; outside one it runs at once. */
454
- export const afterCommit = (work) => {
455
- const store = ambientTransactions.getStore();
456
- if (store && !store.closed) {
457
- store.afterCommit.push(work);
458
- return undefined;
459
- }
460
- return work();
461
- };
@@ -15,25 +15,8 @@
15
15
  * and the publish reads it generically. No domain name, no domain code, nothing app-specific here.
16
16
  */
17
17
 
18
- import { linkFor as linkForType } from "../cc/appMenus.js";
19
-
20
- /*
21
- * The route shape the client's menu component renders, identical to a hand-declared leaf.
22
- *
23
- * Doc 8 M3 — by the entry point's TYPE (`entryPointTypes`, from the domain's structures): a list opens as a list.
24
- * Every leaf used to be a form link, so a list entry point opened as a form stub. An entry point of unknown type
25
- * stays a form link, as before.
26
- */
27
- const linkFor = (entry, entryPointTypes) => {
28
- const entryPoint = String(entry?.entryPoint ?? "").trim();
29
- return linkForType(entryPointTypes?.[entryPoint] === "list" ? "list" : "form", entryPoint);
30
- };
31
-
32
- /* A group path: `["FINA", "Master"]`, or a string for the one-level group it always was. */
33
- const groupPath = (group) =>
34
- (Array.isArray(group) ? group : [group]).map((caption) => String(caption ?? "").trim()).filter((caption) => caption !== "");
35
-
36
- const isGroup = (node) => Array.isArray(node?.subMenu);
18
+ /* The route shape the client's menu component renders, identical to a hand-declared leaf. */
19
+ const linkFor = (entry) => ["./form", String(entry?.entryPoint ?? "").trim(), null];
37
20
 
38
21
  /* The menu layouts the client shell can draw. */
39
22
  const MENU_LAYOUTS = ["horizontal", "vertical"];
@@ -84,89 +67,70 @@ export const selectActiveDomainMenus = ({ menus, activeModules } = {}) => {
84
67
  * something while the tile still opened. Injecting without stripping would also accumulate duplicates
85
68
  * that nothing could clear.
86
69
  */
87
- export const mergeDomainNavigation = ({ applicationConfig, domainKey, menus, entryPointTypes } = {}) => {
70
+ export const mergeDomainNavigation = ({ applicationConfig, domainKey, menus } = {}) => {
88
71
  const key = String(domainKey ?? "").trim();
89
72
  const navigation = applicationConfig?.navigation ?? {};
90
73
  const sourceMenus = Array.isArray(navigation.menus) ? navigation.menus : [];
91
74
  const entries = Array.isArray(menus) ? menus : [];
92
75
 
93
- /* Everything that is NOT this domain's, at every depth, group structure preserved. */
94
- const strip = (nodes) =>
95
- nodes
96
- .filter((node) => String(node?.domain ?? "") !== key)
97
- .map((node) => (isGroup(node) ? { ...node, subMenu: strip(node.subMenu) } : node));
98
- const kept = strip(sourceMenus);
76
+ /* Everything that is NOT this domain's, group structure preserved. */
77
+ const kept = sourceMenus.map((group) => ({
78
+ ...group,
79
+ subMenu: (Array.isArray(group?.subMenu) ? group.subMenu : []).filter(
80
+ (leaf) => String(leaf?.domain ?? "") !== key,
81
+ ),
82
+ }));
99
83
 
100
84
  for (const entry of entries) {
101
- const path = groupPath(entry?.group);
102
- if (path.length === 0 || !entry?.entryPoint) continue;
103
-
104
- let level = kept;
105
- path.forEach((caption, depth) => {
106
- let group = level.find((node) => isGroup(node) && String(node?.caption ?? "") === caption);
107
- if (!group) {
108
- group = { caption, link: [], subMenu: [] };
109
- /*
110
- * ⚠️ WHERE a group the host does not have is placed. Appending is fine for one stray leaf
111
- * and wrong for a domain that owns a whole group: "HR, beside Setup" appended after every
112
- * group the host emits is a group at the BOTTOM of the menu, which is not what was asked
113
- * for and not where anyone looks.
114
- *
115
- * ⚠️ ON CREATION ONLY, and for the TOP level only. Once the group exists, the find above keeps
116
- * its position, and so does the host's own injector, which concats into a group it finds
117
- * rather than re-appending. Every entry of a multi-screen domain therefore carries the same
118
- * hint and only the first one acts on it.
119
- *
120
- * ⚠️ AN ANCHOR THE HOST DOES NOT HAVE APPENDS. Declining to place the group would hide
121
- * every screen the domain owns — far worse than placing it last, and a domain cannot know
122
- * what groups its host happens to emit.
123
- */
124
- const anchor = depth === 0 ? String(entry?.groupAfter ?? "").trim() : "";
125
- const at = anchor ? level.findIndex((node) => String(node?.caption ?? "") === anchor) : -1;
126
- if (at >= 0) level.splice(at + 1, 0, group);
127
- else level.push(group);
128
- }
129
- level = group.subMenu;
130
- });
85
+ const groupCaption = String(entry?.group ?? "").trim();
86
+ if (!groupCaption || !entry?.entryPoint) continue;
87
+
88
+ let group = kept.find((g) => String(g?.caption ?? "") === groupCaption);
89
+ if (!group) {
90
+ group = { caption: groupCaption, link: [], subMenu: [] };
91
+ /*
92
+ * ⚠️ WHERE a group the host does not have is placed. Appending is fine for one stray leaf
93
+ * and wrong for a domain that owns a whole group: "HR, beside Setup" appended after every
94
+ * group the host emits is a group at the BOTTOM of the menu, which is not what was asked
95
+ * for and not where anyone looks.
96
+ *
97
+ * ⚠️ ON CREATION ONLY. Once the group exists, `kept.find` above keeps its position, and so
98
+ * does the host's own injector, which concats into a group it finds rather than
99
+ * re-appending. Every entry of a multi-screen domain therefore carries the same hint and
100
+ * only the first one acts on it.
101
+ *
102
+ * ⚠️ AN ANCHOR THE HOST DOES NOT HAVE APPENDS. Declining to place the group would hide
103
+ * every screen the domain owns — far worse than placing it last, and a domain cannot know
104
+ * what groups its host happens to emit.
105
+ */
106
+ const anchor = String(entry?.groupAfter ?? "").trim();
107
+ const at = anchor ? kept.findIndex((g) => String(g?.caption ?? "") === anchor) : -1;
108
+ if (at >= 0) kept.splice(at + 1, 0, group);
109
+ else kept.push(group);
110
+ }
111
+ if (!Array.isArray(group.subMenu)) group.subMenu = [];
131
112
 
132
- level.push({
113
+ group.subMenu.push({
133
114
  caption: String(entry.caption ?? entry.entryPoint),
134
115
  menuKey: String(entry.menuKey ?? entry.entryPoint),
135
- link: linkFor(entry, entryPointTypes),
116
+ link: linkFor(entry),
136
117
  /* §3.3's stamp: what the next publish uses to find exactly its own leaves. */
137
118
  domain: key,
138
119
  });
139
120
  }
140
121
 
141
- /*
142
- * A group that existed only to hold this domain's leaves, and now holds none, goes with them — at every depth,
143
- * so an emptied "FINA | Master" takes "FINA" with it when nothing else is left there. An empty group renders as a
144
- * dead parent nobody can open. A group whose source held another owner's leaf is left alone, because it was not
145
- * this publish's to remove.
146
- */
147
- const foreignLeafUnder = (node) =>
148
- (node?.subMenu ?? []).some((child) => (isGroup(child) ? foreignLeafUnder(child) : String(child?.domain ?? "") !== key));
149
- const keepPaths = new Set();
150
- const collectKept = (nodes, prefix) => {
151
- for (const node of nodes) {
152
- if (!isGroup(node)) continue;
153
- const path = [...prefix, String(node?.caption ?? "")];
154
- if (foreignLeafUnder(node)) keepPaths.add(path.join("\u0000"));
155
- collectKept(node.subMenu, path);
156
- }
157
- };
158
- collectKept(sourceMenus, []);
159
- const prune = (nodes, prefix) =>
160
- nodes
161
- .map((node) => {
162
- if (!isGroup(node)) return node;
163
- const path = [...prefix, String(node?.caption ?? "")];
164
- return { ...node, subMenu: prune(node.subMenu, path), __path: path.join("\u0000") };
165
- })
166
- .filter((node) => !isGroup(node) || node.subMenu.length > 0 || keepPaths.has(node.__path))
167
- .map(({ __path, ...node }) => node);
122
+ /* A group that existed only to hold this domain's leaves, and now holds none, goes with them —
123
+ an empty group renders as a dead parent nobody can open. The host's own empty groups are left
124
+ alone, because they were not this publish's to remove. */
125
+ const hadDomainLeafOnly = (group) =>
126
+ (group?.subMenu ?? []).length === 0 &&
127
+ !sourceMenus.some(
128
+ (original) =>
129
+ String(original?.caption ?? "") === String(group?.caption ?? "") &&
130
+ (original?.subMenu ?? []).some((leaf) => String(leaf?.domain ?? "") !== key),
131
+ );
168
132
 
169
- return { ...navigation, menus: prune(kept, []) };
133
+ return { ...navigation, menus: kept.filter((group) => !hadDomainLeafOnly(group)) };
170
134
  };
171
135
 
172
136
  /*
@@ -12,7 +12,7 @@
12
12
  * applicationConfig.js performs at publish time, so the runtime reads exactly what was published.
13
13
  */
14
14
  import {
15
- buildPostgresApplicationConfigsLookupSql,
15
+ buildPostgresApplicationConfigLookupSql,
16
16
  parseDomainConfig,
17
17
  } from "./appConfig.js";
18
18
  import { parseConfigData } from "./applicationConfig.js";
@@ -67,22 +67,10 @@ const LATEST_BY_NAME = (name) =>
67
67
  `SELECT version, data FROM ${PG_CONFIG_TABLE} WHERE name = '${String(name).replace(/'/g, "''")}' ORDER BY id DESC LIMIT 1`;
68
68
 
69
69
  /* Returns PARSED domain configs. `entityConfig.primeEntityConfig` takes this as its
70
- `loadDomainConfigs`, which keeps that module a pure cache with no config-access dependency.
71
-
72
- Doc 8 (2026-10-10): the domains of EVERY application on the tenant — the host's APPLICATION_CONFIG and
73
- each `APPLICATION_CONFIG.<app>` — each domain once. A second app's entities are this hub's ORM
74
- declarations and this tenant's tenancy just the same. */
70
+ `loadDomainConfigs`, which keeps that module a pure cache with no config-access dependency. */
75
71
  export const createDomainConfigLoader = ({ exec, dbIndex }) => async () => {
76
- const appRows = await exec(buildPostgresApplicationConfigsLookupSql(), dbIndex);
77
- const seen = new Set();
78
- const sources = [];
79
- for (const appRow of appRows ?? []) {
80
- for (const [name, version] of domainSourcesOf(appRow?.data)) {
81
- if (seen.has(name)) continue;
82
- seen.add(name);
83
- sources.push([name, version]);
84
- }
85
- }
72
+ const appRows = await exec(buildPostgresApplicationConfigLookupSql(), dbIndex);
73
+ const sources = domainSourcesOf(appRows?.[0]?.data);
86
74
 
87
75
  const parsed = [];
88
76
  for (const [name, namedVersion] of sources) {
@@ -42,8 +42,6 @@ export const findImplicitNumericScale = (config) => {
42
42
  for (const [entityName, entity] of entitiesOf(config)) {
43
43
  const attributes = entity?.attributes;
44
44
  if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) continue;
45
- /* Doc 8 M3: an external entity is another system's table; the publish creates none of its columns. */
46
- if (entity?.external === true || String(entity?.external ?? "").trim().toLowerCase() === "true") continue;
47
45
  /* An entity may name no table; the entity key is what a reader would search for. */
48
46
  const table = String(entity?.table ?? entityName);
49
47
 
@@ -56,8 +56,6 @@ export const publishApp = async (data, argv = {}) => {
56
56
  data?.domainId,
57
57
  files: params.files || params.fileList,
58
58
  body: publishPayload,
59
- /* Doc 8 (2026-10-10): the SYS$APPS row the published config names (publishApplicationConfig). */
60
- ...(params.appMeta ? { appMeta: params.appMeta } : {}),
61
59
  /* Bundle mode fetches the encryption key from the Biz-A server BEFORE uploading, so a
62
60
  publish that loses the mode dials production even when the caller asked for local. */
63
61
  serverMode: argv.serverMode || params.serverMode || "prod",
@@ -23,7 +23,6 @@
23
23
  import { APPLICATION_THEME_NAME } from "../domain/themeConfig.js";
24
24
  import { resolveThemeTokens } from "../domain/themePresets.js";
25
25
  import { COMPANY_THEME_NAME } from "../orm/companyTheme.js";
26
- import { COMPANY_LOCALE_NAME, readCompanyLocale } from "../orm/companyLocale.js";
27
26
  import {
28
27
  COMPANY_LOGO_NAME,
29
28
  readCompanyLogo,
@@ -39,9 +38,6 @@ export const PUBLIC_THEME_KEYS = [
39
38
  APPLICATION_THEME_NAME,
40
39
  COMPANY_THEME_NAME,
41
40
  COMPANY_LOGO_NAME,
42
- /* Agung 2026-10-10 — the number format (id / en-US): display only, so a customer page shows the merchant's amounts
43
- the merchant's way. */
44
- COMPANY_LOCALE_NAME,
45
41
  ];
46
42
 
47
43
  /*
@@ -122,7 +118,7 @@ export const createPublicThemeReader = ({ resolveTenant, execFor }) => {
122
118
  }
123
119
  };
124
120
 
125
- const [applicationRow, companyRow, logoRow, localeRow] = await Promise.all([
121
+ const [applicationRow, companyRow, logoRow] = await Promise.all([
126
122
  readOne(APPLICATION_THEME_NAME, "ID, DATA"),
127
123
  readOne(COMPANY_THEME_NAME, "ID, DATA"),
128
124
  /*
@@ -132,7 +128,6 @@ export const createPublicThemeReader = ({ resolveTenant, execFor }) => {
132
128
  * time the theme is read. The image has its own endpoint and its own cache lifetime.
133
129
  */
134
130
  readOne(COMPANY_LOGO_NAME, "ID"),
135
- readOne(COMPANY_LOCALE_NAME, "ID, DATA"),
136
131
  ]);
137
132
 
138
133
  const application = forCustomer(
@@ -149,8 +144,6 @@ export const createPublicThemeReader = ({ resolveTenant, execFor }) => {
149
144
  three things a customer is allowed to see. */
150
145
  name: company?.name ?? application?.name ?? null,
151
146
  hasLogo: logoRow !== null,
152
- /* the merchant's number format; a missing or bad row is Indonesian (readCompanyLocale's rule) */
153
- numberLocale: (await readCompanyLocale({ exec: async () => (localeRow ? [localeRow] : []) })).number,
154
147
  };
155
148
  };
156
149
  };