@solidxai/core 0.1.11-beta.8 → 0.1.11

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +456 -0
  2. package/dist/controllers/datasource-introspection.controller.d.ts +8 -0
  3. package/dist/controllers/datasource-introspection.controller.d.ts.map +1 -1
  4. package/dist/controllers/media.controller.js +0 -2
  5. package/dist/controllers/media.controller.js.map +1 -1
  6. package/dist/helpers/module-metadata-helper.service.js +1 -1
  7. package/dist/helpers/module-metadata-helper.service.js.map +1 -1
  8. package/dist/helpers/solid-registry.d.ts +1 -2
  9. package/dist/helpers/solid-registry.d.ts.map +1 -1
  10. package/dist/helpers/solid-registry.js.map +1 -1
  11. package/dist/helpers/typeorm-db-helper.d.ts +1 -0
  12. package/dist/helpers/typeorm-db-helper.d.ts.map +1 -1
  13. package/dist/helpers/typeorm-db-helper.js +12 -0
  14. package/dist/helpers/typeorm-db-helper.js.map +1 -1
  15. package/dist/index.d.ts +0 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +0 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/interfaces.d.ts +1 -0
  20. package/dist/interfaces.d.ts.map +1 -1
  21. package/dist/interfaces.js.map +1 -1
  22. package/dist/seeders/seed-data/solid-core-metadata.json +62 -33
  23. package/dist/services/authentication.service.d.ts +2 -0
  24. package/dist/services/authentication.service.d.ts.map +1 -1
  25. package/dist/services/authentication.service.js +32 -11
  26. package/dist/services/authentication.service.js.map +1 -1
  27. package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.d.ts.map +1 -1
  28. package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.js +5 -6
  29. package/dist/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.js.map +1 -1
  30. package/dist/services/crud-helper.service.d.ts.map +1 -1
  31. package/dist/services/crud-helper.service.js +32 -12
  32. package/dist/services/crud-helper.service.js.map +1 -1
  33. package/dist/services/datasource-introspection.service.d.ts +10 -0
  34. package/dist/services/datasource-introspection.service.d.ts.map +1 -1
  35. package/dist/services/datasource-introspection.service.js +109 -17
  36. package/dist/services/datasource-introspection.service.js.map +1 -1
  37. package/dist/services/menu-item-metadata.service.d.ts.map +1 -1
  38. package/dist/services/menu-item-metadata.service.js +1 -0
  39. package/dist/services/menu-item-metadata.service.js.map +1 -1
  40. package/dist/services/model-metadata.service.d.ts +2 -0
  41. package/dist/services/model-metadata.service.d.ts.map +1 -1
  42. package/dist/services/model-metadata.service.js +49 -15
  43. package/dist/services/model-metadata.service.js.map +1 -1
  44. package/dist/services/module-metadata.service.d.ts.map +1 -1
  45. package/dist/services/module-metadata.service.js +0 -27
  46. package/dist/services/module-metadata.service.js.map +1 -1
  47. package/dist/services/settings/default-settings-provider.service.d.ts +212 -0
  48. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  49. package/dist/services/settings/default-settings-provider.service.js +106 -0
  50. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  51. package/dist/services/solid-ts-morph.service.d.ts +6 -0
  52. package/dist/services/solid-ts-morph.service.d.ts.map +1 -1
  53. package/dist/services/solid-ts-morph.service.js +43 -0
  54. package/dist/services/solid-ts-morph.service.js.map +1 -1
  55. package/dist/subscribers/audit.subscriber.d.ts +0 -1
  56. package/dist/subscribers/audit.subscriber.d.ts.map +1 -1
  57. package/dist/subscribers/audit.subscriber.js +1 -10
  58. package/dist/subscribers/audit.subscriber.js.map +1 -1
  59. package/dist/subscribers/computed-entity-field.subscriber.d.ts +1 -1
  60. package/dist/subscribers/computed-entity-field.subscriber.d.ts.map +1 -1
  61. package/dist/subscribers/computed-entity-field.subscriber.js +11 -13
  62. package/dist/subscribers/computed-entity-field.subscriber.js.map +1 -1
  63. package/dist/subscribers/created-by-updated-by.subscriber.d.ts.map +1 -1
  64. package/dist/subscribers/created-by-updated-by.subscriber.js.map +1 -1
  65. package/dist/subscribers/field-metadata.subscriber.d.ts.map +1 -1
  66. package/dist/subscribers/field-metadata.subscriber.js.map +1 -1
  67. package/dist/subscribers/scheduled-job.subscriber.js +1 -1
  68. package/dist/subscribers/scheduled-job.subscriber.js.map +1 -1
  69. package/dist/subscribers/security-rule.subscriber.js +1 -1
  70. package/dist/subscribers/security-rule.subscriber.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/controllers/media.controller.ts +1 -1
  73. package/src/helpers/module-metadata-helper.service.ts +1 -1
  74. package/src/helpers/solid-registry.ts +1 -7
  75. package/src/helpers/typeorm-db-helper.ts +15 -0
  76. package/src/index.ts +0 -1
  77. package/src/interfaces.ts +1 -0
  78. package/src/seeders/seed-data/solid-core-metadata.json +62 -33
  79. package/src/services/authentication.service.ts +46 -12
  80. package/src/services/computed-fields/entity/alpha-num-external-id-computed-field-provider.ts +5 -10
  81. package/src/services/crud-helper.service.ts +32 -12
  82. package/src/services/datasource-introspection.service.ts +176 -16
  83. package/src/services/menu-item-metadata.service.ts +2 -1
  84. package/src/services/model-metadata.service.ts +63 -21
  85. package/src/services/module-metadata.service.ts +0 -35
  86. package/src/services/settings/default-settings-provider.service.ts +106 -0
  87. package/src/services/solid-ts-morph.service.ts +61 -1
  88. package/src/subscribers/audit.subscriber.ts +1 -12
  89. package/src/subscribers/computed-entity-field.subscriber.ts +23 -29
  90. package/src/subscribers/created-by-updated-by.subscriber.ts +0 -3
  91. package/src/subscribers/field-metadata.subscriber.ts +0 -3
  92. package/src/subscribers/scheduled-job.subscriber.ts +1 -1
  93. package/src/subscribers/security-rule.subscriber.ts +1 -1
  94. package/dist/helpers/nodemon-heartbeat.d.ts +0 -5
  95. package/dist/helpers/nodemon-heartbeat.d.ts.map +0 -1
  96. package/dist/helpers/nodemon-heartbeat.js +0 -67
  97. package/dist/helpers/nodemon-heartbeat.js.map +0 -1
  98. package/src/helpers/nodemon-heartbeat.ts +0 -59
