biz-a-cli 2.3.80-15326 → 2.3.80-15333

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
@@ -36,8 +36,11 @@ import {
36
36
  import {
37
37
  isPostgresIndex,
38
38
  execPostgres,
39
+ execPostgresStatements,
39
40
  primeRegistry,
40
41
  } from "../engine/domain/dialect.js";
42
+
43
+ import { translateExecuteBlock } from "../engine/orm/executeBlock.js";
41
44
  import { addUser } from "../engine/orm/userAdmin.js";
42
45
  import {
43
46
  preservePublishedAdditions,
@@ -125,10 +125,11 @@ export function createSocketServer(httpServer, cliIpAddress = "127.0.0.1") {
125
125
  TEST_URL,
126
126
  ADMIN_URL,
127
127
  TEST_ADMIN_URL,
128
- // Retired biz-a.id origins. Kept here, commented, in case the domain is used
129
- // again — uncomment these, but NOT the wildcard (see above).
130
- // "https://biz-a.id",
131
- // "https://test.biz-a.id",
128
+ // biz-a.id is still served alongside biz-a.app, so its browser origins stay allowed.
129
+ "https://biz-a.id",
130
+ "https://test.biz-a.id",
131
+ "https://admin.biz-a.id",
132
+ // NOT the wildcard (see above):
132
133
  // /\.biz-a\.id$/,
133
134
  "vscode-file://vscode-app",
134
135
  /\.vscode-cdn\.net$/,
@@ -2781,6 +2781,11 @@ const loadDdlHashes = async (domainName, domainVersion, config) => {
2781
2781
  }
2782
2782
  };
2783
2783
 
2784
+ /* Exported so a publish can store a config row it has just rewritten -- currently the navigation a
2785
+ domain contributed (Dok. 5 section 3.3). Same dialect-aware path every other config write uses. */
2786
+ export const saveConfigRow = (name, version, content, config) =>
2787
+ upsertConfigRow(name, version, content, config);
2788
+
2784
2789
  const writeDdlHashes = async (domainName, domainVersion, tables, config) => {
2785
2790
  const { name, version } = buildDdlHashConfigKey(domainName, domainVersion);
2786
2791
  const payload = JSON.stringify({ tables });
@@ -5,7 +5,12 @@ import {
5
5
  resolveConfigFromPayload,
6
6
  buildPublishStatusConfigKey,
7
7
  writePublishStatus,
8
+ saveConfigRow,
8
9
  } from "./appConfig.js";
10
+ import { execPostgres } from "./dialect.js";
11
+ /* Dok. 5 section 3.3 -- a Layer 3 domain contributing its own menus, gated by its own activation
12
+ setting. Read generically: no domain name and no domain code appear in this file. */
13
+ import { mergeDomainNavigation, selectActiveDomainMenus, readActiveModules } from "./domainNavigation.js";
9
14
 
10
15
  export const parseConfigData = (rawConfig) => {
11
16
  if (rawConfig == null) {
@@ -628,7 +633,7 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
628
633
  if (!appRow?.data) {
629
634
  throw new Error(`SYS$CONFIG record ${applicationConfigName}@${applicationConfigVersion} was not found.`);
630
635
  }
631
- const parsedApplicationConfig = parseConfigData(appRow.data) ?? {};
636
+ let parsedApplicationConfig = parseConfigData(appRow.data) ?? {};
632
637
 
633
638
  /* An app bundle publish is a full replace (addApp() rebuilds the whole bundle from scratch every call), so
634
639
  template.js scaffolding must be generated for EVERY domain registered in application.js's `domains` map, not
@@ -653,6 +658,10 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
653
658
  }
654
659
 
655
660
  const mergedFileList = [];
661
+ /* domainKey -> the menu entries that domain contributes this publish (§3.3). */
662
+ const domainNavigation = new Map();
663
+ /* Parsed once in the collect pass, reused in the build pass. */
664
+ const scannedDomains = [];
656
665
  const seenFileNames = new Set();
657
666
  const addFilesToMergedList = (fileList) => {
658
667
  for (const file of fileList) {
@@ -667,10 +676,61 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
667
676
  if (domainsToScan.size === 0) {
668
677
  addFilesToMergedList(buildPublishBodyFromConfigs(parsedApplicationConfig, {}, "").fileList);
669
678
  } else {
679
+ /*
680
+ * ⚠️ TWO PASSES, AND THE ORDER IS THE WHOLE POINT. The menu the CLIENT reads is generated
681
+ * INTO THE BUNDLE by buildPublishBodyFromConfigs, so any domain menu folded in after that is a
682
+ * publish behind — the row is right and the screen still has no way to be reached. Found live:
683
+ * `hr: 2` in the log, `HR_ROSTER` in APPLICATION_CONFIG, and no such item in the menu.
684
+ *
685
+ * So: collect first, merge, then build the files from the MERGED navigation. The template a
686
+ * leaf points at is generated in this same bundle, so a link can never outrun its screen.
687
+ */
670
688
  for (const [domainKey, { domainConfigName, domainConfigVersion }] of domainsToScan) {
671
689
  const domainRows = await readConfigRows(domainConfigName, domainConfigVersion, config);
672
690
  const domainRow = domainRows?.[0];
673
691
  const parsedDomainConfig = domainRow?.data ? (parseConfigData(domainRow.data) ?? {}) : {};
692
+ scannedDomains.push([domainKey, parsedDomainConfig]);
693
+
694
+ /*
695
+ * Dok. 5 §3.3 / §4.2 — a Layer 3 domain contributing its OWN menus, gated by its own
696
+ * activation setting. Without this a domain could declare screens and still have no way to
697
+ * be reached, and the activation setting drove nothing.
698
+ *
699
+ * ⚠️ READ, NEVER EXECUTED. The activating set lives in the domain's table, which this file
700
+ * must not know about — so the domain NAMES the table and column and the read is generic.
701
+ * No domain name and no domain code appear here, which is what keeps the golden rule.
702
+ *
703
+ * ⚠️ Fails SOFT: a domain that declares nothing, or whose setting cannot be read, simply
704
+ * contributes nothing. A publish must not fail because one domain's settings row is bad.
705
+ */
706
+ const declaredMenus = parsedDomainConfig?.navigation?.menus;
707
+ if (Array.isArray(declaredMenus) && declaredMenus.length > 0) {
708
+ try {
709
+ const activeModules = await readActiveModules({
710
+ activation: parsedDomainConfig?.navigation?.activation,
711
+ exec: execPostgres,
712
+ dbIndex: Number(config?.dbindex ?? config?.dbIndex),
713
+ });
714
+ domainNavigation.set(domainKey, selectActiveDomainMenus({ menus: declaredMenus, activeModules }));
715
+ } catch (error) {
716
+ logger.warn(
717
+ `[navigation] ${domainKey}: could not resolve its menus, so it contributes none: ` +
718
+ `${error?.message ?? error}`,
719
+ );
720
+ }
721
+ }
722
+ }
723
+
724
+ /* Fold the domains' menus in BEFORE the bundle is built from this config. */
725
+ if (domainNavigation.size > 0) {
726
+ let navigation = parsedApplicationConfig?.navigation ?? {};
727
+ for (const [domainKey, menus] of domainNavigation) {
728
+ navigation = mergeDomainNavigation({ applicationConfig: { navigation }, domainKey, menus });
729
+ }
730
+ parsedApplicationConfig = { ...parsedApplicationConfig, navigation };
731
+ }
732
+
733
+ for (const [domainKey, parsedDomainConfig] of scannedDomains) {
674
734
  addFilesToMergedList(buildPublishBodyFromConfigs(parsedApplicationConfig, parsedDomainConfig, domainKey).fileList);
675
735
  }
676
736
  }
@@ -687,6 +747,35 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
687
747
  if (!response || response.success !== true) {
688
748
  throw new Error(response?.error ? String(response.error) : "Application Config publish failed without an error message from the server.");
689
749
  }
750
+
751
+ /*
752
+ * ⚠️ AFTER the bundle publish, not before: a domain's leaf must not appear in the menu until the
753
+ * screen it opens is actually in the bundle. The other order produces a dead link for exactly as
754
+ * long as a publish takes, and permanently if the publish then fails.
755
+ *
756
+ * ⚠️ And the merge is additive + self-healing per domain (see domainNavigation.js), because
757
+ * APPLICATION_CONFIG has more than one writer and a wholesale replace has wiped the other one's
758
+ * work before.
759
+ */
760
+ if (domainNavigation.size > 0) {
761
+ try {
762
+ /* Already merged above, so the stored row and the bundle's menu are the same thing — which
763
+ is the only arrangement in which the two cannot drift. */
764
+ await saveConfigRow(
765
+ applicationConfigName,
766
+ applicationConfigVersion,
767
+ `get = function () { return ${JSON.stringify(parsedApplicationConfig)}; }`,
768
+ config,
769
+ );
770
+ const counts = [...domainNavigation].map(([key, menus]) => `${key}: ${menus.length}`).join(", ");
771
+ logger.info(`[navigation] domain menus published (${counts})`);
772
+ } catch (error) {
773
+ /* The bundle is already published and correct; a menu that did not update is a re-publish
774
+ away, so this must not turn a successful publish into a failure. */
775
+ logger.warn(`[navigation] domain menus could not be merged: ${error?.message ?? error}`);
776
+ }
777
+ }
778
+
690
779
  return response;
691
780
  };
692
781
 
@@ -0,0 +1,131 @@
1
+ /*
2
+ * A Layer 3 domain contributing its own navigation.
3
+ *
4
+ * ⚠️ WHY THIS EXISTS. `buildMenuStructureFromApplicationConfig` reads `APPLICATION_CONFIG.navigation`
5
+ * and nothing else, so a domain could declare entities, screens, settings and server code and still
6
+ * have no way to be REACHED — the host had to hand-declare every leaf. That also made Doc 5 §4.2's
7
+ * "runtime publish emits the menu tree from the activated set" impossible: the activation setting
8
+ * drove nothing at all.
9
+ *
10
+ * §3.3 names the mechanism: "Navigation entries carry `domain: 'hr'` and are emitted at runtime
11
+ * publish according to which submodules are active."
12
+ *
13
+ * ⚠️ THE ACTIVATION IS READ FROM DATA, NOT EXECUTED. The activating set lives in the DOMAIN's own
14
+ * table, which this file must not know about — so the domain NAMES the table and column in its config
15
+ * and the publish reads it generically. No domain name, no domain code, nothing app-specific here.
16
+ */
17
+
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];
20
+
21
+ /*
22
+ * Which of a domain's declared entries are live right now.
23
+ *
24
+ * ⚠️ AN ENTRY WITH NO `module` IS ALWAYS EMITTED — §4.2's design-time menu, which exists whatever is
25
+ * activated. An entry WITH one appears only while that module is active.
26
+ *
27
+ * ⚠️ AND AN UNREADABLE ACTIVATION LIST ACTIVATES NOTHING. Failing open would hand a client every
28
+ * screen the moment a settings row went bad, which is the opposite of what a gate is for.
29
+ */
30
+ export const selectActiveDomainMenus = ({ menus, activeModules } = {}) => {
31
+ const declared = Array.isArray(menus) ? menus : [];
32
+ const active = new Set(Array.isArray(activeModules) ? activeModules.map((m) => String(m)) : []);
33
+ return declared.filter((entry) => {
34
+ const gate = entry?.module;
35
+ if (gate === null || gate === undefined || String(gate).trim() === "") return true;
36
+ return active.has(String(gate));
37
+ });
38
+ };
39
+
40
+ /*
41
+ * Fold a domain's live entries into the host's navigation.
42
+ *
43
+ * ⚠️ ADDITIVE AND SELF-HEALING, which is the lesson APPLICATION_CONFIG's two writers already taught
44
+ * once: a publish that replaces the whole navigation wipes whatever the other writer added. So the
45
+ * host's own leaves are never touched, and neither are another domain's.
46
+ *
47
+ * ⚠️ AND IT STRIPS THIS DOMAIN'S PREVIOUS LEAVES FIRST. That is what the `domain` stamp is for, and
48
+ * it is what makes deactivation real: §4.2's stated failure is that "switching one off would leave
49
+ * its tile behind", which would make activation advisory — the setting would look like it did
50
+ * something while the tile still opened. Injecting without stripping would also accumulate duplicates
51
+ * that nothing could clear.
52
+ */
53
+ export const mergeDomainNavigation = ({ applicationConfig, domainKey, menus } = {}) => {
54
+ const key = String(domainKey ?? "").trim();
55
+ const navigation = applicationConfig?.navigation ?? {};
56
+ const sourceMenus = Array.isArray(navigation.menus) ? navigation.menus : [];
57
+ const entries = Array.isArray(menus) ? menus : [];
58
+
59
+ /* Everything that is NOT this domain's, group structure preserved. */
60
+ const kept = sourceMenus.map((group) => ({
61
+ ...group,
62
+ subMenu: (Array.isArray(group?.subMenu) ? group.subMenu : []).filter(
63
+ (leaf) => String(leaf?.domain ?? "") !== key,
64
+ ),
65
+ }));
66
+
67
+ for (const entry of entries) {
68
+ const groupCaption = String(entry?.group ?? "").trim();
69
+ if (!groupCaption || !entry?.entryPoint) continue;
70
+
71
+ let group = kept.find((g) => String(g?.caption ?? "") === groupCaption);
72
+ if (!group) {
73
+ group = { caption: groupCaption, link: [], subMenu: [] };
74
+ kept.push(group);
75
+ }
76
+ if (!Array.isArray(group.subMenu)) group.subMenu = [];
77
+
78
+ group.subMenu.push({
79
+ caption: String(entry.caption ?? entry.entryPoint),
80
+ menuKey: String(entry.menuKey ?? entry.entryPoint),
81
+ link: linkFor(entry),
82
+ /* §3.3's stamp: what the next publish uses to find exactly its own leaves. */
83
+ domain: key,
84
+ });
85
+ }
86
+
87
+ /* A group that existed only to hold this domain's leaves, and now holds none, goes with them —
88
+ an empty group renders as a dead parent nobody can open. The host's own empty groups are left
89
+ alone, because they were not this publish's to remove. */
90
+ const hadDomainLeafOnly = (group) =>
91
+ (group?.subMenu ?? []).length === 0 &&
92
+ !sourceMenus.some(
93
+ (original) =>
94
+ String(original?.caption ?? "") === String(group?.caption ?? "") &&
95
+ (original?.subMenu ?? []).some((leaf) => String(leaf?.domain ?? "") !== key),
96
+ );
97
+
98
+ return { ...navigation, menus: kept.filter((group) => !hadDomainLeafOnly(group)) };
99
+ };
100
+
101
+ /*
102
+ * Read a domain's activation setting through the coordinates IT declares.
103
+ *
104
+ * ⚠️ Returns an empty list for anything it cannot read — a missing declaration, a missing row, a value
105
+ * that is not a JSON array. See `selectActiveDomainMenus`: the gate fails closed.
106
+ */
107
+ export const readActiveModules = async ({ activation, exec, dbIndex } = {}) => {
108
+ const entity = String(activation?.entity ?? "").trim();
109
+ const keyColumn = String(activation?.keyColumn ?? "").trim();
110
+ const valueColumn = String(activation?.valueColumn ?? "").trim();
111
+ const setting = String(activation?.setting ?? "").trim();
112
+ if (!entity || !keyColumn || !valueColumn || !setting) return [];
113
+
114
+ /* Identifiers come from a published domain config, but they are still interpolated — so anything
115
+ that is not a plain identifier is refused rather than quoted into the statement. */
116
+ const identifier = /^[A-Za-z_][A-Za-z0-9_$]*$/;
117
+ if (![entity, keyColumn, valueColumn].every((name) => identifier.test(name))) return [];
118
+
119
+ try {
120
+ const rows = await exec(
121
+ `SELECT ${valueColumn} AS value FROM ${entity} WHERE ${keyColumn} = '${setting.replace(/'/g, "''")}'`,
122
+ dbIndex,
123
+ );
124
+ const raw = rows?.[0]?.value;
125
+ if (raw === null || raw === undefined) return [];
126
+ const parsed = JSON.parse(String(raw));
127
+ return Array.isArray(parsed) ? parsed : [];
128
+ } catch {
129
+ return [];
130
+ }
131
+ };
@@ -50,6 +50,29 @@ export const assertInterval = (value, what = "grace") => {
50
50
 
51
51
  /* An instant, as a literal. Refused unless it actually parses — same reasoning as the interval. */
52
52
  export const assertInstant = (value, what = "at") => {
53
+ /*
54
+ * ⚠⚠ A DATE OBJECT IS WHAT THE DRIVER HANDS BACK, and it used to pass this guard and then
55
+ * break the statement. `Date.parse` accepts a JS Date's own toString() — "Tue Sep 15 2026
56
+ * 08:00:00 GMT+0700 (Western Indonesia Time)" — which was then interpolated verbatim, and
57
+ * PostgreSQL refused it with `time zone "gmt+0700" not recognized`.
58
+ *
59
+ * So the value was CERTIFIED and still unusable, which is the worst thing a validator can do.
60
+ * Found the first time the §10 bridge ran against real rows: every timestamp it carries comes
61
+ * straight out of a `timestamp` column, so every assignment it issued would have failed.
62
+ *
63
+ * ⚠️ Only a Date is converted. A naive STRING keeps its passthrough, because parsing it here
64
+ * would move its interpretation from the SERVER's timezone to this process's — a silent shift in
65
+ * meaning for every existing caller.
66
+ */
67
+ if (value instanceof Date) {
68
+ if (Number.isNaN(value.getTime())) {
69
+ throw new Error(
70
+ `${what} must be a timestamp — refused an Invalid Date rather than interpolating it ` +
71
+ "into a statement (Doc 4 §7.1).",
72
+ );
73
+ }
74
+ return value.toISOString();
75
+ }
53
76
  const text = String(value ?? "").trim();
54
77
  if (text === "" || Number.isNaN(Date.parse(text))) {
55
78
  throw new Error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15326",
3
+ "version": "2.3.80-15333",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",