@solidxai/core 0.1.11-beta.4 → 0.1.11-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/migrate-removed-fields.command.d.ts +19 -0
- package/dist/commands/migrate-removed-fields.command.d.ts.map +1 -0
- package/dist/commands/migrate-removed-fields.command.js +77 -0
- package/dist/commands/migrate-removed-fields.command.js.map +1 -0
- package/dist/dtos/datasource-introspection-mapping.dto.d.ts +1 -0
- package/dist/dtos/datasource-introspection-mapping.dto.d.ts.map +1 -1
- package/dist/dtos/datasource-introspection-mapping.dto.js +7 -1
- package/dist/dtos/datasource-introspection-mapping.dto.js.map +1 -1
- package/dist/helpers/command.service.d.ts +2 -0
- package/dist/helpers/command.service.d.ts.map +1 -1
- package/dist/helpers/command.service.js +6 -2
- package/dist/helpers/command.service.js.map +1 -1
- package/dist/helpers/nodemon-heartbeat.d.ts +5 -0
- package/dist/helpers/nodemon-heartbeat.d.ts.map +1 -0
- package/dist/helpers/nodemon-heartbeat.js +67 -0
- package/dist/helpers/nodemon-heartbeat.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/seeders/module-metadata-seeder.service.d.ts +7 -1
- package/dist/seeders/module-metadata-seeder.service.d.ts.map +1 -1
- package/dist/seeders/module-metadata-seeder.service.js +101 -1
- package/dist/seeders/module-metadata-seeder.service.js.map +1 -1
- package/dist/seeders/seed-data/solid-core-metadata.json +109 -31
- package/dist/services/api-key.service.d.ts.map +1 -1
- package/dist/services/api-key.service.js.map +1 -1
- package/dist/services/computed-fields/entity/concat-entity-computed-field-provider.service.d.ts +4 -0
- package/dist/services/computed-fields/entity/concat-entity-computed-field-provider.service.d.ts.map +1 -1
- package/dist/services/computed-fields/entity/concat-entity-computed-field-provider.service.js +18 -1
- package/dist/services/computed-fields/entity/concat-entity-computed-field-provider.service.js.map +1 -1
- package/dist/services/crud.service.d.ts +1 -0
- package/dist/services/crud.service.d.ts.map +1 -1
- package/dist/services/crud.service.js +27 -0
- package/dist/services/crud.service.js.map +1 -1
- package/dist/services/datasource-introspection.service.d.ts +1 -0
- package/dist/services/datasource-introspection.service.d.ts.map +1 -1
- package/dist/services/datasource-introspection.service.js +27 -16
- package/dist/services/datasource-introspection.service.js.map +1 -1
- package/dist/services/import-transaction.service.d.ts.map +1 -1
- package/dist/services/import-transaction.service.js +3 -0
- package/dist/services/import-transaction.service.js.map +1 -1
- package/dist/services/model-metadata.service.d.ts +1 -0
- package/dist/services/model-metadata.service.d.ts.map +1 -1
- package/dist/services/model-metadata.service.js +28 -0
- package/dist/services/model-metadata.service.js.map +1 -1
- package/dist/services/module-metadata.service.d.ts.map +1 -1
- package/dist/services/module-metadata.service.js +27 -0
- package/dist/services/module-metadata.service.js.map +1 -1
- package/dist/services/removed-field-migration.service.d.ts +30 -0
- package/dist/services/removed-field-migration.service.d.ts.map +1 -0
- package/dist/services/removed-field-migration.service.js +319 -0
- package/dist/services/removed-field-migration.service.js.map +1 -0
- package/dist/solid-core.module.d.ts.map +1 -1
- package/dist/solid-core.module.js +4 -0
- package/dist/solid-core.module.js.map +1 -1
- package/dist/subscribers/audit.subscriber.d.ts +4 -1
- package/dist/subscribers/audit.subscriber.d.ts.map +1 -1
- package/dist/subscribers/audit.subscriber.js +54 -6
- package/dist/subscribers/audit.subscriber.js.map +1 -1
- package/dist-tests/api/authenticate.spec.js +119 -0
- package/dist-tests/api/authenticate.spec.js.map +1 -0
- package/dist-tests/api/crud-service.findOne.cityMaster.spec.js +97 -0
- package/dist-tests/api/crud-service.findOne.cityMaster.spec.js.map +1 -0
- package/dist-tests/api/ping.spec.js +21 -0
- package/dist-tests/api/ping.spec.js.map +1 -0
- package/dist-tests/helpers/auth.js +41 -0
- package/dist-tests/helpers/auth.js.map +1 -0
- package/dist-tests/helpers/env.js +11 -0
- package/dist-tests/helpers/env.js.map +1 -0
- package/docs/agent-hub-grooming.md +301 -0
- package/docs/dashboards/AGENTIC_DASHBOARD_IMPLEMENTATION_PLAN.md +438 -0
- package/docs/dashboards/dashboard-curl-smoke-tests.txt +146 -0
- package/docs/dashboards/delete-legacy-dashboard-metadata.sql +172 -0
- package/docs/datasource-introspection-ddl-analysis.md +326 -0
- package/docs/datasource-introspection-implementation-plan.md +306 -0
- package/docs/grouping-enhancements.md +89 -0
- package/docs/java-spring/README.md +3 -0
- package/docs/java-spring/solid-core-module-deep-dive-report.md +1317 -0
- package/docs/module-package-import-handoff.md +691 -0
- package/docs/seed-changes.md +65 -0
- package/docs/test-data-workflow.md +200 -0
- package/docs/type-declaration-import-issue.md +24 -0
- package/package.json +1 -1
- package/src/commands/migrate-removed-fields.command.ts +81 -0
- package/src/dtos/datasource-introspection-mapping.dto.ts +5 -0
- package/src/helpers/command.service.ts +10 -2
- package/src/helpers/nodemon-heartbeat.ts +59 -0
- package/src/index.ts +1 -0
- package/src/seeders/module-metadata-seeder.service.ts +118 -0
- package/src/seeders/seed-data/solid-core-metadata.json +109 -31
- package/src/services/api-key.service.ts +36 -36
- package/src/services/computed-fields/entity/concat-entity-computed-field-provider.service.ts +15 -1
- package/src/services/crud.service.ts +36 -11
- package/src/services/datasource-introspection.service.ts +29 -16
- package/src/services/import-transaction.service.ts +5 -0
- package/src/services/model-metadata.service.ts +45 -1
- package/src/services/module-metadata.service.ts +35 -0
- package/src/services/removed-field-migration.service.ts +334 -0
- package/src/solid-core.module.ts +4 -0
- package/src/subscribers/audit.subscriber.ts +70 -5
- package/.claude/settings.local.json +0 -16
- package/CLAUDE.md +0 -26
- package/src/services/1.js +0 -6
|
@@ -26,6 +26,7 @@ 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';
|
|
29
30
|
import { CodeGenerationOptions, ModuleMetadataConfiguration } from '../interfaces';
|
|
30
31
|
import { CrudHelperService } from './crud-helper.service';
|
|
31
32
|
import { ModelMetadataService } from './model-metadata.service';
|
|
@@ -534,6 +535,40 @@ export class ModuleMetadataService {
|
|
|
534
535
|
@DisallowInProduction()
|
|
535
536
|
async generateCodeViaCtl(moduleId: number): Promise<string> {
|
|
536
537
|
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
|
+
|
|
537
572
|
return this.commandService.executeCommandWithArgs({
|
|
538
573
|
command: 'npx',
|
|
539
574
|
args: ['@solidxai/solidctl@latest', 'generate', 'module', `--name=${module.name}`],
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|
3
|
+
import { ModuleRef } from "@nestjs/core";
|
|
4
|
+
import { getDataSourceToken } from "@nestjs/typeorm";
|
|
5
|
+
import { kebabCase, snakeCase } from "lodash";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { ERROR_MESSAGES } from "src/constants/error-messages";
|
|
8
|
+
import { RelationType, SolidFieldType } from "src/dtos/create-field-metadata.dto";
|
|
9
|
+
import { FieldMetadata } from "src/entities/field-metadata.entity";
|
|
10
|
+
import { ModelMetadata } from "src/entities/model-metadata.entity";
|
|
11
|
+
import { ModuleMetadataHelperService } from "src/helpers/module-metadata-helper.service";
|
|
12
|
+
import { classify } from "src/helpers/string.helper";
|
|
13
|
+
import { FieldMetadataRepository } from "src/repository/field-metadata.repository";
|
|
14
|
+
import { ModelMetadataRepository } from "src/repository/model-metadata.repository";
|
|
15
|
+
import { DataSource, EntityMetadata, QueryRunner, Table } from "typeorm";
|
|
16
|
+
|
|
17
|
+
export interface RemovedFieldMigrationResult {
|
|
18
|
+
dryRun: boolean;
|
|
19
|
+
modelName: string;
|
|
20
|
+
operations: string[];
|
|
21
|
+
removedFieldNames: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class RemovedFieldMigrationService {
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly modelMetadataRepo: ModelMetadataRepository,
|
|
28
|
+
private readonly fieldMetadataRepo: FieldMetadataRepository,
|
|
29
|
+
private readonly moduleMetadataHelperService: ModuleMetadataHelperService,
|
|
30
|
+
private readonly moduleRef: ModuleRef,
|
|
31
|
+
) { }
|
|
32
|
+
|
|
33
|
+
private readonly logger = new Logger(RemovedFieldMigrationService.name);
|
|
34
|
+
|
|
35
|
+
// Cleans fields marked for removal by updating schema state and metadata for a single model.
|
|
36
|
+
async migrateMarkedFields(modelUserKey: string, dryRun: boolean = false): Promise<RemovedFieldMigrationResult> {
|
|
37
|
+
if (!modelUserKey) {
|
|
38
|
+
throw new BadRequestException("Model name is required");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const model = await this.modelMetadataRepo.findOne({
|
|
42
|
+
where: { singularName: modelUserKey },
|
|
43
|
+
relations: { fields: true, module: true },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!model) {
|
|
47
|
+
throw new NotFoundException(ERROR_MESSAGES.MODEL_NOT_FOUND(modelUserKey));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fieldsForRemoval = model.fields.filter((field) => field.isMarkedForRemoval);
|
|
51
|
+
const operations: string[] = [];
|
|
52
|
+
|
|
53
|
+
if (fieldsForRemoval.length === 0) {
|
|
54
|
+
const message = `No fields marked for removal were found for model "${model.singularName}".`;
|
|
55
|
+
this.logger.log(message);
|
|
56
|
+
operations.push(message);
|
|
57
|
+
return {
|
|
58
|
+
dryRun,
|
|
59
|
+
modelName: model.singularName,
|
|
60
|
+
operations,
|
|
61
|
+
removedFieldNames: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dataSource = await this.resolveDataSource(model.dataSource);
|
|
66
|
+
const entityMetadata = this.resolveEntityMetadata(dataSource, model);
|
|
67
|
+
const queryRunner = dataSource.createQueryRunner();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await queryRunner.connect();
|
|
71
|
+
|
|
72
|
+
if (!dryRun) {
|
|
73
|
+
await queryRunner.startTransaction();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const field of fieldsForRemoval) {
|
|
77
|
+
await this.cleanupMarkedField({
|
|
78
|
+
field,
|
|
79
|
+
model,
|
|
80
|
+
entityMetadata,
|
|
81
|
+
queryRunner,
|
|
82
|
+
dryRun,
|
|
83
|
+
operations,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!dryRun) {
|
|
88
|
+
await queryRunner.commitTransaction();
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!dryRun && queryRunner.isTransactionActive) {
|
|
92
|
+
await queryRunner.rollbackTransaction();
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
} finally {
|
|
96
|
+
await queryRunner.release();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
dryRun,
|
|
101
|
+
modelName: model.singularName,
|
|
102
|
+
operations,
|
|
103
|
+
removedFieldNames: fieldsForRemoval.map((field) => field.name),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async cleanupMarkedField(params: { field: FieldMetadata; model: ModelMetadata; entityMetadata?: EntityMetadata; queryRunner: QueryRunner; dryRun: boolean; operations: string[]; }): Promise<void> {
|
|
108
|
+
const { field, model, entityMetadata, queryRunner, dryRun, operations } = params;
|
|
109
|
+
const relationMetadata = entityMetadata?.relations.find((relation) => relation.propertyName === field.name);
|
|
110
|
+
const resolvedTableName = entityMetadata?.tableName || model.tableName;
|
|
111
|
+
|
|
112
|
+
if (field.type !== SolidFieldType.relation) {
|
|
113
|
+
const columnCandidates = this.buildColumnCandidates(field, entityMetadata, relationMetadata);
|
|
114
|
+
await this.dropColumnsForField(resolvedTableName, field, columnCandidates, queryRunner, dryRun, operations);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (field.relationType === RelationType.manyToOne) {
|
|
119
|
+
const columnCandidates = this.buildColumnCandidates(field, entityMetadata, relationMetadata);
|
|
120
|
+
await this.dropColumnsForField(resolvedTableName, field, columnCandidates, queryRunner, dryRun, operations);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (field.relationType === RelationType.manyTomany) {
|
|
125
|
+
const joinTableName = relationMetadata?.junctionEntityMetadata?.tableName || field.relationJoinTableName;
|
|
126
|
+
const ownsJoinTable = relationMetadata?.isOwning || field.isRelationManyToManyOwner;
|
|
127
|
+
|
|
128
|
+
if (!ownsJoinTable) {
|
|
129
|
+
operations.push(`No direct database cleanup required for inverse many-to-many field "${field.name}".`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!joinTableName) {
|
|
134
|
+
operations.push(`Skipping join-table cleanup for "${field.name}" because no join table name could be resolved.`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await this.dropJoinTable(joinTableName, field, queryRunner, dryRun, operations);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
operations.push(`No direct database cleanup required for relation field "${field.name}" with type "${field.relationType}".`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async dropColumnsForField(tableName: string, field: FieldMetadata, columnCandidates: string[], queryRunner: QueryRunner, dryRun: boolean, operations: string[],): Promise<void> {
|
|
146
|
+
if (!tableName) {
|
|
147
|
+
operations.push(`Skipping field "${field.name}" because the model table name could not be resolved.`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (columnCandidates.length === 0) {
|
|
152
|
+
operations.push(`Skipping field "${field.name}" because no column candidates could be resolved.`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const handledColumns = new Set<string>();
|
|
157
|
+
let droppedAnyColumn = false;
|
|
158
|
+
|
|
159
|
+
for (const columnName of columnCandidates) {
|
|
160
|
+
if (!columnName || handledColumns.has(columnName)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
handledColumns.add(columnName);
|
|
165
|
+
const table = await this.loadTable(queryRunner, tableName);
|
|
166
|
+
const column = table?.columns.find((tableColumn) => tableColumn.name === columnName);
|
|
167
|
+
|
|
168
|
+
if (!column) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
droppedAnyColumn = true;
|
|
173
|
+
await this.dropColumnArtifacts(table, columnName, queryRunner, dryRun, operations);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!droppedAnyColumn) {
|
|
177
|
+
operations.push(`No database column found for field "${field.name}" on table "${tableName}". Metadata cleanup will still proceed.`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async dropColumnArtifacts(table: Table, columnName: string, queryRunner: QueryRunner, dryRun: boolean, operations: string[],): Promise<void> {
|
|
182
|
+
for (const foreignKey of table.foreignKeys.filter((item) => item.columnNames.includes(columnName))) {
|
|
183
|
+
operations.push(`Drop foreign key "${foreignKey.name}" on "${table.name}.${columnName}"`);
|
|
184
|
+
if (!dryRun) {
|
|
185
|
+
await queryRunner.dropForeignKey(table, foreignKey);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const uniqueConstraint of table.uniques.filter((item) => item.columnNames.includes(columnName))) {
|
|
190
|
+
operations.push(`Drop unique constraint "${uniqueConstraint.name}" on "${table.name}.${columnName}"`);
|
|
191
|
+
if (!dryRun) {
|
|
192
|
+
await queryRunner.dropUniqueConstraint(table, uniqueConstraint);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const index of table.indices.filter((item) => item.columnNames.includes(columnName))) {
|
|
197
|
+
operations.push(`Drop index "${index.name}" on "${table.name}.${columnName}"`);
|
|
198
|
+
if (!dryRun) {
|
|
199
|
+
await queryRunner.dropIndex(table, index);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
operations.push(`Drop column "${table.name}.${columnName}"`);
|
|
204
|
+
if (!dryRun) {
|
|
205
|
+
await queryRunner.dropColumn(table, columnName);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async dropJoinTable(joinTableName: string, field: FieldMetadata, queryRunner: QueryRunner, dryRun: boolean, operations: string[]): Promise<void> {
|
|
210
|
+
const table = await this.loadTable(queryRunner, joinTableName);
|
|
211
|
+
if (!table) {
|
|
212
|
+
operations.push(`Join table "${joinTableName}" for field "${field.name}" does not exist. Metadata cleanup will still proceed.`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
operations.push(`Drop join table "${joinTableName}" for field "${field.name}"`);
|
|
217
|
+
if (!dryRun) {
|
|
218
|
+
await queryRunner.dropTable(joinTableName);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private buildColumnCandidates(field: FieldMetadata, entityMetadata?: EntityMetadata, relationMetadata?: EntityMetadata["relations"][number]): string[] {
|
|
223
|
+
const columnCandidates = new Set<string>();
|
|
224
|
+
|
|
225
|
+
relationMetadata?.joinColumns?.forEach((column) => {
|
|
226
|
+
if (column.databaseName) {
|
|
227
|
+
columnCandidates.add(column.databaseName);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
entityMetadata?.columns
|
|
232
|
+
.filter((column) => column.propertyName === field.name)
|
|
233
|
+
.forEach((column) => {
|
|
234
|
+
if (column.databaseName) {
|
|
235
|
+
columnCandidates.add(column.databaseName);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (field.columnName) {
|
|
240
|
+
columnCandidates.add(field.columnName);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (field.relationCoModelColumnName) {
|
|
244
|
+
columnCandidates.add(field.relationCoModelColumnName);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (field.type === SolidFieldType.relation && field.relationType === RelationType.manyToOne) {
|
|
248
|
+
columnCandidates.add(`${snakeCase(field.name)}_id`);
|
|
249
|
+
} else {
|
|
250
|
+
columnCandidates.add(snakeCase(field.name));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return [...columnCandidates].filter(Boolean);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async resolveDataSource(dataSourceName?: string): Promise<DataSource> {
|
|
257
|
+
const normalizedDataSourceName = dataSourceName && dataSourceName !== "default" ? dataSourceName : undefined;
|
|
258
|
+
const token = normalizedDataSourceName ? getDataSourceToken(normalizedDataSourceName) : getDataSourceToken();
|
|
259
|
+
let dataSource: DataSource | undefined;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
dataSource = this.moduleRef.get<DataSource>(token, { strict: false });
|
|
263
|
+
} catch (error: any) {
|
|
264
|
+
throw new NotFoundException(`Datasource "${normalizedDataSourceName ?? "default"}" could not be resolved: ${error?.message ?? error}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!dataSource) {
|
|
268
|
+
throw new NotFoundException(`Datasource "${normalizedDataSourceName ?? "default"}" could not be resolved.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!dataSource.isInitialized) {
|
|
272
|
+
await dataSource.initialize();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return dataSource;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private resolveEntityMetadata(dataSource: DataSource, model: ModelMetadata): EntityMetadata | undefined {
|
|
279
|
+
const candidates = [classify(model.singularName), model.singularName, model.tableName].filter(Boolean);
|
|
280
|
+
|
|
281
|
+
for (const candidate of candidates) {
|
|
282
|
+
try {
|
|
283
|
+
return dataSource.getMetadata(candidate);
|
|
284
|
+
} catch {
|
|
285
|
+
// Try the next candidate.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return dataSource.entityMetadatas.find((metadata) => metadata.tableName === model.tableName);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async resolveMetadataFilePath(moduleName: string): Promise<string> {
|
|
293
|
+
const defaultPath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName);
|
|
294
|
+
if (await this.fileExists(defaultPath)) {
|
|
295
|
+
return defaultPath;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const dashModuleName = kebabCase(moduleName);
|
|
299
|
+
const moduleMetadataFilePath = path.resolve(
|
|
300
|
+
process.cwd(),
|
|
301
|
+
"module-metadata",
|
|
302
|
+
dashModuleName,
|
|
303
|
+
`${dashModuleName}-metadata.json`,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
if (await this.fileExists(moduleMetadataFilePath)) {
|
|
307
|
+
return moduleMetadataFilePath;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return defaultPath;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async fileExists(filePath: string): Promise<boolean> {
|
|
314
|
+
try {
|
|
315
|
+
await fs.access(filePath);
|
|
316
|
+
return true;
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private async loadTable(queryRunner: QueryRunner, tableName: string): Promise<Table | undefined> {
|
|
323
|
+
if (!tableName) {
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const hasTable = await queryRunner.hasTable(tableName);
|
|
328
|
+
if (!hasTable) {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return queryRunner.getTable(tableName) ?? undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
package/src/solid-core.module.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { MulterModule } from "@nestjs/platform-express";
|
|
14
14
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
|
15
15
|
import { RemoveFieldsCommand } from "./commands/remove-fields.command";
|
|
16
|
+
import { MigrateRemovedFieldsCommand } from "./commands/migrate-removed-fields.command";
|
|
16
17
|
import { FieldMetadataController } from "./controllers/field-metadata.controller";
|
|
17
18
|
import { DashboardController } from "./controllers/dashboard.controller";
|
|
18
19
|
import { MediaStorageProviderMetadataController } from "./controllers/media-storage-provider-metadata.controller";
|
|
@@ -47,6 +48,7 @@ import { ListOfValuesService } from "./services/list-of-values.service";
|
|
|
47
48
|
import { MediaStorageProviderMetadataService } from "./services/media-storage-provider-metadata.service";
|
|
48
49
|
import { MediaService } from "./services/media.service";
|
|
49
50
|
import { ModelMetadataService } from "./services/model-metadata.service";
|
|
51
|
+
import { RemovedFieldMigrationService } from "./services/removed-field-migration.service";
|
|
50
52
|
import { ModuleMetadataExplorerService } from "./services/module-metadata-explorer.service";
|
|
51
53
|
import { ModuleMetadataService } from "./services/module-metadata.service";
|
|
52
54
|
import { ModulePackageService } from "./services/module-package.service";
|
|
@@ -545,6 +547,7 @@ import { PostgresDatasourceIntrospectionProviderService } from "./services/datas
|
|
|
545
547
|
FieldMetadataService,
|
|
546
548
|
DashboardRuntimeService,
|
|
547
549
|
RemoveFieldsCommand,
|
|
550
|
+
MigrateRemovedFieldsCommand,
|
|
548
551
|
RefreshModelCommand,
|
|
549
552
|
RefreshModuleCommand,
|
|
550
553
|
InfoCommand,
|
|
@@ -791,6 +794,7 @@ import { PostgresDatasourceIntrospectionProviderService } from "./services/datas
|
|
|
791
794
|
UserViewMetadataRepository,
|
|
792
795
|
ModelMetadataRepository,
|
|
793
796
|
ModuleMetadataRepository,
|
|
797
|
+
RemovedFieldMigrationService,
|
|
794
798
|
ActionMetadataRepository,
|
|
795
799
|
MediaStorageProviderMetadataRepository,
|
|
796
800
|
FixturesService,
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Injectable, Logger, Scope } from '@nestjs/common';
|
|
2
|
+
import { ModelMetadataHelperService } from 'src/helpers/model-metadata-helper.service';
|
|
2
3
|
import { lowerFirst } from 'src/helpers/string.helper';
|
|
3
4
|
import { SolidRegistry } from 'src/helpers/solid-registry';
|
|
4
5
|
import { DataSource, EntityMetadata, EntitySubscriberInterface, InsertEvent, RemoveEvent, UpdateEvent } from 'typeorm';
|
|
5
6
|
import { AuditQueuePayload } from 'src/interfaces';
|
|
6
7
|
import { RequestContextService } from 'src/services/request-context.service';
|
|
7
8
|
import { PublisherFactory } from 'src/services/queues/publisher-factory.service';
|
|
9
|
+
const AUDIT_BEFORE_SNAPSHOT = '__auditBeforeSnapshot';
|
|
8
10
|
|
|
9
|
-
@Injectable({scope: Scope.TRANSIENT})
|
|
11
|
+
@Injectable({ scope: Scope.TRANSIENT })
|
|
10
12
|
export class AuditSubscriber implements EntitySubscriberInterface {
|
|
11
13
|
private readonly logger = new Logger(AuditSubscriber.name);
|
|
12
14
|
private dataSource: DataSource;
|
|
@@ -14,6 +16,7 @@ export class AuditSubscriber implements EntitySubscriberInterface {
|
|
|
14
16
|
private readonly publisherFactory: PublisherFactory<AuditQueuePayload>,
|
|
15
17
|
private readonly solidRegistry: SolidRegistry,
|
|
16
18
|
private readonly requestContextService: RequestContextService,
|
|
19
|
+
private readonly modelMetadataHelperService: ModelMetadataHelperService,
|
|
17
20
|
) { }
|
|
18
21
|
|
|
19
22
|
bindToDataSource(dataSource: DataSource) {
|
|
@@ -53,20 +56,82 @@ export class AuditSubscriber implements EntitySubscriberInterface {
|
|
|
53
56
|
|
|
54
57
|
async afterUpdate(event: UpdateEvent<any>) {
|
|
55
58
|
if (!this.shouldTrackAudit(event.metadata)) return;
|
|
59
|
+
const entityId = event.entity?.id ?? event.databaseEntity?.id ?? (event as any).entityId ?? null;
|
|
60
|
+
let before = event.databaseEntity ?? null;
|
|
61
|
+
let after = event.entity ?? null;
|
|
62
|
+
const updatedColumnNames = (event.updatedColumns ?? []).map(c => c.propertyName);
|
|
63
|
+
|
|
64
|
+
const auditResult = await this.prepareManyToManyAuditUpdateSnapshot(event, entityId, updatedColumnNames);
|
|
65
|
+
|
|
66
|
+
if (auditResult) {
|
|
67
|
+
before = auditResult.before;
|
|
68
|
+
after = auditResult.after;
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
this.enqueue(event, {
|
|
57
72
|
eventType: 'update',
|
|
58
73
|
modelName: event.metadata.name,
|
|
59
|
-
entityId:
|
|
74
|
+
entityId: entityId,
|
|
60
75
|
occurredAt: new Date().toISOString(),
|
|
61
|
-
after:
|
|
76
|
+
after: after,
|
|
62
77
|
// databaseEntity is only populated when the entity was fetched first (save() path).
|
|
63
78
|
// QueryBuilder update() leaves this undefined; postAuditMessageOnUpdate guards for it.
|
|
64
|
-
before:
|
|
65
|
-
updatedColumnNames:
|
|
79
|
+
before: before,
|
|
80
|
+
updatedColumnNames: updatedColumnNames,
|
|
66
81
|
userId: this.activeUserId(),
|
|
67
82
|
});
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Resolves before/after snapshots for audit-enabled many-to-many relations
|
|
87
|
+
* and updates changed relation field names for audit tracking.
|
|
88
|
+
*/
|
|
89
|
+
private async prepareManyToManyAuditUpdateSnapshot(event: UpdateEvent<any>, entityId: number | null, updatedColumnNames: string[]): Promise<{ before: any; after: any } | null> {
|
|
90
|
+
|
|
91
|
+
const auditRelationFields = (await this.modelMetadataHelperService.loadFieldHierarchy(lowerFirst(event.metadata.name))).filter(field =>
|
|
92
|
+
field.enableAuditTracking &&
|
|
93
|
+
field.type === 'relation' &&
|
|
94
|
+
field.relationType !== 'one-to-many'
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (!entityId || auditRelationFields.length === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const relations: Record<string, boolean> = {};
|
|
102
|
+
|
|
103
|
+
auditRelationFields.forEach(field => {
|
|
104
|
+
relations[field.name] = true;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const relationBefore = event.entity?.[AUDIT_BEFORE_SNAPSHOT] ?? null;
|
|
108
|
+
|
|
109
|
+
const relationAfter = await event.manager.getRepository(event.metadata.target as any).findOne({
|
|
110
|
+
where: { id: entityId } as any,
|
|
111
|
+
relations: relations as any,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (relationBefore && relationAfter) {
|
|
115
|
+
auditRelationFields.forEach(field => {
|
|
116
|
+
const oldIds = Array.isArray(relationBefore[field.name])
|
|
117
|
+
? relationBefore[field.name].map(item => item.id).sort()
|
|
118
|
+
: [];
|
|
119
|
+
|
|
120
|
+
const newIds = Array.isArray(relationAfter[field.name])
|
|
121
|
+
? relationAfter[field.name].map(item => item.id).sort()
|
|
122
|
+
: [];
|
|
123
|
+
|
|
124
|
+
if ((oldIds.length !== newIds.length || JSON.stringify(oldIds) !== JSON.stringify(newIds))
|
|
125
|
+
&& !updatedColumnNames.includes(field.name)) {
|
|
126
|
+
updatedColumnNames.push(field.name);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
before: relationBefore ?? event.databaseEntity ?? null,
|
|
132
|
+
after: relationAfter ?? event.entity ?? null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
70
135
|
async afterRemove(event: RemoveEvent<any>) {
|
|
71
136
|
if (!this.shouldTrackAudit(event.metadata)) return;
|
|
72
137
|
this.enqueue(event, {
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(node -e:*)",
|
|
5
|
-
"WebFetch(domain:docs.solidxai.com)",
|
|
6
|
-
"WebFetch(domain:github.com)",
|
|
7
|
-
"Read(//Users/oswald/projects/Solid_Starters/solidctl/**)",
|
|
8
|
-
"Read(//Users/oswald/projects/Solid_Starters/**)",
|
|
9
|
-
"Bash(find /Users/oswald/projects/Solid_Starters/solid-core-module -type d -name migration* -o -name *database*)",
|
|
10
|
-
"Bash(node verify-imports.js)"
|
|
11
|
-
],
|
|
12
|
-
"additionalDirectories": [
|
|
13
|
-
"/Users/oswald/projects/Solid_Starters/solidctl"
|
|
14
|
-
]
|
|
15
|
-
}
|
|
16
|
-
}
|
package/CLAUDE.md
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# This is the SolidX API project.
|
|
2
|
-
This is included as a dependency in consuming projects.
|
|
3
|
-
|
|
4
|
-
# SolidX UI
|
|
5
|
-
# The corresponding UI project is at the below path:
|
|
6
|
-
/Users/oswald/projects/Solid_Starters/solid-core-ui
|
|
7
|
-
|
|
8
|
-
# SolidX Docs
|
|
9
|
-
# The documentation project is at the below path:
|
|
10
|
-
/Users/oswald/projects/solidxai-docs
|
|
11
|
-
|
|
12
|
-
# SolidX Agent
|
|
13
|
-
# The agent project which has the skills for helping users configure, build and use SolidX is at the below path:
|
|
14
|
-
/Users/oswald/projects/Solid_Starters/agent
|
|
15
|
-
|
|
16
|
-
# SolidX Control Plane Commands
|
|
17
|
-
# The control plane commands for managing SolidX instances are in the below path:
|
|
18
|
-
/Users/oswald/projects/Solid_Starters/solidctl
|
|
19
|
-
|
|
20
|
-
# Testing reference project
|
|
21
|
-
# This is the reference library management project that is used for testing and development of SolidX features. It is located at the below path:
|
|
22
|
-
/Users/oswald/projects/Solid_Starters/solid-library-management
|
|
23
|
-
|
|
24
|
-
# Code Builder project
|
|
25
|
-
# This is the project that contains the code builder which generates code based on user input and templates. It is located at the below path:
|
|
26
|
-
/Users/oswald/projects/Solid_Starters/solid-code-builder
|
package/src/services/1.js
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
1. Do i need to create a storeStreams method for aws service too?
|
|
2
|
-
- Handle later
|
|
3
|
-
2. queues handling -> if queues is enabled by default, i.e triggerExport(exportTransactionEntity.id).
|
|
4
|
-
- startExport should either return the data or return the transaction id
|
|
5
|
-
3. How to handle scenarios wherein, nested related exist.(do i need to only get the userkey)
|
|
6
|
-
- show the userKey
|