@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.
@@ -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.29",
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
- "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.20",
109
- "@jskit-ai/crud-core": "0.1.29",
110
- "@jskit-ai/database-runtime": "0.1.21",
111
- "@jskit-ai/http-runtime": "0.1.20",
112
- "@jskit-ai/kernel": "0.1.21",
113
- "@jskit-ai/realtime": "0.1.20",
114
- "@jskit-ai/users-core": "0.1.30",
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.29",
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.29",
17
- "@jskit-ai/database-runtime": "0.1.21",
18
- "@jskit-ai/http-runtime": "0.1.20",
19
- "@jskit-ai/kernel": "0.1.21",
20
- "@jskit-ai/users-core": "0.1.30",
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(baseEntries = [], patchEntries = []) {
754
+ function mergeFieldMetaEntries(...entryGroups) {
755
755
  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 : {})
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
- return mergeFieldMetaEntries(dbColumnEntries, relationEntries);
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 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(
@@ -65,7 +65,7 @@ function createActions({ surface = "" } = {}) {
65
65
  require: "authenticated"
66
66
  },
67
67
  inputValidator: [workspaceSlugParamsValidator, recordIdParamsValidator, lookupIncludeQueryValidator],
68
- outputValidator: ${option:namespace|singular|camel}Resource.operations.view.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__
@@ -1,3 +1,3 @@
1
1
  export {
2
- ${option:namespace|singular|camel}Resource
2
+ resource
3
3
  } from "./${option:namespace|singular|camel}Resource.js";
@@ -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-add-field-"));
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("add-field patches CRUD resource file using DB snapshot metadata", async () => {
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: "add-field",
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: "add-field",
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(templateSource, /return createCrudLookupProvider\(scope\.make\("repository\.\$\{option:namespace\|snake\}"\)\);/);
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
+ });