@jskit-ai/crud-server-generator 0.1.30 → 0.1.32

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.
@@ -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.30",
4
+ version: "0.1.32",
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
- "add-field": {
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.21",
109
- "@jskit-ai/crud-core": "0.1.30",
110
- "@jskit-ai/database-runtime": "0.1.22",
111
- "@jskit-ai/http-runtime": "0.1.21",
112
- "@jskit-ai/kernel": "0.1.22",
113
- "@jskit-ai/realtime": "0.1.21",
114
- "@jskit-ai/users-core": "0.1.31",
137
+ "@jskit-ai/auth-core": "0.1.23",
138
+ "@jskit-ai/crud-core": "0.1.32",
139
+ "@jskit-ai/database-runtime": "0.1.24",
140
+ "@jskit-ai/http-runtime": "0.1.23",
141
+ "@jskit-ai/kernel": "0.1.24",
142
+ "@jskit-ai/realtime": "0.1.23",
143
+ "@jskit-ai/users-core": "0.1.33",
115
144
  "@local/${option:namespace|kebab}": "file:packages/${option:namespace|kebab}",
116
145
  "typebox": "^1.0.81"
117
146
  },
@@ -218,6 +247,18 @@ export default Object.freeze({
218
247
  }
219
248
  }
220
249
  ],
221
- text: []
250
+ text: [
251
+ {
252
+ op: "append-text",
253
+ file: "config/roles.js",
254
+ position: "bottom",
255
+ skipIfContains: "\"crud.${option:namespace|snake}.list\"",
256
+ value:
257
+ "\nroleCatalog.roles.member.permissions.push(\n \"crud.${option:namespace|snake}.list\",\n \"crud.${option:namespace|snake}.view\",\n \"crud.${option:namespace|snake}.create\",\n \"crud.${option:namespace|snake}.update\",\n \"crud.${option:namespace|snake}.delete\"\n);\n",
258
+ reason: "Grant generated CRUD action permissions to the default member role in the app-owned role catalog.",
259
+ category: "crud",
260
+ id: "crud-role-catalog-permissions-${option:namespace|snake}"
261
+ }
262
+ ]
222
263
  }
223
264
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-server-generator",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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.30",
17
- "@jskit-ai/database-runtime": "0.1.22",
18
- "@jskit-ai/http-runtime": "0.1.21",
19
- "@jskit-ai/kernel": "0.1.22",
20
- "@jskit-ai/users-core": "0.1.31",
16
+ "@jskit-ai/crud-core": "0.1.32",
17
+ "@jskit-ai/database-runtime": "0.1.24",
18
+ "@jskit-ai/http-runtime": "0.1.23",
19
+ "@jskit-ai/kernel": "0.1.24",
20
+ "@jskit-ai/users-core": "0.1.33",
21
21
  "recast": "^0.23.11",
22
22
  "typebox": "^1.0.81"
23
23
  }
@@ -369,7 +369,9 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
369
369
  } else if (typeKind === "date") {
370
370
  schemaExpression = 'Type.String({ format: "date", minLength: 1 })';
371
371
  } else if (typeKind === "time") {
372
- schemaExpression = 'Type.String({ format: "time", minLength: 1 })';
372
+ return column.nullable === true
373
+ ? "NULLABLE_HTML_TIME_STRING_SCHEMA"
374
+ : "HTML_TIME_STRING_SCHEMA";
373
375
  } else if (typeKind === "json") {
374
376
  schemaExpression = "Type.Any()";
375
377
  }
@@ -380,6 +382,17 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
380
382
  return schemaExpression;
381
383
  }
382
384
 