@@ -5,6 +5,7 @@ import { ActiveUserData } from "src/interfaces/active-user-data.interface";
5
5
  import { SolidRegistry } from "src/helpers/solid-registry";
6
6
  import { BadRequestException, Logger } from "@nestjs/common";
7
7
  import { ERROR_MESSAGES } from "src/constants/error-messages";
8
+ import { buildCastToText } from "src/helpers/typeorm-db-helper";
8
9
 
9
10
  export enum FilterCombinator {
10
11
  AND = '$and',
@@ -101,15 +102,19 @@ export class CrudHelperService {
101
102
  case '$eq':
102
103
  qb.andWhere(`${colExpr} = :${uniqueFieldAlias}`, { [uniqueFieldAlias]: normalizedPrimaryOperatorObj.$eq });
103
104
  break;
104
- case '$eqi':
105
- qb.andWhere(`LOWER(${colExpr}) = :${uniqueFieldAlias}`, { [uniqueFieldAlias]: normalizedPrimaryOperatorObj.$eqi.toLowerCase() });
105
+ case '$eqi': {
106
+ const castExpr = buildCastToText(this.getDriver(qb), colExpr);
107
+ qb.andWhere(`LOWER(${castExpr}) = :${uniqueFieldAlias}`, { [uniqueFieldAlias]: String(normalizedPrimaryOperatorObj.$eqi).toLowerCase() });
106
108
  break;
109
+ }
107
110
  case '$ne':
108
111
  qb.andWhere(`${colExpr} != :${uniqueFieldAlias}`, { [uniqueFieldAlias]: normalizedPrimaryOperatorObj.$ne });
109
112
  break;
110
- case '$nei':
111
- qb.andWhere(`LOWER(${colExpr}) != :${uniqueFieldAlias}`, { [uniqueFieldAlias]: normalizedPrimaryOperatorObj.$nei.toLowerCase() });
113
+ case '$nei': {
114
+ const castExpr = buildCastToText(this.getDriver(qb), colExpr);
115
+ qb.andWhere(`LOWER(${castExpr}) != :${uniqueFieldAlias}`, { [uniqueFieldAlias]: String(normalizedPrimaryOperatorObj.$nei).toLowerCase() });
112
116
  break;
117
+ }
113
118
  case '$gt':
114
119
  qb.andWhere(`${colExpr} > :${uniqueFieldAlias}`, { [uniqueFieldAlias]: normalizedPrimaryOperatorObj.$gt });
115
120
  break;
@@ -134,12 +139,16 @@ export class CrudHelperService {
134
139
  case '$notContains':
135
140
  qb.andWhere(`${colExpr} NOT LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${normalizedPrimaryOperatorObj.$notContains}%` });
136
141
  break;
137
- case '$containsi':
138
- qb.andWhere(`LOWER(${colExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${normalizedPrimaryOperatorObj.$containsi.toLowerCase()}%` });
142
+ case '$containsi': {
143
+ const castExpr = buildCastToText(this.getDriver(qb), colExpr);
144
+ qb.andWhere(`LOWER(${castExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${String(normalizedPrimaryOperatorObj.$containsi).toLowerCase()}%` });
139
145
  break;
140
- case '$notContainsi':
141
- qb.andWhere(`LOWER(${colExpr}) NOT LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${normalizedPrimaryOperatorObj.$notContainsi.toLowerCase()}%` });
146
+ }
147
+ case '$notContainsi': {
148
+ const castExpr = buildCastToText(this.getDriver(qb), colExpr);
149
+ qb.andWhere(`LOWER(${castExpr}) NOT LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${String(normalizedPrimaryOperatorObj.$notContainsi).toLowerCase()}%` });
142
150
  break;
