masterrecord 0.3.30 → 0.3.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,17 +46,60 @@ class EntityModelBuilder {
46
46
  MDB.obj.name = methodName;
47
47
  obj[methodName] = MDB.obj;
48
48
  }
49
+
50
+ // Extract composite indexes from static property (Option A)
51
+ if (model.compositeIndexes) {
52
+ obj.__compositeIndexes = this.#normalizeCompositeIndexes(
53
+ model.compositeIndexes,
54
+ model.name
55
+ );
56
+ } else {
57
+ obj.__compositeIndexes = []; // Initialize empty array
58
+ }
59
+
49
60
  return obj;
50
61
  }
51
62
 
52
63
  static cleanNull(obj) {
53
- for (var propName in obj) {
64
+ for (var propName in obj) {
54
65
  if (obj[propName] === null) {
55
66
  delete obj[propName];
56
67
  }
57
68
  }
58
69
  }
59
70
 
71
+ static #normalizeCompositeIndexes(indexes, tableName) {
72
+ if (!Array.isArray(indexes)) {
73
+ throw new Error(`compositeIndexes must be an array`);
74
+ }
75
+
76
+ return indexes.map((index, i) => {
77
+ // Simple array: ['col1', 'col2'] -> auto-generate name
78
+ if (Array.isArray(index)) {
79
+ const colNames = index.join('_');
80
+ return {
81
+ columns: index,
82
+ name: `idx_${tableName.toLowerCase()}_${colNames}`,
83
+ unique: false
84
+ };
85
+ }
86
+
87
+ // Object: { columns: [...], name?, unique? }
88
+ if (!index.columns || !Array.isArray(index.columns)) {
89
+ throw new Error(`Composite index must have 'columns' array`);
90
+ }
91
+
92
+ const name = index.name ||
93
+ `idx_${tableName.toLowerCase()}_${index.columns.join('_')}`;
94
+
95
+ return {
96
+ columns: index.columns,
97
+ name: name,
98
+ unique: index.unique || false
99
+ };
100
+ });
101
+ }
102
+
60
103
  }
61
104
 
62
105
  module.exports = EntityModelBuilder;
package/Migrations/cli.js CHANGED
@@ -275,15 +275,17 @@ program.option('-V', 'output the version');
275
275
  return;
276
276
  }
277
277
  var cleanEntities = migration.cleanEntities(contextInstance.__entities);
278
+ var seedData = contextInstance.__contextSeedData || {};
279
+ var seedConfig = contextInstance.__contextSeedConfig || {};
278
280
 
279
281
  // Skip if no changes between snapshot schema and current entities
280
- const has = migration.hasChanges(contextSnapshot.schema || [], cleanEntities || []);
282
+ const has = migration.hasChanges(contextSnapshot.schema || [], cleanEntities || [], seedData);
281
283
  if(!has){
282
284
  console.log(`No changes detected for ${path.basename(contextAbs)}. Skipping.`);
283
285
  return;
284
286
  }
285
287
 
286
- var newEntity = migration.template(name, contextSnapshot.schema, cleanEntities);
288
+ var newEntity = migration.template(name, contextSnapshot.schema, cleanEntities, seedData, seedConfig);
287
289
  if(!fs.existsSync(migBase)){
288
290
  try{ fs.mkdirSync(migBase, { recursive: true }); }catch(_){ /* ignore */ }
289
291
  }
@@ -465,6 +467,7 @@ program.option('-V', 'output the version');
465
467
  executedLocation : executedLocation,
466
468
  context : contextInstance,
467
469
  contextEntities : cleanEntities,
470
+ contextSeedData: contextInstance.__contextSeedData || {},
468
471
  contextFileName: contextFileName
469
472
  }
470
473
 
@@ -582,6 +585,8 @@ program.option('-V', 'output the version');
582
585
  executedLocation : executedLocation,
583
586
  context : contextInstance,
584
587
  contextEntities : cleanEntities,
588
+ contextSeedData: contextInstance.__contextSeedData || {},
589
+ contextSeedConfig: contextInstance.__contextSeedConfig || {},
585
590
  contextFileName: contextFileName
586
591
  }
587
592
  migration.createSnapShot(snap);
@@ -832,6 +837,8 @@ program.option('-V', 'output the version');
832
837
  executedLocation : executedLocation,