385
+ function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false } = {}) {
386
+ const imports = [
387
+ "normalizeObjectInput",
388
+ "createCursorListValidator"
389
+ ];
390
+ if (needsHtmlTimeSchemas) {
391
+ imports.push("HTML_TIME_STRING_SCHEMA", "NULLABLE_HTML_TIME_STRING_SCHEMA");
392
+ }
393
+ return `import {\n ${imports.join(",\n ")}\n} from "@jskit-ai/kernel/shared/validators";`;
394
+ }
395
+
383
396
  function renderInputNormalizer(column) {
384
397
  const typeKind = String(column.typeKind || "");
385
398
  const nullable = column?.nullable === true;
@@ -751,32 +764,34 @@ function renderMigrationForeignKeyLines(snapshot) {
751
764
  return lines.join("\n");
752
765
  }
753
766
 
754
- function mergeFieldMetaEntries(baseEntries = [], patchEntries = []) {
767
+ function mergeFieldMetaEntries(...entryGroups) {
755
768
  const mergedByKey = new Map();
756
- for (const sourceEntry of [...baseEntries, ...patchEntries]) {
757
- const key = normalizeText(sourceEntry?.key);
758
- if (!key) {
759
- continue;
760
- }
761
- const existing = mergedByKey.get(key) || {};
762
- const next = {
763
- ...existing,
764
- ...sourceEntry,
765
- key
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 : {})
769
+ for (const sourceEntries of entryGroups) {
770
+ for (const sourceEntry of Array.isArray(sourceEntries) ? sourceEntries : []) {
771
+ const key = normalizeText(sourceEntry?.key);
772
+ if (!key) {
773
+ continue;
774
+ }
775
+ const existing = mergedByKey.get(key) || {};
776
+ const next = {
777
+ ...existing,
778
+ ...sourceEntry,
779
+ key
777
780
  };
781
+ if (existing.relation || sourceEntry.relation) {
782
+ next.relation = {
783
+ ...(existing.relation && typeof existing.relation === "object" ? existing.relation : {}),
784
+ ...(sourceEntry.relation && typeof sourceEntry.relation === "object" ? sourceEntry.relation : {})
785
+ };
786
+ }
787
+ if (existing.ui || sourceEntry.ui) {
788
+ next.ui = {
789
+ ...(existing.ui && typeof existing.ui === "object" ? existing.ui : {}),
790
+ ...(sourceEntry.ui && typeof sourceEntry.ui === "object" ? sourceEntry.ui : {})
791
+ };
792
+ }
793
+ mergedByKey.set(key, next);
778
794
  }
779
- mergedByKey.set(key, next);
780
795
  }
781
796
 
782
797
  return [...mergedByKey.values()].sort((left, right) => left.key.localeCompare(right.key));
@@ -791,6 +806,87 @@ function resolveLookupNamespaceFromTableName(tableName = "") {
791
806
  return normalizedTableName.replace(/_/g, "-");
792
807
  }
793
808
 
809
+ function toFieldLabel(key = "") {
810
+ const normalizedKey = normalizeText(key);
811
+ if (!normalizedKey) {
812
+ return "";
813
+ }
814
+
815
+ const words = normalizedKey
816
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
817
+ .replace(/[_\-.]+/g, " ")
818
+ .split(/\s+/)
819
+ .map((entry) => normalizeText(entry))
820
+ .filter(Boolean);
821
+ if (words.length < 1) {
822
+ return "";
823
+ }
824
+
825
+ return words
826
+ .map((entry) => `${entry.slice(0, 1).toUpperCase()}${entry.slice(1)}`)
827
+ .join(" ");
828
+ }
829
+
830
+ function isSupportedSelectOptionValue(value) {
831
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
832
+ }
833
+
834
+ function toSelectOptionIdentity(value) {
835
+ return `${typeof value}:${String(value)}`;
836
+ }
837
+
838
+ function toSelectOptionLabel(value) {
839
+ if (typeof value === "string") {
840
+ return toFieldLabel(value) || value;
841
+ }
842
+ if (typeof value === "number" || typeof value === "boolean") {
843
+ return String(value);
844
+ }
845
+ return "";
846
+ }
847
+
848
+ function normalizeFieldMetaUiOptions(rawOptions = []) {
849
+ if (!Array.isArray(rawOptions)) {
850
+ return [];
851
+ }
852
+
853
+ const options = [];
854
+ const seenValues = new Set();
855
+ for (const rawEntry of rawOptions) {
856
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
857
+ continue;
858
+ }
859
+ const value = rawEntry.value;
860
+ if (!isSupportedSelectOptionValue(value)) {
861
+ continue;
862
+ }
863
+
864
+ const identity = toSelectOptionIdentity(value);
865
+ if (seenValues.has(identity)) {
866
+ continue;
867
+ }
868
+ seenValues.add(identity);
869
+
870
+ const explicitLabel = normalizeText(rawEntry.label);
871
+ options.push({
872
+ value,
873
+ label: explicitLabel || toSelectOptionLabel(value) || String(value)
874
+ });
875
+ }
876
+
877
+ return options;
878
+ }
879
+
880
+ function resolveEnumFieldMetaUiOptions(enumValues = []) {
881
+ const options = Array.isArray(enumValues)
882
+ ? enumValues.map((value) => ({
883
+ value,
884
+ label: toSelectOptionLabel(value)
885
+ }))
886
+ : [];
887
+ return normalizeFieldMetaUiOptions(options);
888
+ }
889
+
794
890
  function buildFieldMetaEntries({ outputColumns = [], writableColumns = [], snapshot = {} } = {}) {
795
891
  const fieldColumns = [...outputColumns, ...writableColumns];
796
892
  const fieldColumnsByName = new Map();
@@ -858,7 +954,33 @@ function buildFieldMetaEntries({ outputColumns = [], writableColumns = [], snaps
858
954
  });
859
955
  }
860
956
 
861
- return mergeFieldMetaEntries(dbColumnEntries, relationEntries);
957
+ const relationFieldKeys = new Set(
958
+ relationEntries
959
+ .map((entry) => normalizeText(entry?.key))
960
+ .filter(Boolean)
961
+ );
962
+ const enumEntries = [];
963
+ for (const column of fieldColumnsByKey.values()) {
964
+ const key = normalizeText(column?.key);
965
+ if (!key || relationFieldKeys.has(key)) {
966
+ continue;
967
+ }
968
+
969
+ const options = resolveEnumFieldMetaUiOptions(column?.enumValues);
970
+ if (options.length < 1) {
971
+ continue;
972
+ }
973
+
974
+ enumEntries.push({
975
+ key,
976
+ ui: {
977
+ formControl: "select",
978
+ options
979
+ }
980
+ });
981
+ }
982
+
983
+ return mergeFieldMetaEntries(dbColumnEntries, relationEntries, enumEntries);
862
984
  }
863
985
 
864
986
  function renderFieldMetaEntryLines(entry = {}) {
@@ -896,17 +1018,39 @@ function renderFieldMetaEntryLines(entry = {}) {
896
1018
  topLevelProperties.push(relationLines.join("\n"));
897
1019
  }
898
1020
 
1021
+ const fieldUiOptions = normalizeFieldMetaUiOptions(entry?.ui?.options);
899
1022
  const formControl = checkCrudLookupFormControl(entry?.ui?.formControl, {
900
1023
  context: `resource.fieldMeta["${normalizeText(entry.key)}"].ui.formControl`,
901
- defaultValue: relation ? "autocomplete" : ""
1024
+ defaultValue: relation ? "autocomplete" : (fieldUiOptions.length > 0 ? "select" : "")
902
1025
  });
903
- if (formControl) {
1026
+ if (formControl || fieldUiOptions.length > 0) {
1027
+ const uiPropertyBlocks = [];
1028
+ if (formControl) {
1029
+ uiPropertyBlocks.push([
1030
+ `formControl: ${JSON.stringify(formControl)}${relation ? " // or \"select\"" : ""}`
1031
+ ]);
1032
+ }
1033
+ if (fieldUiOptions.length > 0) {
1034
+ const optionsJsonLines = JSON.stringify(fieldUiOptions, null, 2).split("\n");
1035
+ const optionPropertyLines = [`options: ${optionsJsonLines[0]}`];
1036
+ for (const jsonLine of optionsJsonLines.slice(1)) {
1037
+ optionPropertyLines.push(jsonLine);
1038
+ }
1039
+ uiPropertyBlocks.push(optionPropertyLines);
1040
+ }
1041
+
1042
+ const uiLines = ["ui: {"];
1043
+ for (const [propertyIndex, propertyLines] of uiPropertyBlocks.entries()) {
1044
+ const isLastProperty = propertyIndex >= uiPropertyBlocks.length - 1;
1045
+ const propertySuffix = isLastProperty ? "" : ",";
1046
+ for (const [lineIndex, line] of propertyLines.entries()) {
1047
+ const isLastLine = lineIndex >= propertyLines.length - 1;
1048
+ uiLines.push(` ${line}${isLastLine ? propertySuffix : ""}`);
1049
+ }
1050
+ }
1051
+ uiLines.push("}");
904
1052
  topLevelProperties.push(
905
- [
906
- "ui: {",
907
- ` formControl: ${JSON.stringify(formControl)} // or "select"`,
908
- "}"
909
- ].join("\n")
1053
+ uiLines.join("\n")
910
1054
  );
911
1055
  }
912
1056
 
@@ -933,6 +1077,29 @@ function renderResourceFieldMetaPushLines(entries = []) {
933
1077
  return sourceEntries.map((entry) => renderFieldMetaEntryLines(entry)).join("\n\n");
934
1078
  }
935
1079
 
1080
+ function renderRepositoryListConfigLines(snapshot = {}) {
1081
+ const commentLines = [
1082
+ " // defaultLimit: 20,",
1083
+ " // maxLimit: 100,",
1084
+ " // searchColumns: [\"name\"],"
1085
+ ];
1086
+ const sourceColumns = Array.isArray(snapshot?.columns) ? snapshot.columns : [];
1087
+ const hasCreatedAtColumn = sourceColumns.some((column = {}) => normalizeText(column?.name) === "created_at");
1088
+ if (!hasCreatedAtColumn) {
1089
+ return commentLines.join("\n");
1090
+ }
1091
+
1092
+ return [
1093
+ ...commentLines,
1094
+ " orderBy: [",
1095
+ " {",
1096
+ " column: \"created_at\",",
1097
+ " direction: \"desc\"",
1098
+ " }",
1099
+ " ]"
1100
+ ].join("\n");
1101
+ }
1102
+
936
1103
  function buildReplacementsFromSnapshot({
937
1104
  snapshot,
938
1105
  resolvedOwnershipFilter
@@ -959,6 +1126,7 @@ function buildReplacementsFromSnapshot({
959
1126
  const needsNullableDateInput = writableColumns.some(
960
1127
  (column) => column.typeKind === "date" && column.nullable === true
961
1128
  );
1129
+ const needsHtmlTimeSchemas = resourceColumns.some((column) => column.typeKind === "time");
962
1130
  const needsDate = resourceColumns.some((column) => column.typeKind === "date");
963
1131
  const needsJson = resourceColumns.some((column) => column.typeKind === "json");
964
1132
  const needsNormalizeText = resourceColumns.some((column) =>
@@ -976,6 +1144,9 @@ function buildReplacementsFromSnapshot({
976
1144
  __JSKIT_CRUD_TABLE_NAME__: JSON.stringify(snapshot.tableName),
977
1145
  __JSKIT_CRUD_ID_COLUMN__: JSON.stringify(snapshot.idColumn || DEFAULT_ID_COLUMN),
978
1146
  __JSKIT_CRUD_RESOLVED_OWNERSHIP_FILTER__: resolvedOwnershipFilter,
1147
+ __JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__: renderResourceValidatorsImport({
1148
+ needsHtmlTimeSchemas
1149
+ }),
979
1150
  __JSKIT_CRUD_RESOURCE_DATABASE_RUNTIME_IMPORT__: renderResourceDatabaseRuntimeImport({
980
1151
  needsToIsoString: needsDateTimeOutput || needsDate,
981
1152
  needsToDatabaseDateTimeUtc: needsDateTimeInput
@@ -1002,6 +1173,7 @@ function buildReplacementsFromSnapshot({
1002
1173
  __JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__: renderResourceOutputNormalizationLines(outputColumns),
1003
1174
  __JSKIT_CRUD_RESOURCE_CREATE_REQUIRED_FIELDS__: JSON.stringify(createRequiredFieldKeys),
1004
1175
  __JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__: renderResourceFieldMetaPushLines(fieldMetaEntries),
1176
+ __JSKIT_CRUD_LIST_CONFIG_LINES__: renderRepositoryListConfigLines(snapshot),
1005
1177
  __JSKIT_CRUD_MIGRATION_COLUMN_LINES__: renderMigrationColumnLines(snapshot),
1006
1178
  __JSKIT_CRUD_MIGRATION_INDEX_LINES__: renderMigrationIndexLines(snapshot),
1007
1179
  __JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__: renderMigrationForeignKeyLines(snapshot)
@@ -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 add-field requires appRoot.");
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 add-field requires target file path.");
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 add-field target file must stay within app root.");
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 add-field requires <fieldKey>.");
71
+ throw new Error("crud-server-generator scaffold-field requires <fieldKey>.");
72
72
  }
73
73
  if (!targetFile) {
74
- throw new Error("crud-server-generator add-field requires <targetFile>.");
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 add-field") {
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 add-field could not find field "${key}" in DB snapshot columns. Available: ${available || "<none>"}.`
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 !== "add-field") {
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 add-field cannot patch non-writable field "${fieldKey}" (column "${column.name}").`
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 add-field") {
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 add-field") {
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 add-field") {
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 add-field") {
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 add-field") {
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 add-field") {
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 add-field") {
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 add-field") {
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 add-field",
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 add-field") {
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 add-field fieldMeta entry requires key.");
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 add-field fieldMeta relation requires namespace.");
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 add-field"
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(