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,
|
package/bin/directHubEvent.js
CHANGED
|
@@ -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
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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(
|