833
838
  context : contextInstance,
834
839
  contextEntities : cleanEntities,
840
+ contextSeedData: contextInstance.__contextSeedData || {},
841
+ contextSeedConfig: contextInstance.__contextSeedConfig || {},
835
842
  contextFileName: path.basename(snapshotFile).replace('_contextSnapShot.json','')
836
843
  }
837
844
  migration.createSnapShot(snap);
@@ -887,13 +894,15 @@ program.option('-V', 'output the version');
887
894
  }
888
895
  var migration = new Migration();
889
896
  var cleanEntities = migration.cleanEntities(contextInstance.__entities);
897
+ var seedData = contextInstance.__contextSeedData || {};
898
+ var seedConfig = contextInstance.__contextSeedConfig || {};
890
899
  // If no changes, skip with message
891
- const has = migration.hasChanges(cs.schema || [], cleanEntities || []);
900
+ const has = migration.hasChanges(cs.schema || [], cleanEntities || [], seedData);
892
901
  if(!has){
893
902
  console.log(`No changes detected for ${path.basename(contextAbs)}. Skipping.`);
894
903
  continue;
895
904
  }
896
- var newEntity = migration.template(name, cs.schema, cleanEntities);
905
+ var newEntity = migration.template(name, cs.schema, cleanEntities, seedData, seedConfig);
897
906
  if(!fs.existsSync(migBase)){
898
907
  try{ fs.mkdirSync(migBase, { recursive: true }); }catch(_){ /* ignore */ }
899
908
  }
@@ -1010,6 +1019,8 @@ program.option('-V', 'output the version');
1010
1019
  executedLocation : executedLocation,
1011
1020
  context : contextInstance,
1012
1021
  contextEntities : cleanEntities,
1022
+ contextSeedData: contextInstance.__contextSeedData || {},
1023
+ contextSeedConfig: contextInstance.__contextSeedConfig || {},
1013
1024
  contextFileName: entry.ctxName
1014
1025
  }
1015
1026
  migration.createSnapShot(snap);
@@ -1072,6 +1083,8 @@ program.option('-V', 'output the version');
1072
1083
  file : abs,
1073
1084
  executedLocation : executedLocation,
1074
1085
  contextEntities : [],
1086
+ contextSeedData: {},
1087
+ contextSeedConfig: {},
1075
1088
  contextFileName: key
1076
1089
  };