151
+ }
143
152
  case '$null':
144
153
  qb.andWhere(`${colExpr} IS NULL`);
145
154
  break;
@@ -152,15 +161,19 @@ export class CrudHelperService {
152
161
  case '$startsWith':
153
162
  qb.andWhere(`${colExpr} LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `${normalizedPrimaryOperatorObj.$startsWith}%` });
154
163
  break;
155
- case '$startsWithi':
156
- qb.andWhere(`LOWER(${colExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `${normalizedPrimaryOperatorObj.$startsWithi.toLowerCase()}%` });
164
+ case '$startsWithi': {
165
+ const castExpr = buildCastToText(this.getDriver(qb), colExpr);
166
+ qb.andWhere(`LOWER(${castExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `${String(normalizedPrimaryOperatorObj.$startsWithi).toLowerCase()}%` });
157
167
  break;
168
+ }
158
169
  case '$endsWith':
159
170
  qb.andWhere(`${colExpr} LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${normalizedPrimaryOperatorObj.$endsWith}` });
160
171
  break;
161
- case '$endsWithi':
162
- qb.andWhere(`LOWER(${colExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${normalizedPrimaryOperatorObj.$endsWithi.toLowerCase()}` });
172
+ case '$endsWithi': {
173
+ const castExpr = buildCastToText(this.getDriver(qb), colExpr);
174
+ qb.andWhere(`LOWER(${castExpr}) LIKE :${uniqueFieldAlias}`, { [uniqueFieldAlias]: `%${String(normalizedPrimaryOperatorObj.$endsWithi).toLowerCase()}` });
163
175
  break;
176
+ }
164
177
  default:
165
178
  throw new Error(`Operator ${operator} is not supported`);
166
179
  }
@@ -280,9 +293,13 @@ export class CrudHelperService {
280
293
  const orderOptions = this.orderOptions(normalizedSort);
281
294
  if (orderOptions) {
282
295
  const orderOptionKeys = Object.keys(orderOptions) as Array<keyof typeof orderOptions>;
296
+ let hasExplicitIdSort = false;
283
297
  orderOptionKeys.forEach((key) => {
284
298
  const value = orderOptions[key] as 'ASC' | 'DESC';
285
299
  const field = String(key);
300
+ if (field === 'id') {
301
+ hasExplicitIdSort = true;
302
+ }
286
303
  if (field.includes('.')) {
287
304
  const { alias, property, created } = this.ensureRelationPathJoined(qb, entityAlias, field.split('.'));
288
305
  const orderColumn = `${alias}.${property}`;
@@ -292,6 +309,9 @@ export class CrudHelperService {
292
309
  qb.addOrderBy(`${entityAlias}.${field}`, value);
293
310
  }
294
311
  });
312
+ if (!hasExplicitIdSort) {
313
+ qb.addOrderBy(`${entityAlias}.id`, 'DESC');
314
+ }
295
315
  }
296
316
  }
297
317
 
@@ -43,6 +43,10 @@ type TableInventoryRecord = {
43
43
  suggestedUserKeyField: string;
44
44
  columnCount: number;
45
45
  physicalColumnCount: number;
46
+ hasPrimaryKey: boolean;
47
+ primaryKeyColumnCount: number;
48
+ primaryKeyColumnNames: string[];
49
+ mappingBlockedReason: string | null;
46
50
  };
47
51
 
48
52
  type TableDetailColumnRecord = {
@@ -401,10 +405,7 @@ export class DatasourceIntrospectionService {
401
405
  throw new BadRequestException("Persist the model mapping first so the generated entity can be registered before creating migration artifacts.");
402
406
  }
403
407
 
404
- if (plan.migration.willGenerate && plan.migration.content && plan.migration.filePath) {
405
- await fs.mkdir(path.dirname(plan.migration.filePath), { recursive: true });
406
- await fs.writeFile(plan.migration.filePath, plan.migration.content, "utf8");
407
- }
408
+ const migrationWritten = await this.writeMigrationFile(plan.migration);
408
409
 
409
410
  const datasourceFile = await this.syncTypeormDatasourceFile(
410
411
  module.name,
@@ -418,7 +419,7 @@ export class DatasourceIntrospectionService {
418
419
  operation: plan.operation,
419
420
  migration: {
420
421
  ...plan.migration,
421
- written: Boolean(plan.migration.willGenerate && plan.migration.filePath),
422
+ written: migrationWritten,
422
423
  },
423
424
  datasourceFile,
424
425
  summary: plan.summary,
@@ -504,6 +505,8 @@ export class DatasourceIntrospectionService {
504
505
  if (!tableRecord) {
505
506
  throw new NotFoundException(`Table "${mappingDto.tableName}" was not found on datasource "${datasource.name}".`);
506
507
  }
508
+ const tableColumns = inventory.columnsByKey.get(tableKey) ?? [];
509
+ this.validateTablePrimaryKeyForMapping(tableRecord, tableColumns, mappingDto);
507
510
 
508
511
  const existingModel = mappingDto.mapped && mappingDto.modelId
509
512
  ? await this.modelMetadataRepository.findOne({
@@ -521,14 +524,14 @@ export class DatasourceIntrospectionService {
521
524
  throw new NotFoundException(`Mapped model "${mappingDto.modelId}" was not found.`);
522
525
  }
523
526
 
524
- const reviewedModel = this.normalizeReviewedMetadataModel(mappingDto.reviewedModel, mappingDto);
527
+ const reviewedModel = this.normalizeReviewedMetadataModel(mappingDto.reviewedModel, mappingDto, tableColumns);
525
528
  const modelPayload = reviewedModel
526
529
  ? this.buildModelPayloadFromReviewedModel(moduleId, mappingDto, existingModel, reviewedModel)
527
530
  : this.buildModelPayload(moduleId, mappingDto, existingModel);
528
531
  const metadataJson = reviewedModel
529
532
  ? await this.buildMetadataJsonPreviewFromReviewedModel(module.name, reviewedModel)
530
533
  : await this.buildMetadataJsonPreview(module.name, mappingDto, existingModel);
531
- const migration = await this.buildMigrationPreview(module.name, datasource, mappingDto, inventory.columnsByKey.get(tableKey) ?? []);
534
+ const migration = await this.buildMigrationPreview(module.name, datasource, mappingDto, tableColumns);
532
535
  const includedColumnNames = mappingDto.columns
533
536
  .filter((column) => !column.handledBySuperclass && column.include)
534
537
  .map((column) => column.columnName);
@@ -609,6 +612,11 @@ export class DatasourceIntrospectionService {
609
612
  existingModel: ModelMetadata | null,
610
613
  reviewedModel: ReviewedMetadataModel,
611
614
  ): CreateModelMetadataDto | UpdateModelMetaDataDto {
615
+ const resolvedUserKeyFieldName = this.resolveReviewedUserKeyFieldName(
616
+ reviewedModel.fields,
617
+ reviewedModel.userKeyFieldUserKey,
618
+ mappingDto.userKeyField,
619
+ );
612
620
  const systemFieldPayloads = existingModel
613
621
  ? existingModel.fields
614
622
  .filter((field) => field.isSystem)
@@ -620,7 +628,7 @@ export class DatasourceIntrospectionService {
620
628
  return {
621
629
  ...field,
622
630
  ...(matchedExistingField?.id ? { id: matchedExistingField.id } : {}),
623
- isUserKey: field.name === reviewedModel.userKeyFieldUserKey,
631
+ isUserKey: field.name === resolvedUserKeyFieldName,
624
632
  isSystem: field.isSystem ?? false,
625
633
  };
626
634
  });
@@ -654,6 +662,11 @@ export class DatasourceIntrospectionService {
654
662
  ) {
655
663
  const filePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName);
656
664
  const previewFields = await this.buildMetadataPreviewFields(mappingDto, existingModel);
665
+ const resolvedUserKeyFieldName = this.resolveReviewedUserKeyFieldName(
666
+ previewFields,
667
+ mappingDto.userKeyField,
668
+ mappingDto.userKeyField,
669
+ ) ?? mappingDto.userKeyField;
657
670
 
658
671
  return {
659
672
  filePath,
@@ -665,7 +678,7 @@ export class DatasourceIntrospectionService {
665
678
  dataSource: mappingDto.dataSource,
666
679
  dataSourceType: mappingDto.dataSourceType,
667
680
  tableName: mappingDto.tableName,
668
- userKeyFieldUserKey: mappingDto.userKeyField,
681
+ userKeyFieldUserKey: resolvedUserKeyFieldName,
669
682
  isChild: false,
670
683
  legacyTableType: mappingDto.legacyTableType,
671
684
  parentModelUserKey: "",
@@ -733,10 +746,6 @@ export class DatasourceIntrospectionService {
733
746
 
734
747
  const serialized = Array.from(finalFields.values())
735
748
  .filter((field) => !field.isSystem)
736
- .map((field) => ({
737
- ...field,
738
- isUserKey: field.name === mappingDto.userKeyField,
739
- }))
740
749
  .sort((left, right) => {
741
750
  const leftOrder = typeof left.id === "number" ? left.id : Number.MAX_SAFE_INTEGER;
742
751
  const rightOrder = typeof right.id === "number" ? right.id : Number.MAX_SAFE_INTEGER;
@@ -752,6 +761,16 @@ export class DatasourceIntrospectionService {
752
761
  previewFields.push(await this.fieldMetadataService.createFieldConfig(field as any));
753
762
  }
754
763
 
764
+ const resolvedUserKeyFieldName = this.resolveReviewedUserKeyFieldName(
765
+ previewFields,
766
+ mappingDto.userKeyField,
767
+ mappingDto.userKeyField,
768
+ );
769
+
770
+ for (const field of previewFields) {
771
+ field.isUserKey = field.name === resolvedUserKeyFieldName;
772
+ }
773
+
755
774
  return previewFields;
756
775
  }
757
776
 
@@ -829,6 +848,7 @@ export class DatasourceIntrospectionService {
829
848
  private normalizeReviewedMetadataModel(
830
849
  reviewedModel: Record<string, any> | undefined,
831
850
  mappingDto: DatasourceIntrospectionMappingDto,
851
+ tableColumns: DatasourceIntrospectionColumn[],
832
852
  ): ReviewedMetadataModel | null {
833
853
  if (!reviewedModel) {
834
854
  return null;
@@ -888,11 +908,24 @@ export class DatasourceIntrospectionService {
888
908
  fieldNames.add(loweredName);
889
909
  }
890
910
 
891
- const userKeyFieldUserKey = readString("userKeyFieldUserKey");
892
- if (!normalizedFields.some((field) => field.name === userKeyFieldUserKey && !field.isMarkedForRemoval)) {
911
+ const requestedUserKeyFieldUserKey = readString("userKeyFieldUserKey");
912
+ const resolvedUserKeyFieldName = this.resolveReviewedUserKeyFieldName(
913
+ normalizedFields,
914
+ requestedUserKeyFieldUserKey,
915
+ mappingDto.userKeyField,
916
+ );
917
+ const inheritedGeneratedIdUserKey = this.isGeneratedIdInheritedUserKey(
918
+ mappingDto.legacyTableType,
919
+ requestedUserKeyFieldUserKey,
920
+ );
921
+ if (!resolvedUserKeyFieldName && !inheritedGeneratedIdUserKey) {
893
922
  throw new BadRequestException("Reviewed metadata JSON must keep the selected userKeyFieldUserKey in the fields array.");
894
923
  }
895
924
 
925
+ this.validateReviewedPrimaryKeyFields(normalizedFields, tableColumns);
926
+
927
+ const userKeyFieldUserKey = resolvedUserKeyFieldName ?? requestedUserKeyFieldUserKey;
928
+
896
929
  return {
897
930
  singularName: readString("singularName"),
898
931
  pluralName: readString("pluralName"),
@@ -911,11 +944,54 @@ export class DatasourceIntrospectionService {
911
944
  internationalisation: readBoolean("internationalisation"),
912
945
  fields: normalizedFields.map((field) => ({
913
946
  ...field,
914
- isUserKey: field.name === userKeyFieldUserKey,
947
+ isUserKey: field.name === resolvedUserKeyFieldName,
915
948
  })),
916
949
  };
917
950
  }
918
951
 
952
+ private resolveReviewedUserKeyFieldName(
953
+ reviewedFields: Array<Record<string, any>>,
954
+ requestedUserKeyFieldName: string,
955
+ fallbackUserKeyFieldName?: string,
956
+ ) {
957
+ const activeFields = reviewedFields.filter((field) => !field.isMarkedForRemoval);
958
+ const normalizedRequested = `${requestedUserKeyFieldName ?? ""}`.trim().toLowerCase();
959
+ const normalizedFallback = `${fallbackUserKeyFieldName ?? ""}`.trim().toLowerCase();
960
+
961
+ const byExactRequested = activeFields.find((field) => field.name?.toLowerCase?.() === normalizedRequested);
962
+ if (byExactRequested) {
963
+ return byExactRequested.name;
964
+ }
965
+
966
+ const byExistingUserKeyFlag = activeFields.find((field) => field.isUserKey);
967
+ if (byExistingUserKeyFlag) {
968
+ return byExistingUserKeyFlag.name;
969
+ }
970
+
971
+ const byPrimaryKeyFallback = activeFields.find((field) => {
972
+ if (!field.isPrimaryKey) {
973
+ return false;
974
+ }
975
+
976
+ const normalizedName = `${field.name ?? ""}`.trim().toLowerCase();
977
+ return normalizedName === normalizedFallback || normalizedName === "id" || normalizedName === "legacyid";
978
+ });
979
+ if (byPrimaryKeyFallback) {
980
+ return byPrimaryKeyFallback.name;
981
+ }
982
+
983
+ const byFallbackFieldName = activeFields.find((field) => field.name?.toLowerCase?.() === normalizedFallback);
984
+ return byFallbackFieldName?.name ?? null;
985
+ }
986
+
987
+ private isGeneratedIdInheritedUserKey(
988
+ legacyTableType: LegacyTableType,
989
+ requestedUserKeyFieldName: string | null | undefined,
990
+ ) {
991
+ return legacyTableType === LegacyTableType.GENERATED_ID
992
+ && `${requestedUserKeyFieldName ?? ""}`.trim().toLowerCase() === "id";
993
+ }
994
+
919
995
  private normalizeReviewedField(field: any, index: number) {
920
996
  if (!field || typeof field !== "object" || Array.isArray(field)) {
921
997
  throw new BadRequestException(`Reviewed metadata JSON field at index ${index} is not a valid object.`);
@@ -1309,6 +1385,8 @@ export class DatasourceIntrospectionService {
1309
1385
  const suggestedIdColumn = this.getSuggestedIdColumn(datasource.type, tableColumns, suggestedLegacyTableType);
1310
1386
  const mappedModel = this.resolveMappedModel(modelLookup, datasource.name, tableName);
1311
1387
  const physicalColumns = tableColumns.filter((column) => !this.isHandledBySuperclass(column.columnName, suggestedLegacyTableType));
1388
+ const primaryKeyColumns = tableColumns.filter((column) => column.isPrimaryKey);
1389
+ const hasPrimaryKey = primaryKeyColumns.length > 0;
1312
1390
 
1313
1391
  return {
1314
1392
  schema,
@@ -1326,6 +1404,12 @@ export class DatasourceIntrospectionService {
1326
1404
  suggestedUserKeyField: this.buildUserKeyCandidates(tableColumns)[0]?.name ?? "id",
1327
1405
  columnCount: tableColumns.length,
1328
1406
  physicalColumnCount: physicalColumns.length,
1407
+ hasPrimaryKey,
1408
+ primaryKeyColumnCount: primaryKeyColumns.length,
1409
+ primaryKeyColumnNames: primaryKeyColumns.map((column) => column.columnName),
1410
+ mappingBlockedReason: hasPrimaryKey
1411
+ ? null
1412
+ : this.buildMissingPrimaryKeyMessage(tableName),
1329
1413
  } satisfies TableInventoryRecord;
1330
1414
  })
1331
1415
  .filter((record): record is TableInventoryRecord => Boolean(record))
@@ -1530,6 +1614,66 @@ export class DatasourceIntrospectionService {
1530
1614
  };
1531
1615
  }
1532
1616
 
1617
+ private validateTablePrimaryKeyForMapping(
1618
+ tableRecord: TableInventoryRecord,
1619
+ tableColumns: DatasourceIntrospectionColumn[],
1620
+ mappingDto: DatasourceIntrospectionMappingDto,
1621
+ ) {
1622
+ const detectedPrimaryKeyColumns = tableColumns.filter((column) => column.isPrimaryKey);
1623
+ if (!detectedPrimaryKeyColumns.length) {
1624
+ throw new BadRequestException(tableRecord.mappingBlockedReason ?? this.buildMissingPrimaryKeyMessage(tableRecord.tableName));
1625
+ }
1626
+
1627
+ const requestColumnsByName = new Map(
1628
+ mappingDto.columns.map((column) => [column.columnName.trim().toLowerCase(), column]),
1629
+ );
1630
+
1631
+ for (const primaryKeyColumn of detectedPrimaryKeyColumns) {
1632
+ const matchingRequestColumn = requestColumnsByName.get(primaryKeyColumn.columnName.trim().toLowerCase());
1633
+ if (!matchingRequestColumn) {
1634
+ throw new BadRequestException(`Primary key column "${primaryKeyColumn.columnName}" must remain present in the mapping request for table "${tableRecord.tableName}".`);
1635
+ }
1636
+
1637
+ if (!matchingRequestColumn.include) {
1638
+ throw new BadRequestException(`Primary key column "${primaryKeyColumn.columnName}" cannot be excluded from the mapping for table "${tableRecord.tableName}".`);
1639
+ }
1640
+
1641
+ if (!matchingRequestColumn.isPrimaryKey) {
1642
+ throw new BadRequestException(`Primary key column "${primaryKeyColumn.columnName}" must stay marked as isPrimaryKey for table "${tableRecord.tableName}".`);
1643
+ }
1644
+ }
1645
+ }
1646
+
1647
+ private validateReviewedPrimaryKeyFields(
1648
+ reviewedFields: Array<Record<string, any>>,
1649
+ tableColumns: DatasourceIntrospectionColumn[],
1650
+ ) {
1651
+ const detectedPrimaryKeyColumns = tableColumns.filter((column) => column.isPrimaryKey);
1652
+ if (!detectedPrimaryKeyColumns.length) {
1653
+ return;
1654
+ }
1655
+
1656
+ const activeReviewedFields = reviewedFields.filter((field) => !field.isMarkedForRemoval);
1657
+
1658
+ for (const primaryKeyColumn of detectedPrimaryKeyColumns) {
1659
+ const matchingReviewedField = activeReviewedFields.find((field) =>
1660
+ `${field.columnName ?? ""}`.trim().toLowerCase() === primaryKeyColumn.columnName.trim().toLowerCase(),
1661
+ );
1662
+
1663
+ if (!matchingReviewedField) {
1664
+ throw new BadRequestException(`Reviewed metadata JSON must keep datasource primary key column "${primaryKeyColumn.columnName}" in the fields array.`);
1665
+ }
1666
+
1667
+ if (!matchingReviewedField.isPrimaryKey) {
1668
+ throw new BadRequestException(`Reviewed metadata JSON must keep datasource primary key column "${primaryKeyColumn.columnName}" marked with isPrimaryKey: true.`);
1669
+ }
1670
+ }
1671
+ }
1672
+
1673
+ private buildMissingPrimaryKeyMessage(tableName: string) {
1674
+ return `Table "${tableName}" cannot be mapped because the datasource table does not define any primary key. Add a real primary key to the table before continuing with SolidX mapping.`;
1675
+ }
1676
+
1533
1677
  private getSuperclassFieldName(columnName: string, legacyTableType: LegacyTableType) {
1534
1678
  return this.getLegacySuperclassFieldName(columnName, legacyTableType);
1535
1679
  }
@@ -1771,6 +1915,22 @@ export class DatasourceIntrospectionService {
1771
1915
  };
1772
1916
  }
1773
1917
 
1918
+ private async writeMigrationFile(migration: MappingPreviewRecord["migration"]) {
1919
+ if (!migration.willGenerate || !migration.content || !migration.filePath) {
1920
+ return false;
1921
+ }
1922
+
1923
+ await fs.mkdir(path.dirname(migration.filePath), { recursive: true });
1924
+ await fs.writeFile(migration.filePath, migration.content, "utf8");
1925
+
1926
+ const writtenContent = await fs.readFile(migration.filePath, "utf8").catch(() => null);
1927
+ if (writtenContent !== migration.content) {
1928
+ throw new BadRequestException(`Migration file could not be verified after writing: ${migration.filePath}`);
1929
+ }
1930
+
1931
+ return true;
1932
+ }
1933
+
1774
1934
  private renderTypeormDatasourceTemplate(input: {
1775
1935
  datasource: DatasourceRecord;
1776
1936
  entityClassName: string;
@@ -154,7 +154,7 @@ export class MenuItemMetadataService extends CRUDService<MenuItemMetadata> {
154
154
  if (!i.parentMenuItem) {
155
155
  return true
156
156
  } else {
157
- if(i.action.type === 'custom'){
157
+ if (i.action.type === 'custom') {
158
158
  return true;
159
159
  }
160
160
  return this.crudHelperService.hasReadPermissionOnModel(activeUser, i.action.model.singularName)
@@ -203,6 +203,7 @@ export class MenuItemMetadataService extends CRUDService<MenuItemMetadata> {
203
203
  key: rootItem.name.toLowerCase().replace(/\s+/g, '-'),
204
204
  icon: rootItem.iconName,
205
205
  // iconVariant : rootItem.iconVariant
206
+ isSystem: (rootItem.action?.model?.isSystem ?? false) || rootItem.module?.name === "solid-core",
206
207
  }
207
208
  if (children.length > 0) {
208
209
  data["children"] = await this.buildMenuTree(children, allMenuItems, activeUser);
@@ -1,6 +1,7 @@
1
1
  import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
2
2
  import { InjectDataSource } from '@nestjs/typeorm';
3
3
  import * as fs from 'fs/promises'; // Use the Promise-based version of fs for async/await
4
+ import { existsSync } from 'fs';
4
5
  import * as path from 'path';
5
6
  import { DataSource, EntityManager, In, Repository, SelectQueryBuilder } from 'typeorm';
6
7
  import { CreateModelMetadataDto } from '../dtos/create-model-metadata.dto';
@@ -27,7 +28,6 @@ import { PermissionMetadata } from '../entities/permission-metadata.entity';
27
28
  import { RoleMetadata } from '../entities/role-metadata.entity';
28
29
  import { ViewMetadata } from '../entities/view-metadata.entity';
29
30
  import { CommandService } from '../helpers/command.service';
30
- import { startNodemonHeartbeat } from '../helpers/nodemon-heartbeat';
31
31
  import {
32
32
  REFRESH_MODEL_COMMAND,
33
33
  REMOVE_FIELDS_COMMAND,
@@ -744,10 +744,72 @@ export class ModelMetadataService {
744
744
  }
745
745
  }
746
746
 
747
+ await this.cleanupAssociatedTypeormDatasourceFiles(modelEntity, modulePath);
748
+
747
749
  // - | Drop database table | Removes the database table from the DB, this is a very risky step. Best to review all relations to other models etc and then do this manually | Manual (X)
748
750
 
749
751
  }
750
752
 
753
+ private resolveSolidApiRoot(): string | null {
754
+ const cwd = process.cwd();
755
+ const candidates = [
756
+ cwd,
757
+ path.join(cwd, 'solid-api'),
758
+ ];
759
+
760
+ for (const candidate of candidates) {
761
+ const srcRoot = path.join(candidate, 'src');
762
+ if (existsSync(srcRoot)) {
763
+ return candidate;
764
+ }
765
+ }
766
+
767
+ return null;
768
+ }
769
+
770
+ private async cleanupAssociatedTypeormDatasourceFiles(modelEntity: ModelMetadata, modulePath?: string | null) {
771
+ if (!modulePath) {
772
+ return;
773
+ }
774
+
775
+ const solidApiRoot = this.resolveSolidApiRoot();
776
+ if (!solidApiRoot) {
777
+ this.logger.warn(`Unable to locate consuming solid-api workspace while cleaning datasource files for model '${modelEntity.singularName}'`);
778
+ return;
779
+ }
780
+
781
+ const srcRoot = path.join(solidApiRoot, 'src');
782
+ const srcEntries = await fs.readdir(srcRoot).catch(() => []);
783
+ const datasourceFiles = srcEntries
784
+ .filter((entry) => /^typeorm-.*-datasource\.ts$/.test(entry))
785
+ .map((entry) => path.join(srcRoot, entry));
786
+
787
+ if (datasourceFiles.length === 0) {
788
+ return;
789
+ }
790
+
791
+ const moduleDirName = path.basename(modulePath);
792
+ const entityClassName = classify(modelEntity.singularName);
793
+ const entityImportPath = `./${moduleDirName}/entities/${kebabCase(modelEntity.singularName)}.entity`;
794
+
795
+ this.logger.log(`Scanning ${datasourceFiles.length} TypeORM datasource file(s) for model '${modelEntity.singularName}' cleanup`);
796
+
797
+ try {
798
+ this.solidTsMorphService.begin();
799
+ for (const datasourceFile of datasourceFiles) {
800
+ this.solidTsMorphService.cleanupTypeormDatasourceEntity(
801
+ datasourceFile,
802
+ entityImportPath,
803
+ entityClassName,
804
+ );
805
+ }
806
+ await this.solidTsMorphService.commit();
807
+ } catch (error: any) {
808
+ this.solidTsMorphService.rollback();
809
+ this.logger.error(`Failed to clean datasource files for model '${modelEntity.singularName}':`, error);
810
+ }
811
+ }
812
+
751
813
  private async cleanupAssociatedViewsActionsAndMenus(modelId: number) {
752
814
  const viewRepo = this.dataSource.getRepository(ViewMetadata);
753
815
  const actionRepo = this.dataSource.getRepository(ActionMetadata);
@@ -968,26 +1030,6 @@ export class ModelMetadataService {
968
1030
  @DisallowInProduction()
969
1031
  async generateCodeViaCtl(modelId: number): Promise<string> {
970
1032
  const model = await this.findOne(modelId);
971
- const isEmbedded = process.env.DEFAULT_DATABASE_DRIVER === 'pglite';
972
-
973
- // When using an embedded PGlite database, the single-connection limit means
974
- // a spawned `solid refresh-model` subprocess cannot get its own DB connection
975
- // while the API server is already holding it. Fall back to in-process code
976
- // generation (which reuses the existing connection).
977
- if (isEmbedded) {
978
- this.logger.log('Embedded database detected — generating API code in-process');
979
- // Prevent nodemon from restarting the server while schematics write files.
980
- const heartbeat = startNodemonHeartbeat(path.join(process.cwd(), 'src'));
981
- try {
982
- return await this.handleGenerateCode({
983
- modelUserKey: model.singularName,
984
- dryRun: false,
985
- });
986
- } finally {
987
- heartbeat.stop();
988
- }
989
- }
990
-
991
1033
  return this.commandService.executeCommandWithArgs({
992
1034
  command: 'npx',
993
1035
  args: ['@solidxai/solidctl@latest', 'generate', 'model', `--name=${model.singularName}`],
@@ -26,7 +26,6 @@ import {
26
26
  } from '../helpers/schematic.service';
27
27
  import { CommandService } from '../helpers/command.service';
28
28
  import { SolidRegistry } from '../helpers/solid-registry';
29
- import { startNodemonHeartbeat } from '../helpers/nodemon-heartbeat';
30
29
  import { CodeGenerationOptions, ModuleMetadataConfiguration } from '../interfaces';
31
30
  import { CrudHelperService } from './crud-helper.service';
32
31
  import { ModelMetadataService } from './model-metadata.service';
@@ -535,40 +534,6 @@ export class ModuleMetadataService {
535
534
  @DisallowInProduction()
536
535
  async generateCodeViaCtl(moduleId: number): Promise<string> {
537
536
  const module = await this.findOne(moduleId);
538
- const isEmbedded = process.env.DEFAULT_DATABASE_DRIVER === 'pglite';
539
-
540
- // When using an embedded PGlite database, the single-connection limit means
541
- // a spawned `solid refresh-module` subprocess cannot get its own DB connection
542
- // while the API server is already holding it. Fall back to in-process code
543
- // generation (which reuses the existing connection) and only spawn solidctl
544
- // for the UI module scaffold (which does not need a database connection).
545
- if (isEmbedded) {
546
- this.logger.log('Embedded database detected — generating API code in-process');
547
- // Prevent nodemon from restarting the server while schematics write files.
548
- // The heartbeat touches a watched file every 1.5 s (nodemon delay is 2 s),
549
- // keeping the delay from expiring during DB queries between schematic runs.
550
- const heartbeat = startNodemonHeartbeat(path.join(process.cwd(), 'src'));
551
- try {
552
- const apiOutput = await this.generateCode({
553
- moduleUserKey: module.name,
554
- dryRun: false,
555
- });
556
- let uiOutput = '';
557
- try {
558
- uiOutput = await this.commandService.executeCommandWithArgs({
559
- command: 'npx',
560
- args: ['@solidxai/solidctl@latest', 'generate', 'ui-module', `--name=${module.name}`],
561
- cwd: path.join(process.cwd(), '..'),
562
- });
563
- } catch (error) {
564
- this.logger.warn('UI module scaffold failed (solidctl may be outdated); API code was generated successfully.');
565
- }
566
- return `${apiOutput}\n${uiOutput}`;
567
- } finally {
568
- heartbeat.stop();
569
- }
570
- }
571
-
572
537
  return this.commandService.executeCommandWithArgs({
573
538
  command: 'npx',
574
539
  args: ['@solidxai/solidctl@latest', 'generate', 'module', `--name=${module.name}`],