@jskit-ai/crud-server-generator 0.1.56 → 0.1.57

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.56",
4
+ version: "0.1.57",
5
5
  kind: "generator",
6
6
  description: "CRUD server generator with routes, actions, and persistence scaffolding.",
7
7
  options: {
@@ -151,13 +151,13 @@ export default Object.freeze({
151
151
  mutations: {
152
152
  dependencies: {
153
153
  runtime: {
154
- "@jskit-ai/auth-core": "0.1.47",
155
- "@jskit-ai/crud-core": "0.1.56",
156
- "@jskit-ai/database-runtime": "0.1.48",
157
- "@jskit-ai/http-runtime": "0.1.47",
158
- "@jskit-ai/kernel": "0.1.48",
159
- "@jskit-ai/realtime": "0.1.47",
160
- "@jskit-ai/users-core": "0.1.58",
154
+ "@jskit-ai/auth-core": "0.1.48",
155
+ "@jskit-ai/crud-core": "0.1.57",
156
+ "@jskit-ai/database-runtime": "0.1.49",
157
+ "@jskit-ai/http-runtime": "0.1.48",
158
+ "@jskit-ai/kernel": "0.1.49",
159
+ "@jskit-ai/realtime": "0.1.48",
160
+ "@jskit-ai/users-core": "0.1.59",
161
161
  "@local/${option:namespace|kebab}": "file:packages/${option:namespace|kebab}",
162
162
  "typebox": "^1.0.81"
163
163
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-server-generator",
3
- "version": "0.1.56",
3
+ "version": "0.1.57",
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.56",
17
- "@jskit-ai/database-runtime": "0.1.48",
18
- "@jskit-ai/http-runtime": "0.1.47",
19
- "@jskit-ai/kernel": "0.1.48",
20
- "@jskit-ai/users-core": "0.1.58",
16
+ "@jskit-ai/crud-core": "0.1.57",
17
+ "@jskit-ai/database-runtime": "0.1.49",
18
+ "@jskit-ai/http-runtime": "0.1.48",
19
+ "@jskit-ai/kernel": "0.1.49",
20
+ "@jskit-ai/users-core": "0.1.59",
21
21
  "recast": "^0.23.11",
22
22
  "typebox": "^1.0.81"
23
23
  }
@@ -391,9 +391,194 @@ function resolveColumnKey(column, idColumn) {
391
391
  return String(column.key || "");
392
392
  }
393
393
 
394
+ const NUMERIC_CHECK_CONSTRAINT_PATTERN = /(?:`([^`]+)`|([A-Za-z_][A-Za-z0-9_]*))\s*(>=|>|<=|<)\s*(-?\d+(?:\.\d+)?)/g;
395
+
396
+ function normalizeNumericBoundValue(value, scale = null) {
397
+ const parsed = Number(value);
398
+ if (!Number.isFinite(parsed)) {
399
+ return null;
400
+ }
401
+ if (!Number.isInteger(scale) || scale < 0) {
402
+ return parsed;
403
+ }
404
+ return Number(parsed.toFixed(scale));
405
+ }
406
+
407
+ function resolveNumericExclusiveStep(column) {
408
+ if (column?.typeKind === "integer") {
409
+ return 1;
410
+ }
411
+ if (column?.typeKind === "number" && Number.isInteger(column?.numericScale) && column.numericScale > 0) {
412
+ return 1 / (10 ** column.numericScale);
413
+ }
414
+ return null;
415
+ }
416
+
417
+ function applyLowerBound(current = null, candidate = null) {
418
+ if (!candidate) {
419
+ return current;
420
+ }
421
+ if (!current) {
422
+ return candidate;
423
+ }
424
+ if (candidate.value > current.value) {
425
+ return candidate;
426
+ }
427
+ if (candidate.value < current.value) {
428
+ return current;
429
+ }
430
+ if (candidate.exclusive === true && current.exclusive !== true) {
431
+ return candidate;
432
+ }
433
+ return current;
434
+ }
435
+
436
+ function applyUpperBound(current = null, candidate = null) {
437
+ if (!candidate) {
438
+ return current;
439
+ }
440
+ if (!current) {
441
+ return candidate;
442
+ }
443
+ if (candidate.value < current.value) {
444
+ return candidate;
445
+ }
446
+ if (candidate.value > current.value) {
447
+ return current;
448
+ }
449
+ if (candidate.exclusive === true && current.exclusive !== true) {
450
+ return candidate;
451
+ }
452
+ return current;
453
+ }
454
+
455
+ function resolveColumnNumericBounds(snapshot = {}) {
456
+ const byColumnName = new Map();
457
+ const columns = Array.isArray(snapshot.columns) ? snapshot.columns : [];
458
+ const checkConstraints = Array.isArray(snapshot.checkConstraints) ? snapshot.checkConstraints : [];
459
+ const numericColumnsByName = new Map(
460
+ columns
461
+ .filter((column) => column?.typeKind === "integer" || column?.typeKind === "number")
462
+ .map((column) => [String(column.name || ""), column])
463
+ );
464
+
465
+ function getColumnBounds(columnName) {
466
+ if (!byColumnName.has(columnName)) {
467
+ byColumnName.set(columnName, {
468
+ minimum: null,
469
+ exclusiveMinimum: null,
470
+ maximum: null,
471
+ exclusiveMaximum: null
472
+ });
473
+ }
474
+ return byColumnName.get(columnName);
475
+ }
476
+
477
+ for (const column of numericColumnsByName.values()) {
478
+ if (column.unsigned === true) {
479
+ const target = getColumnBounds(column.name);
480
+ target.minimum = 0;
481
+ }
482
+ }
483
+
484
+ for (const constraint of checkConstraints) {
485
+ const clause = String(constraint?.clause || "");
486
+ if (!clause) {
487
+ continue;
488
+ }
489
+
490
+ let match = null;
491
+ while ((match = NUMERIC_CHECK_CONSTRAINT_PATTERN.exec(clause)) != null) {
492
+ const columnName = String(match[1] || match[2] || "");
493
+ const operator = String(match[3] || "");
494
+ const rawValue = Number(match[4]);
495
+ const column = numericColumnsByName.get(columnName) || null;
496
+ if (!column || !Number.isFinite(rawValue)) {
497
+ continue;
498
+ }
499
+
500
+ const target = getColumnBounds(columnName);
501
+ if (operator === ">=" || operator === ">") {
502
+ let candidate = null;
503
+ if (operator === ">=") {
504
+ candidate = {
505
+ value: normalizeNumericBoundValue(rawValue, column.numericScale),
506
+ exclusive: false
507
+ };
508
+ } else {
509
+ const exclusiveStep = resolveNumericExclusiveStep(column);
510
+ if (exclusiveStep != null) {
511
+ candidate = {
512
+ value: normalizeNumericBoundValue(rawValue + exclusiveStep, column.numericScale),
513
+ exclusive: false
514
+ };
515
+ } else {
516
+ candidate = {
517
+ value: normalizeNumericBoundValue(rawValue, column.numericScale),
518
+ exclusive: true
519
+ };
520
+ }
521
+ }
522
+
523
+ const nextBound = applyLowerBound(
524
+ target.minimum != null || target.exclusiveMinimum != null
525
+ ? {
526
+ value: target.minimum ?? target.exclusiveMinimum,
527
+ exclusive: target.exclusiveMinimum != null
528
+ }
529
+ : null,
530
+ candidate
531
+ );
532
+ target.minimum = nextBound?.exclusive === true ? null : nextBound?.value ?? null;
533
+ target.exclusiveMinimum = nextBound?.exclusive === true ? nextBound?.value ?? null : null;
534
+ continue;
535
+ }
536
+
537
+ if (operator === "<=" || operator === "<") {
538
+ let candidate = null;
539
+ if (operator === "<=") {
540
+ candidate = {
541
+ value: normalizeNumericBoundValue(rawValue, column.numericScale),
542
+ exclusive: false
543
+ };
544
+ } else {
545
+ const exclusiveStep = resolveNumericExclusiveStep(column);
546
+ if (exclusiveStep != null) {
547
+ candidate = {
548
+ value: normalizeNumericBoundValue(rawValue - exclusiveStep, column.numericScale),
549
+ exclusive: false
550
+ };
551
+ } else {
552
+ candidate = {
553
+ value: normalizeNumericBoundValue(rawValue, column.numericScale),
554
+ exclusive: true
555
+ };
556
+ }
557
+ }
558
+
559
+ const nextBound = applyUpperBound(
560
+ target.maximum != null || target.exclusiveMaximum != null
561
+ ? {
562
+ value: target.maximum ?? target.exclusiveMaximum,
563
+ exclusive: target.exclusiveMaximum != null
564
+ }
565
+ : null,
566
+ candidate
567
+ );
568
+ target.maximum = nextBound?.exclusive === true ? null : nextBound?.value ?? null;
569
+ target.exclusiveMaximum = nextBound?.exclusive === true ? nextBound?.value ?? null : null;
570
+ }
571
+ }
572
+ NUMERIC_CHECK_CONSTRAINT_PATTERN.lastIndex = 0;
573
+ }
574
+
575
+ return byColumnName;
576
+ }
577
+
394
578
  function resolveScaffoldColumns(snapshot) {
395
579
  const idColumn = String(snapshot.idColumn || DEFAULT_ID_COLUMN);
396
580
  const sourceColumns = Array.isArray(snapshot.columns) ? snapshot.columns : [];
581
+ const numericBoundsByColumnName = resolveColumnNumericBounds(snapshot);
397
582
  const foreignKeyColumnNames = new Set(
398
583
  (Array.isArray(snapshot.foreignKeys) ? snapshot.foreignKeys : [])
399
584
  .flatMap((foreignKey) => Array.isArray(foreignKey?.columns) ? foreignKey.columns : [])
@@ -427,6 +612,7 @@ function resolveScaffoldColumns(snapshot) {
427
612
 
428
613
  return Object.freeze({
429
614
  ...column,
615
+ ...(numericBoundsByColumnName.get(column.name) || {}),
430
616
  key,
431
617
  isOwnerColumn,
432
618
  isIdColumn,
@@ -462,9 +648,19 @@ function renderPropertyAccess(sourceName, key) {
462
648
 
463
649
  function renderIntegerSchema(column) {
464
650
  const options = [];
465
- if (column.unsigned === true) {
651
+ if (Number.isFinite(column?.minimum)) {
652
+ options.push(`minimum: ${column.minimum}`);
653
+ } else if (Number.isFinite(column?.exclusiveMinimum)) {
654
+ options.push(`exclusiveMinimum: ${column.exclusiveMinimum}`);
655
+ } else if (column.unsigned === true) {
466
656
  options.push("minimum: 0");
467
657
  }
658
+ if (Number.isFinite(column?.maximum)) {
659
+ options.push(`maximum: ${column.maximum}`);
660
+ }
661
+ if (Number.isFinite(column?.exclusiveMaximum)) {
662
+ options.push(`exclusiveMaximum: ${column.exclusiveMaximum}`);
663
+ }
468
664
  if (options.length > 0) {
469
665
  return `Type.Integer({ ${options.join(", ")} })`;
470
666
  }
@@ -499,7 +695,22 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
499
695
  }
500
696
  schemaExpression = renderIntegerSchema(column);
501
697
  } else if (typeKind === "number") {
502
- schemaExpression = "Type.Number()";
698
+ const options = [];
699
+ if (Number.isFinite(column?.minimum)) {
700
+ options.push(`minimum: ${column.minimum}`);
701
+ }
702
+ if (Number.isFinite(column?.exclusiveMinimum)) {
703
+ options.push(`exclusiveMinimum: ${column.exclusiveMinimum}`);
704
+ }
705
+ if (Number.isFinite(column?.maximum)) {
706
+ options.push(`maximum: ${column.maximum}`);
707
+ }
708
+ if (Number.isFinite(column?.exclusiveMaximum)) {
709
+ options.push(`exclusiveMaximum: ${column.exclusiveMaximum}`);
710
+ }
711
+ schemaExpression = options.length > 0
712
+ ? `Type.Number({ ${options.join(", ")} })`
713
+ : "Type.Number()";
503
714
  } else if (typeKind === "boolean") {
504
715
  schemaExpression = "Type.Boolean()";
505
716
  } else if (typeKind === "datetime") {
@@ -520,13 +731,15 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
520
731
  return schemaExpression;
521
732
  }
522
733
 
523
- function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false, needsRecordIdSchemas = false } = {}) {
734
+ function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false, recordIdValidatorImports = [] } = {}) {
524
735
  const imports = [
525
736
  "normalizeObjectInput",
526
737
  "createCursorListValidator"
527
738
  ];
528
- if (needsRecordIdSchemas) {
529
- imports.push("recordIdSchema", "recordIdInputSchema", "nullableRecordIdSchema", "nullableRecordIdInputSchema");
739
+ for (const importName of Array.isArray(recordIdValidatorImports) ? recordIdValidatorImports : []) {
740
+ if (!imports.includes(importName)) {
741
+ imports.push(importName);
742
+ }
530
743
  }
531
744
  if (needsHtmlTimeSchemas) {
532
745
  imports.push("HTML_TIME_STRING_SCHEMA", "NULLABLE_HTML_TIME_STRING_SCHEMA");
@@ -534,6 +747,19 @@ function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false, needsRec
534
747
  return `import {\n ${imports.join(",\n ")}\n} from "@jskit-ai/kernel/shared/validators";`;
535
748
  }
536
749
 
750
+ function resolveRecordIdValidatorImports(...sources) {
751
+ const imports = ["recordIdSchema"];
752
+ const joinedSource = sources
753
+ .map((source) => String(source || ""))
754
+ .join("\n");
755
+ for (const importName of ["recordIdInputSchema", "nullableRecordIdSchema", "nullableRecordIdInputSchema"]) {
756
+ if (joinedSource.includes(importName)) {
757
+ imports.push(importName);
758
+ }
759
+ }
760
+ return imports;
761
+ }
762
+
537
763
  function renderInputNormalizer(column) {
538
764
  const typeKind = String(column.typeKind || "");
539
765
  const nullable = column?.nullable === true;
@@ -1621,6 +1847,7 @@ function buildReplacementsFromSnapshot({
1621
1847
  requiresNamedPermissions
1622
1848
  }),
1623
1849
  __JSKIT_CRUD_ROUTE_SURFACE_REQUIRES_WORKSPACE__: String(surfaceRequiresWorkspace === true),
1850
+ __JSKIT_CRUD_ROUTE_BASE__: JSON.stringify(surfaceRequiresWorkspace === true ? "/w/:workspaceSlug" : "/"),
1624
1851
  __JSKIT_CRUD_ROUTE_WORKSPACE_SUPPORT_IMPORTS__: renderRouteWorkspaceSupportImports({
1625
1852
  surfaceRequiresWorkspace
1626
1853
  }),
@@ -1656,7 +1883,14 @@ function buildReplacementsFromSnapshot({
1656
1883
  }),
1657
1884
  __JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__: renderResourceValidatorsImport({
1658
1885
  needsHtmlTimeSchemas,
1659
- needsRecordIdSchemas
1886
+ recordIdValidatorImports: resolveRecordIdValidatorImports(
1887
+ renderResourceSchemaPropertyLines(outputColumns, {
1888
+ forOutput: true
1889
+ }),
1890
+ renderResourceSchemaPropertyLines(writableColumns, {
1891
+ forOutput: false
1892
+ })
1893
+ )
1660
1894
  }),
1661
1895
  __JSKIT_CRUD_RESOURCE_DATABASE_RUNTIME_IMPORT__: renderResourceDatabaseRuntimeImport({
1662
1896
  needsToIsoString: needsDateTimeOutput || needsDate,
@@ -30,7 +30,7 @@ function registerRoutes(
30
30
  const router = app.make("jskit.http.router");
31
31
  const normalizedRouteSurface = normalizeSurfaceId(routeSurface);
32
32
  const routeBase = resolveScopedApiBasePath({
33
- routeBase: __JSKIT_CRUD_ROUTE_SURFACE_REQUIRES_WORKSPACE__ ? "/w/:workspaceSlug" : "/",
33
+ routeBase: __JSKIT_CRUD_ROUTE_BASE__,
34
34
  relativePath: routeRelativePath,
35
35
  strictParams: false
36
36
  });
@@ -738,6 +738,118 @@ test("buildReplacementsFromSnapshot preserves custom collations, hash unique ind
738
738
  );
739
739
  });
740
740
 
741
+ test("resolveScaffoldColumns derives resource numeric bounds from check constraints", () => {
742
+ const snapshot = createSnapshot({
743
+ tableName: "batch_receivals",
744
+ hasWorkspaceIdColumn: false,
745
+ hasUserIdColumn: false
746
+ });
747
+
748
+ const inputWeightColumn = Object.freeze({
749
+ name: "input_weight",
750
+ key: "inputWeight",
751
+ dataType: "decimal",
752
+ columnType: "decimal(10,3)",
753
+ typeKind: "number",
754
+ nullable: false,
755
+ hasDefault: false,
756
+ defaultValue: null,
757
+ autoIncrement: false,
758
+ unsigned: false,
759
+ extra: "",
760
+ maxLength: null,
761
+ numericPrecision: 10,
762
+ numericScale: 3,
763
+ datetimePrecision: null,
764
+ characterSetName: "",
765
+ collationName: "",
766
+ enumValues: Object.freeze([])
767
+ });
768
+
769
+ const batchedDailySequenceColumn = Object.freeze({
770
+ name: "batched_daily_sequence",
771
+ key: "batchedDailySequence",
772
+ dataType: "int",
773
+ columnType: "int unsigned",
774
+ typeKind: "integer",
775
+ nullable: false,
776
+ hasDefault: false,
777
+ defaultValue: null,
778
+ autoIncrement: false,
779
+ unsigned: true,
780
+ extra: "",
781
+ maxLength: null,
782
+ numericPrecision: 10,
783
+ numericScale: 0,
784
+ datetimePrecision: null,
785
+ characterSetName: "",
786
+ collationName: "",
787
+ enumValues: Object.freeze([])
788
+ });
789
+
790
+ const moistureLevelColumn = Object.freeze({
791
+ name: "moisture_level",
792
+ key: "moistureLevel",
793
+ dataType: "decimal",
794
+ columnType: "decimal(5,2)",
795
+ typeKind: "number",
796
+ nullable: true,
797
+ hasDefault: false,
798
+ defaultValue: null,
799
+ autoIncrement: false,
800
+ unsigned: false,
801
+ extra: "",
802
+ maxLength: null,
803
+ numericPrecision: 5,
804
+ numericScale: 2,
805
+ datetimePrecision: null,
806
+ characterSetName: "",
807
+ collationName: "",
808
+ enumValues: Object.freeze([])
809
+ });
810
+
811
+ const scaffoldColumns = __testables.resolveScaffoldColumns({
812
+ ...snapshot,
813
+ columns: Object.freeze([
814
+ snapshot.columns[0],
815
+ inputWeightColumn,
816
+ batchedDailySequenceColumn,
817
+ moistureLevelColumn
818
+ ]),
819
+ checkConstraints: Object.freeze([
820
+ Object.freeze({
821
+ name: "chk_batch_receivals_input_weight",
822
+ clause: "`input_weight` > 0"
823
+ }),
824
+ Object.freeze({
825
+ name: "chk_batches_batched_daily_sequence",
826
+ clause: "`batched_daily_sequence` >= 1"
827
+ }),
828
+ Object.freeze({
829
+ name: "chk_batches_moisture_level",
830
+ clause: "`moisture_level` is null or `moisture_level` >= 0 and `moisture_level` <= 100"
831
+ })
832
+ ])
833
+ });
834
+
835
+ const inputWeight = scaffoldColumns.find((column) => column.name === "input_weight");
836
+ const batchedDailySequence = scaffoldColumns.find((column) => column.name === "batched_daily_sequence");
837
+ const moistureLevel = scaffoldColumns.find((column) => column.name === "moisture_level");
838
+
839
+ assert.equal(
840
+ __testables.renderResourceFieldSchema(inputWeight),
841
+ "Type.Number({ minimum: 0.001 })"
842
+ );
843
+ assert.equal(
844
+ __testables.renderResourceFieldSchema(batchedDailySequence),
845
+ "Type.Integer({ minimum: 1 })"
846
+ );
847
+ assert.equal(
848
+ __testables.renderResourceFieldSchema(moistureLevel),
849
+ "Type.Union([Type.Number({ minimum: 0, maximum: 100 }), Type.Null()])"
850
+ );
851
+ });
852
+
741
853
  test("buildReplacementsFromSnapshot normalizes nullable temporal inputs without invalid date errors", () => {
742
854
  const snapshot = createSnapshot({
743
855
  hasWorkspaceIdColumn: false,
@@ -960,6 +1072,40 @@ test("buildReplacementsFromSnapshot uses shared framework time schemas in genera
960
1072
  );
961
1073
  });
962
1074
 
1075
+ test("buildReplacementsFromSnapshot only imports record-id validator helpers that the resource actually uses", () => {
1076
+ const snapshot = createSnapshot({
1077
+ tableName: "pollen_types",
1078
+ columns: [
1079
+ {
1080
+ name: "id",
1081
+ dataType: "bigint",
1082
+ columnType: "bigint unsigned",
1083
+ nullable: false,
1084
+ key: "id"
1085
+ },
1086
+ {
1087
+ name: "name",
1088
+ dataType: "varchar",
1089
+ columnType: "varchar(32)",
1090
+ nullable: false,
1091
+ maxLength: 32,
1092
+ key: "name"
1093
+ }
1094
+ ]
1095
+ });
1096
+
1097
+ const replacements = __testables.buildReplacementsFromSnapshot({
1098
+ namespace: "pollen-types",
1099
+ snapshot,
1100
+ resolvedOwnershipFilter: "public"
1101
+ });
1102
+
1103
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__, /recordIdSchema/);
1104
+ assert.doesNotMatch(replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__, /recordIdInputSchema/);
1105
+ assert.doesNotMatch(replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__, /nullableRecordIdSchema/);
1106
+ assert.doesNotMatch(replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__, /nullableRecordIdInputSchema/);
1107
+ });
1108
+
963
1109
  test("crud provider template uses shared lookup provider helpers instead of inline wiring", async () => {
964
1110
  const testDirectory = path.dirname(fileURLToPath(import.meta.url));
965
1111
  const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "CrudProvider.js");
@@ -89,6 +89,7 @@ function buildTemplateReplacements({
89
89
  ? "[workspaceSlugParamsValidator, recordIdParamsValidator]"
90
90
  : "recordIdParamsValidator"],
91
91
  ["__JSKIT_CRUD_ROUTE_SURFACE_REQUIRES_WORKSPACE__", String(surfaceRequiresWorkspace === true)],
92
+ ["__JSKIT_CRUD_ROUTE_BASE__", JSON.stringify(surfaceRequiresWorkspace ? "/w/:workspaceSlug" : "/")],
92
93
  ["__JSKIT_CRUD_ROUTE_WORKSPACE_SUPPORT_IMPORTS__", routeWorkspaceSupportImports],
93
94
  ["__JSKIT_CRUD_LIST_ROUTE_PARAMS_VALIDATOR_LINE__", surfaceRequiresWorkspace ? " paramsValidator: routeParamsValidator," : ""],
94
95
  ["__JSKIT_CRUD_VIEW_ROUTE_PARAMS_VALIDATOR_LINE__", surfaceRequiresWorkspace ? " paramsValidator: [routeParamsValidator, recordIdParamsValidator]," : " paramsValidator: recordIdParamsValidator,"],