1077
1090
  migration.createSnapShot(snap);
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Dependency graph for topological sorting of seed data
3
+ * Uses Kahn's algorithm to order tables by foreign key dependencies
4
+ */
5
+ class DependencyGraph {
6
+ constructor(entities) {
7
+ this.entities = entities;
8
+ this.graph = new Map(); // adjacency list: parent -> [children]
9
+ this.inDegree = new Map(); // in-degree for each table (number of dependencies)
10
+ }
11
+
12
+ /**
13
+ * Build dependency graph from entity relationships
14
+ * Tables with belongsTo relationships depend on their foreign tables
15
+ */
16
+ buildFromEntities() {
17
+ // Initialize graph structure for all entities
18
+ this.entities.forEach(entity => {
19
+ const tableName = entity.__name;
20
+ if (!tableName) return;
21
+
22
+ this.graph.set(tableName, []);
23
+ this.inDegree.set(tableName, 0);
24
+ });
25
+
26
+ // Build edges from belongsTo relationships
27
+ this.entities.forEach(entity => {
28
+ const tableName = entity.__name;
29
+ if (!tableName) return;
30
+
31
+ // Find belongsTo relationships (dependencies)
32
+ Object.keys(entity).forEach(key => {
33
+ const field = entity[key];
34
+
35
+ // Check if this is a belongsTo relationship with a foreign table
36
+ if (field && typeof field === 'object' &&
37
+ field.relationshipType === 'belongsTo' &&
38
+ field.foreignTable) {
39
+
40
+ const foreignTable = field.foreignTable;
41
+
42
+ // Ensure foreign table exists in graph
43
+ if (!this.graph.has(foreignTable)) {
44
+ this.graph.set(foreignTable, []);
45
+ this.inDegree.set(foreignTable, 0);
46
+ }
47
+
48
+ // Add edge: foreignTable -> tableName (tableName depends on foreignTable)
49
+ this.graph.get(foreignTable).push(tableName);
50
+ this.inDegree.set(tableName, this.inDegree.get(tableName) + 1);
51
+ }
52
+ });
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Perform topological sort using Kahn's algorithm
58
+ * @returns {Array<string>} Ordered list of table names
59
+ * @throws {Error} If circular dependency detected
60
+ */
61
+ topologicalSort() {
62
+ const result = [];
63
+ const queue = [];
64
+ const inDegreeCopy = new Map(this.inDegree);
65
+
66
+ // Start with nodes that have no dependencies (in-degree = 0)
67
+ for (const [node, degree] of inDegreeCopy.entries()) {
68
+ if (degree === 0) {
69
+ queue.push(node);
70
+ }
71
+ }
72
+
73
+ while (queue.length > 0) {
74
+ const current = queue.shift();
75
+ result.push(current);
76
+
77
+ // Process all neighbors (tables that depend on current)
78
+ const neighbors = this.graph.get(current) || [];
79
+ neighbors.forEach(neighbor => {
80
+ inDegreeCopy.set(neighbor, inDegreeCopy.get(neighbor) - 1);
81
+ if (inDegreeCopy.get(neighbor) === 0) {
82
+ queue.push(neighbor);
83
+ }
84
+ });
85
+ }
86
+
87
+ // Detect cycles: if we couldn't visit all nodes, there's a cycle
88
+ if (result.length !== this.inDegree.size) {
89
+ const unvisited = Array.from(this.inDegree.keys()).filter(k => !result.includes(k));
90
+ throw new Error(`Circular dependency detected in tables: ${unvisited.join(' <-> ')}`);
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * Get topologically sorted list filtered to only tables with seed data
98
+ * @param {Object} seedData - Object with table names as keys
99
+ * @returns {Array<string>} Ordered list of table names that have seed data
100
+ */
101
+ filterToSeededTables(seedData) {
102
+ const sorted = this.topologicalSort();
103
+ const seededTables = Object.keys(seedData);
104
+ return sorted.filter(table => seededTables.includes(table));
105
+ }
106
+ }
107
+
108
+ module.exports = DependencyGraph;
@@ -246,6 +246,16 @@ class migrationMySQLQuery {
246
246
  return `DROP INDEX \`${indexName}\` ON \`${indexInfo.tableName}\``;
247
247
  }
248
248
 
249
+ createCompositeIndex(indexInfo){
250
+ const columns = indexInfo.columns.map(c => `\`${c}\``).join(', ');
251
+ const uniqueKeyword = indexInfo.unique ? 'UNIQUE ' : '';
252
+ return `CREATE ${uniqueKeyword}INDEX \`${indexInfo.indexName}\` ON \`${indexInfo.tableName}\`(${columns})`;
253
+ }
254
+
255
+ dropCompositeIndex(indexInfo){
256
+ return `DROP INDEX \`${indexInfo.indexName}\` ON \`${indexInfo.tableName}\``;
257
+ }
258
+
249
259
  /**
250
260
  * SEED DATA METHODS
251
261
  * Support for inserting seed data during migrations
@@ -233,6 +233,16 @@ class migrationPostgresQuery {
233
233
  return `DROP INDEX IF EXISTS "${indexName}"`;
234
234
  }
235
235
 
236
+ createCompositeIndex(indexInfo){
237
+ const columns = indexInfo.columns.map(c => `"${c}"`).join(', ');
238
+ const uniqueKeyword = indexInfo.unique ? 'UNIQUE ' : '';
239
+ return `CREATE ${uniqueKeyword}INDEX IF NOT EXISTS "${indexInfo.indexName}" ON "${indexInfo.tableName}"(${columns})`;
240
+ }
241
+
242
+ dropCompositeIndex(indexInfo){
243
+ return `DROP INDEX IF EXISTS "${indexInfo.indexName}"`;
244
+ }
245
+
236
246
  /**
237
247
  * SEED DATA METHODS
238
248
  * Support for inserting seed data during migrations
@@ -201,6 +201,16 @@ class migrationSQLiteQuery {
201
201
  return `DROP INDEX IF EXISTS ${indexName}`;
202
202
  }
203
203
 
204
+ createCompositeIndex(indexInfo){
205
+ const columns = indexInfo.columns.join(', ');
206
+ const uniqueKeyword = indexInfo.unique ? 'UNIQUE ' : '';
207
+ return `CREATE ${uniqueKeyword}INDEX IF NOT EXISTS ${indexInfo.indexName} ON ${indexInfo.tableName}(${columns})`;
208
+ }
209
+
210
+ dropCompositeIndex(indexInfo){
211
+ return `DROP INDEX IF EXISTS ${indexInfo.indexName}`;
212
+ }
213
+
204
214
  /**
205
215
  * SEED DATA METHODS
206
216
  * Support for inserting seed data during migrations
@@ -120,6 +120,182 @@ module.exports = ${this.name};
120
120
  }
121
121
  }
122
122
 
123
+ createCompositeIndex(type, indexInfo){
124
+ const indexInfoStr = JSON.stringify({
125
+ tableName: indexInfo.tableName,
126
+ columns: indexInfo.columns,
127
+ indexName: indexInfo.indexName,
128
+ unique: indexInfo.unique
129
+ });
130
+
131
+ if(type === "up"){
132
+ this.#up += os.EOL + ` this.createCompositeIndex(${indexInfoStr});`
133
+ }
134
+ else{
135
+ this.#down += os.EOL + ` this.dropCompositeIndex(${indexInfoStr});`
136
+ }
137
+ }
138
+
139
+ dropCompositeIndex(type, indexInfo){
140
+ const indexInfoStr = JSON.stringify({
141
+ tableName: indexInfo.tableName,
142
+ columns: indexInfo.columns,
143
+ indexName: indexInfo.indexName,
144
+ unique: indexInfo.unique
145
+ });
146
+
147
+ if(type === "up"){
148
+ this.#up += os.EOL + ` this.dropCompositeIndex(${indexInfoStr});`
149
+ }
150
+ else{
151
+ this.#down += os.EOL + ` this.createCompositeIndex(${indexInfoStr});`
152
+ }
153
+ }
154
+
155
+ seedData(type, tableName, records, currentEnv = 'development'){
156
+ if(!records || records.length === 0) return;
157
+
158
+ if(type === "up"){
159
+ // Filter records by environment first
160
+ const filteredRecords = records.filter(record => {
161
+ const envCondition = record.__seedEnv;
162
+ if (envCondition && envCondition.strategy === 'generation-time') {
163
+ return envCondition.conditions.includes(currentEnv);
164
+ }
165
+ return true; // No environment condition, include record
166
+ });
167
+
168
+ if (filteredRecords.length === 0) return;
169
+
170
+ // Check if all records are factory-generated
171
+ const allGenerated = filteredRecords.every(r => r.__seedMeta?.generated);
172
+
173
+ // Use optimized loop syntax for bulk factory data (10+ records)
174
+ if (allGenerated && filteredRecords.length >= 10) {
175
+ this.#up += os.EOL + ` const factoryRecords = [`;
176
+
177
+ filteredRecords.forEach((record, i) => {
178
+ const cleanRecord = { ...record };
179
+ delete cleanRecord.__rollback;
180
+ delete cleanRecord.__seedEnv;
181
+ delete cleanRecord.__seedStrategy;
182
+ delete cleanRecord.__seedMeta;
183
+
184
+ const recordStr = JSON.stringify(cleanRecord);
185
+ this.#up += os.EOL + ` ${recordStr}${i < filteredRecords.length - 1 ? ',' : ''}`;
186
+ });
187
+
188
+ this.#up += os.EOL + ` ];`;
189
+ this.#up += os.EOL + ` for (const record of factoryRecords) {`;
190
+ this.#up += os.EOL + ` await table.${tableName}.create(record);`;
191
+ this.#up += os.EOL + ` }`;
192
+ } else {
193
+ // Standard individual inserts for non-factory or small batches
194
+ filteredRecords.forEach(record => {
195
+ const strategy = record.__seedStrategy;
196
+
197
+ // Clean up metadata before generating migration code
198
+ const cleanRecord = { ...record };
199
+ delete cleanRecord.__rollback;
200
+ delete cleanRecord.__seedEnv;
201
+ delete cleanRecord.__seedStrategy;
202
+ delete cleanRecord.__seedMeta;
203
+
204
+ // Handle upsert strategy
205
+ if (strategy && strategy.type === 'upsert') {
206
+ this._generateUpsert(tableName, cleanRecord, strategy);
207
+ } else {
208
+ // Standard insert
209
+ const recordStr = JSON.stringify(cleanRecord);
210
+
211
+ // Check if record is too long for single line (> 80 chars)
212
+ if (recordStr.length > 80) {
213
+ // Multi-line format with proper indentation
214
+ const formattedRecord = JSON.stringify(cleanRecord, null, 12)
215
+ .split('\n')
216
+ .join(os.EOL + ' ');
217
+ this.#up += os.EOL + ` await table.${tableName}.create(${formattedRecord});`;
218
+ } else {
219
+ // Single-line format
220
+ this.#up += os.EOL + ` await table.${tableName}.create(${recordStr});`;
221
+ }
222
+ }
223
+ });
224
+ }
225
+ }
226
+ }
227
+
228
+ _generateUpsert(tableName, cleanRecord, strategy) {
229
+ const conflictKey = strategy.conflictKey === 'primaryKey'
230
+ ? (cleanRecord.id !== undefined ? 'id' : Object.keys(cleanRecord)[0])
231
+ : strategy.conflictKey;
232
+
233
+ const conflictValue = cleanRecord[conflictKey];
234
+ if (conflictValue === undefined) {
235
+ throw new Error(`Upsert requires a value for conflict key: ${conflictKey}`);
236
+ }
237
+
238
+ this.#up += os.EOL + ` {`;
239
+ this.#up += os.EOL + ` const existing = await table.${tableName}.where(r => r.${conflictKey} == ${JSON.stringify(conflictValue)}).single();`;
240
+ this.#up += os.EOL + ` if (existing) {`;
241
+
242
+ // Update logic
243
+ if (strategy.updateFields && Array.isArray(strategy.updateFields)) {
244
+ strategy.updateFields.forEach(field => {
245
+ if (cleanRecord[field] !== undefined) {
246
+ this.#up += os.EOL + ` existing.${field} = ${JSON.stringify(cleanRecord[field])};`;
247
+ }
248
+ });
249
+ } else {
250
+ // Update all fields except conflict key
251
+ Object.keys(cleanRecord).forEach(field => {
252
+ if (field !== conflictKey) {
253
+ this.#up += os.EOL + ` existing.${field} = ${JSON.stringify(cleanRecord[field])};`;
254
+ }
255
+ });
256
+ }
257
+
258
+ this.#up += os.EOL + ` await existing.save();`;
259
+ this.#up += os.EOL + ` } else {`;
260
+ this.#up += os.EOL + ` await table.${tableName}.create(${JSON.stringify(cleanRecord)});`;
261
+ this.#up += os.EOL + ` }`;
262
+ this.#up += os.EOL + ` }`;
263
+ }
264
+
265
+ seedDataDown(type, tableName, records, config){
266
+ if(type !== "down" || !config || !config.generateDownMigrations) return;
267
+ if(!records || records.length === 0) return;
268
+
269
+ // Reverse order for safe FK deletion (children before parents)
270
+ const reversed = [...records].reverse();
271
+
272
+ reversed.forEach(record => {
273
+ const rollback = record.__rollback;
274
+ if (!rollback || !rollback.value) {
275
+ // Skip if no rollback metadata (e.g., no primary key specified)
276
+ return;
277
+ }
278
+
279
+ const pkValue = rollback.value;
280
+ const pkKey = rollback.key || 'id';
281
+
282
+ // Generate delete code with error handling
283
+ this.#down += os.EOL + ` try {`;
284
+ this.#down += os.EOL + ` const record = await table.${tableName}.findById(${JSON.stringify(pkValue)});`;
285
+ this.#down += os.EOL + ` if (record) await record.delete();`;
286
+ this.#down += os.EOL + ` } catch (e) {`;
287
+
288
+ if (config.onRollbackError === 'throw') {
289
+ this.#down += os.EOL + ` throw new Error('Seed rollback failed: ${tableName} id=${pkValue} - ' + e.message);`;
290
+ } else if (config.onRollbackError === 'warn') {
291
+ this.#down += os.EOL + ` console.warn('Seed rollback: ${tableName} id=${pkValue} not found or error:', e.message);`;
292
+ }
293
+ // else ignore (onRollbackError === 'ignore')
294
+
295
+ this.#down += os.EOL + ` }`;
296
+ });
297
+ }
298
+
123
299
  }
124
300
 
125
301
  module.exports = MigrationTemplate;