@jskit-ai/crud-server-generator 0.1.29 → 0.1.31
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/package.descriptor.mjs +39 -10
- package/package.json +6 -6
- package/src/server/buildTemplateContext.js +162 -31
- package/src/server/subcommands/addField.js +9 -9
- package/src/server/subcommands/resourceAst.js +13 -13
- package/src/shared/crud/crudResource.js +17 -0
- package/templates/src/local-package/server/CrudProvider.js +3 -1
- package/templates/src/local-package/server/actions.js +1 -1
- package/templates/src/local-package/server/repository.js +13 -0
- package/templates/src/local-package/shared/crudResource.js +17 -0
- package/templates/src/local-package/shared/index.js +1 -1
- package/test/addFieldSubcommand.test.js +4 -4
- package/test/buildTemplateContext.test.js +45 -1
- package/test/templateSymbolConsistency.test.js +40 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/crud-server-generator",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.31",
|
|
5
5
|
kind: "generator",
|
|
6
6
|
description: "CRUD server generator with routes, actions, and persistence scaffolding.",
|
|
7
7
|
options: {
|
|
@@ -80,10 +80,39 @@ export default Object.freeze({
|
|
|
80
80
|
}
|
|
81
81
|
},
|
|
82
82
|
metadata: {
|
|
83
|
+
generatorPrimarySubcommand: "scaffold",
|
|
83
84
|
generatorSubcommands: {
|
|
84
|
-
"
|
|
85
|
+
"scaffold": {
|
|
86
|
+
description: "Scaffold a CRUD resource package for a table (same behavior as running generate with no subcommand).",
|
|
87
|
+
optionNames: [
|
|
88
|
+
"namespace",
|
|
89
|
+
"surface",
|
|
90
|
+
"ownership-filter",
|
|
91
|
+
"table-name",
|
|
92
|
+
"id-column",
|
|
93
|
+
"directory-prefix"
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
"scaffold-field": {
|
|
85
97
|
entrypoint: "src/server/subcommands/addField.js",
|
|
86
|
-
export: "runGeneratorSubcommand"
|
|
98
|
+
export: "runGeneratorSubcommand",
|
|
99
|
+
description: "Patch one writable field into an existing generated CRUD resource module.",
|
|
100
|
+
positionalArgs: [
|
|
101
|
+
{
|
|
102
|
+
name: "<fieldKey>",
|
|
103
|
+
required: true,
|
|
104
|
+
description: "Resource field key (camelCase) resolved from the DB snapshot."
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "<targetFile>",
|
|
108
|
+
required: true,
|
|
109
|
+
description: "Path to the generated CRUD resource file relative to app root."
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
optionNames: [
|
|
113
|
+
"table-name",
|
|
114
|
+
"id-column"
|
|
115
|
+
]
|
|
87
116
|
}
|
|
88
117
|
},
|
|
89
118
|
apiSummary: {
|
|
@@ -105,13 +134,13 @@ export default Object.freeze({
|
|
|
105
134
|
mutations: {
|
|
106
135
|
dependencies: {
|
|
107
136
|
runtime: {
|
|
108
|
-
"@jskit-ai/auth-core": "0.1.
|
|
109
|
-
"@jskit-ai/crud-core": "0.1.
|
|
110
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
111
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
112
|
-
"@jskit-ai/kernel": "0.1.
|
|
113
|
-
"@jskit-ai/realtime": "0.1.
|
|
114
|
-
"@jskit-ai/users-core": "0.1.
|
|
137
|
+
"@jskit-ai/auth-core": "0.1.22",
|
|
138
|
+
"@jskit-ai/crud-core": "0.1.31",
|
|
139
|
+
"@jskit-ai/database-runtime": "0.1.23",
|
|
140
|
+
"@jskit-ai/http-runtime": "0.1.22",
|
|
141
|
+
"@jskit-ai/kernel": "0.1.23",
|
|
142
|
+
"@jskit-ai/realtime": "0.1.22",
|
|
143
|
+
"@jskit-ai/users-core": "0.1.32",
|
|
115
144
|
"@local/${option:namespace|kebab}": "file:packages/${option:namespace|kebab}",
|
|
116
145
|
"typebox": "^1.0.81"
|
|
117
146
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/crud-server-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@babel/parser": "^7.29.2",
|
|
16
|
-
"@jskit-ai/crud-core": "0.1.
|
|
17
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
18
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
19
|
-
"@jskit-ai/kernel": "0.1.
|
|
20
|
-
"@jskit-ai/users-core": "0.1.
|
|
16
|
+
"@jskit-ai/crud-core": "0.1.31",
|
|
17
|
+
"@jskit-ai/database-runtime": "0.1.23",
|
|
18
|
+
"@jskit-ai/http-runtime": "0.1.22",
|
|
19
|
+
"@jskit-ai/kernel": "0.1.23",
|
|
20
|
+
"@jskit-ai/users-core": "0.1.32",
|
|
21
21
|
"recast": "^0.23.11",
|
|
22
22
|
"typebox": "^1.0.81"
|
|
23
23
|
}
|
|
@@ -751,32 +751,34 @@ function renderMigrationForeignKeyLines(snapshot) {
|
|
|
751
751
|
return lines.join("\n");
|
|
752
752
|
}
|
|
753
753
|
|
|
754
|
-
function mergeFieldMetaEntries(
|
|
754
|
+
function mergeFieldMetaEntries(...entryGroups) {
|
|
755
755
|
const mergedByKey = new Map();
|
|
756
|
-
for (const
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
if (existing.relation || sourceEntry.relation) {
|
|
768
|
-
next.relation = {
|
|
769
|
-
...(existing.relation && typeof existing.relation === "object" ? existing.relation : {}),
|
|
770
|
-
...(sourceEntry.relation && typeof sourceEntry.relation === "object" ? sourceEntry.relation : {})
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
if (existing.ui || sourceEntry.ui) {
|
|
774
|
-
next.ui = {
|
|
775
|
-
...(existing.ui && typeof existing.ui === "object" ? existing.ui : {}),
|
|
776
|
-
...(sourceEntry.ui && typeof sourceEntry.ui === "object" ? sourceEntry.ui : {})
|
|
756
|
+
for (const sourceEntries of entryGroups) {
|
|
757
|
+
for (const sourceEntry of Array.isArray(sourceEntries) ? sourceEntries : []) {
|
|
758
|
+
const key = normalizeText(sourceEntry?.key);
|
|
759
|
+
if (!key) {
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const existing = mergedByKey.get(key) || {};
|
|
763
|
+
const next = {
|
|
764
|
+
...existing,
|
|
765
|
+
...sourceEntry,
|
|
766
|
+
key
|
|
777
767
|
};
|
|
768
|
+
if (existing.relation || sourceEntry.relation) {
|
|
769
|
+
next.relation = {
|
|
770
|
+
...(existing.relation && typeof existing.relation === "object" ? existing.relation : {}),
|
|
771
|
+
...(sourceEntry.relation && typeof sourceEntry.relation === "object" ? sourceEntry.relation : {})
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
if (existing.ui || sourceEntry.ui) {
|
|
775
|
+
next.ui = {
|
|
776
|
+
...(existing.ui && typeof existing.ui === "object" ? existing.ui : {}),
|
|
777
|
+
...(sourceEntry.ui && typeof sourceEntry.ui === "object" ? sourceEntry.ui : {})
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
mergedByKey.set(key, next);
|
|
778
781
|
}
|
|
779
|
-
mergedByKey.set(key, next);
|
|
780
782
|
}
|
|
781
783
|
|
|
782
784
|
return [...mergedByKey.values()].sort((left, right) => left.key.localeCompare(right.key));
|
|
@@ -791,6 +793,87 @@ function resolveLookupNamespaceFromTableName(tableName = "") {
|
|
|
791
793
|
return normalizedTableName.replace(/_/g, "-");
|
|
792
794
|
}
|
|
793
795
|
|
|
796
|
+
function toFieldLabel(key = "") {
|
|
797
|
+
const normalizedKey = normalizeText(key);
|
|
798
|
+
if (!normalizedKey) {
|
|
799
|
+
return "";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const words = normalizedKey
|
|
803
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
804
|
+
.replace(/[_\-.]+/g, " ")
|
|
805
|
+
.split(/\s+/)
|
|
806
|
+
.map((entry) => normalizeText(entry))
|
|
807
|
+
.filter(Boolean);
|
|
808
|
+
if (words.length < 1) {
|
|
809
|
+
return "";
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return words
|
|
813
|
+
.map((entry) => `${entry.slice(0, 1).toUpperCase()}${entry.slice(1)}`)
|
|
814
|
+
.join(" ");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function isSupportedSelectOptionValue(value) {
|
|
818
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function toSelectOptionIdentity(value) {
|
|
822
|
+
return `${typeof value}:${String(value)}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function toSelectOptionLabel(value) {
|
|
826
|
+
if (typeof value === "string") {
|
|
827
|
+
return toFieldLabel(value) || value;
|
|
828
|
+
}
|
|
829
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
830
|
+
return String(value);
|
|
831
|
+
}
|
|
832
|
+
return "";
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function normalizeFieldMetaUiOptions(rawOptions = []) {
|
|
836
|
+
if (!Array.isArray(rawOptions)) {
|
|
837
|
+
return [];
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const options = [];
|
|
841
|
+
const seenValues = new Set();
|
|
842
|
+
for (const rawEntry of rawOptions) {
|
|
843
|
+
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
const value = rawEntry.value;
|
|
847
|
+
if (!isSupportedSelectOptionValue(value)) {
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const identity = toSelectOptionIdentity(value);
|
|
852
|
+
if (seenValues.has(identity)) {
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
seenValues.add(identity);
|
|
856
|
+
|
|
857
|
+
const explicitLabel = normalizeText(rawEntry.label);
|
|
858
|
+
options.push({
|
|
859
|
+
value,
|
|
860
|
+
label: explicitLabel || toSelectOptionLabel(value) || String(value)
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return options;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function resolveEnumFieldMetaUiOptions(enumValues = []) {
|
|
868
|
+
const options = Array.isArray(enumValues)
|
|
869
|
+
? enumValues.map((value) => ({
|
|
870
|
+
value,
|
|
871
|
+
label: toSelectOptionLabel(value)
|
|
872
|
+
}))
|
|
873
|
+
: [];
|
|
874
|
+
return normalizeFieldMetaUiOptions(options);
|
|
875
|
+
}
|
|
876
|
+
|
|
794
877
|
function buildFieldMetaEntries({ outputColumns = [], writableColumns = [], snapshot = {} } = {}) {
|
|
795
878
|
const fieldColumns = [...outputColumns, ...writableColumns];
|
|
796
879
|
const fieldColumnsByName = new Map();
|
|
@@ -858,7 +941,33 @@ function buildFieldMetaEntries({ outputColumns = [], writableColumns = [], snaps
|
|
|
858
941
|
});
|
|
859
942
|
}
|
|
860
943
|
|
|
861
|
-
|
|
944
|
+
const relationFieldKeys = new Set(
|
|
945
|
+
relationEntries
|
|
946
|
+
.map((entry) => normalizeText(entry?.key))
|
|
947
|
+
.filter(Boolean)
|
|
948
|
+
);
|
|
949
|
+
const enumEntries = [];
|
|
950
|
+
for (const column of fieldColumnsByKey.values()) {
|
|
951
|
+
const key = normalizeText(column?.key);
|
|
952
|
+
if (!key || relationFieldKeys.has(key)) {
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const options = resolveEnumFieldMetaUiOptions(column?.enumValues);
|
|
957
|
+
if (options.length < 1) {
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
enumEntries.push({
|
|
962
|
+
key,
|
|
963
|
+
ui: {
|
|
964
|
+
formControl: "select",
|
|
965
|
+
options
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return mergeFieldMetaEntries(dbColumnEntries, relationEntries, enumEntries);
|
|
862
971
|
}
|
|
863
972
|
|
|
864
973
|
function renderFieldMetaEntryLines(entry = {}) {
|
|
@@ -896,17 +1005,39 @@ function renderFieldMetaEntryLines(entry = {}) {
|
|
|
896
1005
|
topLevelProperties.push(relationLines.join("\n"));
|
|
897
1006
|
}
|
|
898
1007
|
|
|
1008
|
+
const fieldUiOptions = normalizeFieldMetaUiOptions(entry?.ui?.options);
|
|
899
1009
|
const formControl = checkCrudLookupFormControl(entry?.ui?.formControl, {
|
|
900
1010
|
context: `resource.fieldMeta["${normalizeText(entry.key)}"].ui.formControl`,
|
|
901
|
-
defaultValue: relation ? "autocomplete" : ""
|
|
1011
|
+
defaultValue: relation ? "autocomplete" : (fieldUiOptions.length > 0 ? "select" : "")
|
|
902
1012
|
});
|
|
903
|
-
if (formControl) {
|
|
1013
|
+
if (formControl || fieldUiOptions.length > 0) {
|
|
1014
|
+
const uiPropertyBlocks = [];
|
|
1015
|
+
if (formControl) {
|
|
1016
|
+
uiPropertyBlocks.push([
|
|
1017
|
+
`formControl: ${JSON.stringify(formControl)}${relation ? " // or \"select\"" : ""}`
|
|
1018
|
+
]);
|
|
1019
|
+
}
|
|
1020
|
+
if (fieldUiOptions.length > 0) {
|
|
1021
|
+
const optionsJsonLines = JSON.stringify(fieldUiOptions, null, 2).split("\n");
|
|
1022
|
+
const optionPropertyLines = [`options: ${optionsJsonLines[0]}`];
|
|
1023
|
+
for (const jsonLine of optionsJsonLines.slice(1)) {
|
|
1024
|
+
optionPropertyLines.push(jsonLine);
|
|
1025
|
+
}
|
|
1026
|
+
uiPropertyBlocks.push(optionPropertyLines);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const uiLines = ["ui: {"];
|
|
1030
|
+
for (const [propertyIndex, propertyLines] of uiPropertyBlocks.entries()) {
|
|
1031
|
+
const isLastProperty = propertyIndex >= uiPropertyBlocks.length - 1;
|
|
1032
|
+
const propertySuffix = isLastProperty ? "" : ",";
|
|
1033
|
+
for (const [lineIndex, line] of propertyLines.entries()) {
|
|
1034
|
+
const isLastLine = lineIndex >= propertyLines.length - 1;
|
|
1035
|
+
uiLines.push(` ${line}${isLastLine ? propertySuffix : ""}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
uiLines.push("}");
|
|
904
1039
|
topLevelProperties.push(
|
|
905
|
-
|
|
906
|
-
"ui: {",
|
|
907
|
-
` formControl: ${JSON.stringify(formControl)} // or "select"`,
|
|
908
|
-
"}"
|
|
909
|
-
].join("\n")
|
|
1040
|
+
uiLines.join("\n")
|
|
910
1041
|
);
|
|
911
1042
|
}
|
|
912
1043
|
|
|
@@ -35,12 +35,12 @@ function toPosixPath(value = "") {
|
|
|
35
35
|
function resolveTargetFilePath(appRoot, targetFile) {
|
|
36
36
|
const appRootAbsolute = path.resolve(String(appRoot || ""));
|
|
37
37
|
if (!appRootAbsolute) {
|
|
38
|
-
throw new Error("crud-server-generator
|
|
38
|
+
throw new Error("crud-server-generator scaffold-field requires appRoot.");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const normalizedTargetFile = normalizeText(targetFile);
|
|
42
42
|
if (!normalizedTargetFile) {
|
|
43
|
-
throw new Error("crud-server-generator
|
|
43
|
+
throw new Error("crud-server-generator scaffold-field requires target file path.");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const absolutePath = path.isAbsolute(normalizedTargetFile)
|
|
@@ -53,7 +53,7 @@ function resolveTargetFilePath(appRoot, targetFile) {
|
|
|
53
53
|
relativePath.startsWith(`..${path.sep}`) ||
|
|
54
54
|
path.isAbsolute(relativePath)
|
|
55
55
|
) {
|
|
56
|
-
throw new Error("crud-server-generator
|
|
56
|
+
throw new Error("crud-server-generator scaffold-field target file must stay within app root.");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
return {
|
|
@@ -68,10 +68,10 @@ function parseSubcommandArgs(args = []) {
|
|
|
68
68
|
const targetFile = normalizeText(source[1]);
|
|
69
69
|
|
|
70
70
|
if (!fieldKey) {
|
|
71
|
-
throw new Error("crud-server-generator
|
|
71
|
+
throw new Error("crud-server-generator scaffold-field requires <fieldKey>.");
|
|
72
72
|
}
|
|
73
73
|
if (!targetFile) {
|
|
74
|
-
throw new Error("crud-server-generator
|
|
74
|
+
throw new Error("crud-server-generator scaffold-field requires <targetFile>.");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
return {
|
|
@@ -80,7 +80,7 @@ function parseSubcommandArgs(args = []) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
function resolveRequestedTableConfig(source = "", options = {}, context = "crud-server-generator
|
|
83
|
+
function resolveRequestedTableConfig(source = "", options = {}, context = "crud-server-generator scaffold-field") {
|
|
84
84
|
const defaults = resolveCrudResourceDefaults(source, context);
|
|
85
85
|
const tableName = normalizeText(options?.["table-name"] || defaults.tableName);
|
|
86
86
|
if (!tableName) {
|
|
@@ -110,7 +110,7 @@ function resolveColumnForField(snapshot = {}, fieldKey = "", { idColumn = "id" }
|
|
|
110
110
|
.filter(Boolean)
|
|
111
111
|
.join(", ");
|
|
112
112
|
throw new Error(
|
|
113
|
-
`crud-server-generator
|
|
113
|
+
`crud-server-generator scaffold-field could not find field "${key}" in DB snapshot columns. Available: ${available || "<none>"}.`
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -178,7 +178,7 @@ async function runGeneratorSubcommand({
|
|
|
178
178
|
resolveSnapshot = resolveGenerationSnapshot
|
|
179
179
|
} = {}) {
|
|
180
180
|
const normalizedSubcommand = normalizeText(subcommand).toLowerCase();
|
|
181
|
-
if (normalizedSubcommand !== "
|
|
181
|
+
if (normalizedSubcommand !== "scaffold-field") {
|
|
182
182
|
throw new Error(`Unsupported crud-server-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
|
|
183
183
|
}
|
|
184
184
|
|
|
@@ -197,7 +197,7 @@ async function runGeneratorSubcommand({
|
|
|
197
197
|
const column = resolveColumnForField(snapshot, fieldKey, { idColumn });
|
|
198
198
|
if (column?.writable !== true) {
|
|
199
199
|
throw new Error(
|
|
200
|
-
`crud-server-generator
|
|
200
|
+
`crud-server-generator scaffold-field cannot patch non-writable field "${fieldKey}" (column "${column.name}").`
|
|
201
201
|
);
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -37,7 +37,7 @@ function isIdentifierName(value = "") {
|
|
|
37
37
|
return IDENTIFIER_PATTERN.test(String(value || ""));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function parseModule(source = "", context = "crud-server-generator
|
|
40
|
+
function parseModule(source = "", context = "crud-server-generator scaffold-field") {
|
|
41
41
|
try {
|
|
42
42
|
return recast.parse(String(source || ""), { parser: BABEL_REC_AST_PARSER });
|
|
43
43
|
} catch (error) {
|
|
@@ -47,7 +47,7 @@ function parseModule(source = "", context = "crud-server-generator add-field") {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function parseExpression(source = "", context = "crud-server-generator
|
|
50
|
+
function parseExpression(source = "", context = "crud-server-generator scaffold-field") {
|
|
51
51
|
const expressionSource = `const __jskitFieldExpression = ${String(source || "")};`;
|
|
52
52
|
const ast = parseModule(expressionSource, context);
|
|
53
53
|
const statement = ast?.program?.body?.[0];
|
|
@@ -61,7 +61,7 @@ function parseExpression(source = "", context = "crud-server-generator add-field
|
|
|
61
61
|
return declaration.init;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function parseStatement(source = "", context = "crud-server-generator
|
|
64
|
+
function parseStatement(source = "", context = "crud-server-generator scaffold-field") {
|
|
65
65
|
const ast = parseModule(String(source || ""), context);
|
|
66
66
|
const statement = ast?.program?.body?.[0];
|
|
67
67
|
if (!statement) {
|
|
@@ -114,7 +114,7 @@ function findVariableDeclarator(programNode, variableName = "") {
|
|
|
114
114
|
return null;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function requireVariableDeclarator(programNode, variableName = "", context = "crud-server-generator
|
|
117
|
+
function requireVariableDeclarator(programNode, variableName = "", context = "crud-server-generator scaffold-field") {
|
|
118
118
|
const declaration = findVariableDeclarator(programNode, variableName);
|
|
119
119
|
if (declaration) {
|
|
120
120
|
return declaration;
|
|
@@ -122,7 +122,7 @@ function requireVariableDeclarator(programNode, variableName = "", context = "cr
|
|
|
122
122
|
throw new Error(`${context} could not find const ${variableName}.`);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function requireSchemaPropertiesObject(programNode, variableName = "", context = "crud-server-generator
|
|
125
|
+
function requireSchemaPropertiesObject(programNode, variableName = "", context = "crud-server-generator scaffold-field") {
|
|
126
126
|
const declaration = requireVariableDeclarator(programNode, variableName, context);
|
|
127
127
|
const initExpression = declaration.init;
|
|
128
128
|
if (!n.CallExpression.check(initExpression)) {
|
|
@@ -149,7 +149,7 @@ function requireSchemaPropertiesObject(programNode, variableName = "", context =
|
|
|
149
149
|
return firstArgument;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function requireObjectFreezePayloadObject(programNode, variableName = "", context = "crud-server-generator
|
|
152
|
+
function requireObjectFreezePayloadObject(programNode, variableName = "", context = "crud-server-generator scaffold-field") {
|
|
153
153
|
const declaration = requireVariableDeclarator(programNode, variableName, context);
|
|
154
154
|
const initExpression = declaration.init;
|
|
155
155
|
if (!n.CallExpression.check(initExpression)) {
|
|
@@ -197,7 +197,7 @@ function findObjectPropertyByName(objectNode, propertyName = "") {
|
|
|
197
197
|
return null;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
function requireNormalizeFunctionBody(programNode, variableName = "", context = "crud-server-generator
|
|
200
|
+
function requireNormalizeFunctionBody(programNode, variableName = "", context = "crud-server-generator scaffold-field") {
|
|
201
201
|
const validatorObject = requireObjectFreezePayloadObject(programNode, variableName, context);
|
|
202
202
|
const normalizeProperty = findObjectPropertyByName(validatorObject, "normalize");
|
|
203
203
|
if (!normalizeProperty) {
|
|
@@ -218,7 +218,7 @@ function requireNormalizeFunctionBody(programNode, variableName = "", context =
|
|
|
218
218
|
throw new Error(`${context} expected ${variableName}.normalize to be a function with a block body.`);
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
function requireNormalizedObjectLiteral(functionBody, context = "crud-server-generator
|
|
221
|
+
function requireNormalizedObjectLiteral(functionBody, context = "crud-server-generator scaffold-field") {
|
|
222
222
|
for (const statement of functionBody.body || []) {
|
|
223
223
|
if (!n.VariableDeclaration.check(statement)) {
|
|
224
224
|
continue;
|
|
@@ -248,7 +248,7 @@ function insertObjectProperty(
|
|
|
248
248
|
propertyName = "",
|
|
249
249
|
valueExpressionSource = "",
|
|
250
250
|
{
|
|
251
|
-
context = "crud-server-generator
|
|
251
|
+
context = "crud-server-generator scaffold-field",
|
|
252
252
|
insertBeforeComputed = false
|
|
253
253
|
} = {}
|
|
254
254
|
) {
|
|
@@ -448,7 +448,7 @@ function resolveObjectPropertyStringValue(objectNode, propertyName = "") {
|
|
|
448
448
|
return "";
|
|
449
449
|
}
|
|
450
450
|
|
|
451
|
-
function resolveCrudResourceDefaults(source = "", context = "crud-server-generator
|
|
451
|
+
function resolveCrudResourceDefaults(source = "", context = "crud-server-generator scaffold-field") {
|
|
452
452
|
const ast = parseModule(source, context);
|
|
453
453
|
const statements = Array.isArray(ast?.program?.body) ? ast.program.body : [];
|
|
454
454
|
|
|
@@ -478,7 +478,7 @@ function resolveCrudResourceDefaults(source = "", context = "crud-server-generat
|
|
|
478
478
|
function renderResourceFieldMetaPushStatement(entry = {}) {
|
|
479
479
|
const key = normalizeText(entry?.key);
|
|
480
480
|
if (!key) {
|
|
481
|
-
throw new Error("crud-server-generator
|
|
481
|
+
throw new Error("crud-server-generator scaffold-field fieldMeta entry requires key.");
|
|
482
482
|
}
|
|
483
483
|
|
|
484
484
|
const lines = ["RESOURCE_FIELD_META.push({"];
|
|
@@ -495,7 +495,7 @@ function renderResourceFieldMetaPushStatement(entry = {}) {
|
|
|
495
495
|
normalizeCrudLookupNamespace(relation.namespace) ||
|
|
496
496
|
normalizeCrudLookupNamespace(relation.apiPath);
|
|
497
497
|
if (!relationNamespace) {
|
|
498
|
-
throw new Error("crud-server-generator
|
|
498
|
+
throw new Error("crud-server-generator scaffold-field fieldMeta relation requires namespace.");
|
|
499
499
|
}
|
|
500
500
|
lines.push(" relation: {");
|
|
501
501
|
lines.push(` kind: ${JSON.stringify(normalizeText(relation.kind) || "lookup")},`);
|
|
@@ -530,7 +530,7 @@ function applyCrudResourceFieldPatch(
|
|
|
530
530
|
normalizeImportNames = [],
|
|
531
531
|
databaseRuntimeImportNames = [],
|
|
532
532
|
databaseRuntimeRepositoryOptionsImportNames = [],
|
|
533
|
-
context = "crud-server-generator
|
|
533
|
+
context = "crud-server-generator scaffold-field"
|
|
534
534
|
} = {}
|
|
535
535
|
) {
|
|
536
536
|
const normalizedFieldKey = normalizeText(fieldKey);
|
|
@@ -183,4 +183,21 @@ const crudResource = {
|
|
|
183
183
|
|
|
184
184
|
void CRUD_RESOURCE_FIELD_META;
|
|
185
185
|
|
|
186
|
+
// Example 1:n collection hydration:
|
|
187
|
+
// CRUD_RESOURCE_FIELD_META.push({
|
|
188
|
+
// key: "pets",
|
|
189
|
+
// relation: {
|
|
190
|
+
// kind: "collection",
|
|
191
|
+
// namespace: "pets",
|
|
192
|
+
// foreignKey: "customerId",
|
|
193
|
+
// parentValueKey: "id",
|
|
194
|
+
// hydrateOnList: false, // list: opt-in with include=pets
|
|
195
|
+
// hydrateOnView: true // view: hydrated by default
|
|
196
|
+
// }
|
|
197
|
+
// });
|
|
198
|
+
//
|
|
199
|
+
// To hydrate child lookups too, request nested include paths:
|
|
200
|
+
// - include=pets
|
|
201
|
+
// - include=pets,pets.breedId
|
|
202
|
+
|
|
186
203
|
export { crudResource };
|
|
@@ -45,7 +45,9 @@ class ${option:namespace|pascal}Provider {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
app.singleton("crud.lookup.${option:namespace|snake}", (scope) => {
|
|
48
|
-
return createCrudLookupProvider(scope.make("repository.${option:namespace|snake}")
|
|
48
|
+
return createCrudLookupProvider(scope.make("repository.${option:namespace|snake}"), {
|
|
49
|
+
ownershipFilter: crudPolicy.ownershipFilter
|
|
50
|
+
});
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
app.service(
|
|
@@ -65,7 +65,7 @@ function createActions({ surface = "" } = {}) {
|
|
|
65
65
|
require: "authenticated"
|
|
66
66
|
},
|
|
67
67
|
inputValidator: [workspaceSlugParamsValidator, recordIdParamsValidator, lookupIncludeQueryValidator],
|
|
68
|
-
outputValidator:
|
|
68
|
+
outputValidator: resource.operations.view.outputValidator,
|
|
69
69
|
idempotency: "none",
|
|
70
70
|
audit: {
|
|
71
71
|
actionName: actionIds.view
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
crudRepositoryList,
|
|
4
4
|
crudRepositoryFindById,
|
|
5
5
|
crudRepositoryListByIds,
|
|
6
|
+
crudRepositoryListByForeignIds,
|
|
6
7
|
crudRepositoryCreate,
|
|
7
8
|
crudRepositoryUpdateById,
|
|
8
9
|
crudRepositoryDeleteById
|
|
@@ -37,6 +38,17 @@ function createRepository(knex, options = {}) {
|
|
|
37
38
|
return crudRepositoryListByIds(repositoryRuntime, knex, ids, options, callOptions);
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
async function listByForeignIds(ids = [], foreignKey = "", callOptions = {}) {
|
|
42
|
+
return crudRepositoryListByForeignIds(
|
|
43
|
+
repositoryRuntime,
|
|
44
|
+
knex,
|
|
45
|
+
ids,
|
|
46
|
+
foreignKey,
|
|
47
|
+
options,
|
|
48
|
+
callOptions
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
async function create(payload = {}, callOptions = {}) {
|
|
41
53
|
return crudRepositoryCreate(repositoryRuntime, knex, payload, options, callOptions);
|
|
42
54
|
}
|
|
@@ -53,6 +65,7 @@ function createRepository(knex, options = {}) {
|
|
|
53
65
|
list,
|
|
54
66
|
findById,
|
|
55
67
|
listByIds,
|
|
68
|
+
listByForeignIds,
|
|
56
69
|
create,
|
|
57
70
|
updateById,
|
|
58
71
|
deleteById
|
|
@@ -138,4 +138,21 @@ export { resource };
|
|
|
138
138
|
// @jskit-contract crud.resource.field-meta.${option:namespace|snake}.v1
|
|
139
139
|
void RESOURCE_FIELD_META;
|
|
140
140
|
|
|
141
|
+
// Example 1:n collection hydration:
|
|
142
|
+
// RESOURCE_FIELD_META.push({
|
|
143
|
+
// key: "pets",
|
|
144
|
+
// relation: {
|
|
145
|
+
// kind: "collection",
|
|
146
|
+
// namespace: "pets",
|
|
147
|
+
// foreignKey: "customerId",
|
|
148
|
+
// parentValueKey: "id",
|
|
149
|
+
// hydrateOnList: false, // list: opt-in with include=pets
|
|
150
|
+
// hydrateOnView: true // view: hydrated by default
|
|
151
|
+
// }
|
|
152
|
+
// });
|
|
153
|
+
//
|
|
154
|
+
// To hydrate child lookups too, request nested include paths:
|
|
155
|
+
// - include=pets
|
|
156
|
+
// - include=pets,pets.breedId
|
|
157
|
+
|
|
141
158
|
__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__
|
|
@@ -86,7 +86,7 @@ export { resource };
|
|
|
86
86
|
`;
|
|
87
87
|
|
|
88
88
|
async function withTempApp(run) {
|
|
89
|
-
const appRoot = await mkdtemp(path.join(tmpdir(), "crud-server-
|
|
89
|
+
const appRoot = await mkdtemp(path.join(tmpdir(), "crud-server-scaffold-field-"));
|
|
90
90
|
try {
|
|
91
91
|
await run(appRoot);
|
|
92
92
|
} finally {
|
|
@@ -129,14 +129,14 @@ function createSnapshot() {
|
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
test("
|
|
132
|
+
test("scaffold-field patches CRUD resource file using DB snapshot metadata", async () => {
|
|
133
133
|
await withTempApp(async (appRoot) => {
|
|
134
134
|
const resourceFile = "packages/contacts/src/shared/contactResource.js";
|
|
135
135
|
await writeAppFile(appRoot, resourceFile, RESOURCE_SOURCE);
|
|
136
136
|
|
|
137
137
|
const result = await runGeneratorSubcommand({
|
|
138
138
|
appRoot,
|
|
139
|
-
subcommand: "
|
|
139
|
+
subcommand: "scaffold-field",
|
|
140
140
|
args: ["categoryId", resourceFile],
|
|
141
141
|
options: {},
|
|
142
142
|
resolveSnapshot: async () => createSnapshot()
|
|
@@ -157,7 +157,7 @@ test("add-field patches CRUD resource file using DB snapshot metadata", async ()
|
|
|
157
157
|
|
|
158
158
|
const secondRun = await runGeneratorSubcommand({
|
|
159
159
|
appRoot,
|
|
160
|
-
subcommand: "
|
|
160
|
+
subcommand: "scaffold-field",
|
|
161
161
|
args: ["categoryId", resourceFile],
|
|
162
162
|
options: {},
|
|
163
163
|
resolveSnapshot: async () => createSnapshot()
|
|
@@ -285,6 +285,47 @@ test("buildReplacementsFromSnapshot renders append-only field meta entries from
|
|
|
285
285
|
assert.match(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, /table\.foreign\(\["vet_id"\]/);
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
+
test("buildReplacementsFromSnapshot renders enum field meta options as select controls", () => {
|
|
289
|
+
const baseSnapshot = createSnapshot({
|
|
290
|
+
hasWorkspaceOwnerColumn: false,
|
|
291
|
+
hasUserOwnerColumn: false
|
|
292
|
+
});
|
|
293
|
+
const snapshot = {
|
|
294
|
+
...baseSnapshot,
|
|
295
|
+
columns: Object.freeze([
|
|
296
|
+
...baseSnapshot.columns,
|
|
297
|
+
Object.freeze({
|
|
298
|
+
name: "temperament",
|
|
299
|
+
key: "temperament",
|
|
300
|
+
dataType: "enum",
|
|
301
|
+
columnType: "enum('relaxed','friendly_excitable','unknown')",
|
|
302
|
+
typeKind: "string",
|
|
303
|
+
nullable: false,
|
|
304
|
+
hasDefault: false,
|
|
305
|
+
defaultValue: null,
|
|
306
|
+
autoIncrement: false,
|
|
307
|
+
unsigned: false,
|
|
308
|
+
extra: "",
|
|
309
|
+
maxLength: null,
|
|
310
|
+
numericPrecision: null,
|
|
311
|
+
numericScale: null,
|
|
312
|
+
enumValues: Object.freeze(["relaxed", "friendly_excitable", "unknown"])
|
|
313
|
+
})
|
|
314
|
+
])
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const replacements = __testables.buildReplacementsFromSnapshot({
|
|
318
|
+
snapshot,
|
|
319
|
+
resolvedOwnershipFilter: "public"
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /key: "temperament"/);
|
|
323
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /formControl: "select"/);
|
|
324
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /options: \[/);
|
|
325
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /"value": "friendly_excitable"/);
|
|
326
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /"label": "Friendly Excitable"/);
|
|
327
|
+
});
|
|
328
|
+
|
|
288
329
|
test("renderMigrationColumnLine ignores SQL NULL string defaults", () => {
|
|
289
330
|
const line = __testables.renderMigrationColumnLine(
|
|
290
331
|
{
|
|
@@ -445,6 +486,9 @@ test("crud provider template uses shared lookup provider helpers instead of inli
|
|
|
445
486
|
/from "@jskit-ai\/crud-core\/server\/lookupProviders";/
|
|
446
487
|
);
|
|
447
488
|
assert.match(templateSource, /resolveLookupProvider: createCrudLookupProviderResolver\(scope\)/);
|
|
448
|
-
assert.match(
|
|
489
|
+
assert.match(
|
|
490
|
+
templateSource,
|
|
491
|
+
/return createCrudLookupProvider\(scope\.make\("repository\.\$\{option:namespace\|snake\}"\), \{\s*ownershipFilter: crudPolicy\.ownershipFilter\s*\}\);/
|
|
492
|
+
);
|
|
449
493
|
assert.doesNotMatch(templateSource, /normalizePathname\(relation\.apiPath\)/);
|
|
450
494
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
function resolveTemplatePath(relativePath) {
|
|
7
|
+
return fileURLToPath(new URL(`../templates/${relativePath}`, import.meta.url));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("actions template uses resource symbol for view output validator", async () => {
|
|
11
|
+
const actionsTemplate = await readFile(
|
|
12
|
+
resolveTemplatePath("src/local-package/server/actions.js"),
|
|
13
|
+
"utf8"
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
assert.match(
|
|
17
|
+
actionsTemplate,
|
|
18
|
+
/outputValidator:\s*resource\.operations\.view\.outputValidator,/
|
|
19
|
+
);
|
|
20
|
+
assert.doesNotMatch(
|
|
21
|
+
actionsTemplate,
|
|
22
|
+
/\$\{option:namespace\|singular\|camel\}Resource\.operations\.view\.outputValidator/
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("shared index template re-exports standardized resource symbol", async () => {
|
|
27
|
+
const sharedIndexTemplate = await readFile(
|
|
28
|
+
resolveTemplatePath("src/local-package/shared/index.js"),
|
|
29
|
+
"utf8"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
assert.match(
|
|
33
|
+
sharedIndexTemplate,
|
|
34
|
+
/export\s*\{\s*resource\s*\}\s*from\s*"\.\/\$\{option:namespace\|singular\|camel\}Resource\.js";/s
|
|
35
|
+
);
|
|
36
|
+
assert.doesNotMatch(
|
|
37
|
+
sharedIndexTemplate,
|
|
38
|
+
/export\s*\{\s*\$\{option:namespace\|singular\|camel\}Resource\s*\}/s
|
|
39
|
+
);
|
|
40
|
+
});
|