webspresso 0.0.6 → 0.0.7

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/bin/webspresso.js CHANGED
@@ -785,6 +785,261 @@ module.exports = {
785
785
  }
786
786
  });
787
787
 
788
+ // ============================================================================
789
+ // Database Commands
790
+ // ============================================================================
791
+
792
+ /**
793
+ * Load database configuration
794
+ * @param {string} [configPath] - Custom config path
795
+ * @returns {Object} Database config
796
+ */
797
+ function loadDbConfig(configPath) {
798
+ const defaultPaths = ['webspresso.db.js', 'knexfile.js'];
799
+ const paths = configPath ? [configPath, ...defaultPaths] : defaultPaths;
800
+
801
+ for (const p of paths) {
802
+ const fullPath = path.resolve(process.cwd(), p);
803
+ if (fs.existsSync(fullPath)) {
804
+ return { config: require(fullPath), path: fullPath };
805
+ }
806
+ }
807
+
808
+ console.error('❌ Database config not found. Create webspresso.db.js or knexfile.js');
809
+ process.exit(1);
810
+ }
811
+
812
+ /**
813
+ * Create database instance from config
814
+ * @param {Object} config - Database config
815
+ * @param {string} [env] - Environment name
816
+ * @returns {Promise<Object>} Database instance
817
+ */
818
+ async function createDbInstance(config, env) {
819
+ const environment = env || process.env.NODE_ENV || 'development';
820
+ const dbConfig = config[environment] || config;
821
+
822
+ // Dynamic import knex
823
+ let knex;
824
+ try {
825
+ knex = require('knex');
826
+ } catch {
827
+ console.error('❌ Knex not installed. Run: npm install knex');
828
+ process.exit(1);
829
+ }
830
+
831
+ return knex(dbConfig);
832
+ }
833
+
834
+ // db:migrate command
835
+ program
836
+ .command('db:migrate')
837
+ .description('Run pending database migrations')
838
+ .option('-e, --env <environment>', 'Environment (development, production)', 'development')
839
+ .option('-c, --config <path>', 'Path to database config file')
840
+ .action(async (options) => {
841
+ const { config, path: configPath } = loadDbConfig(options.config);
842
+ console.log(`\n📦 Using config: ${configPath}`);
843
+ console.log(` Environment: ${options.env}\n`);
844
+
845
+ const knex = await createDbInstance(config, options.env);
846
+
847
+ try {
848
+ const migrationConfig = config.migrations || {};
849
+ const [batch, migrations] = await knex.migrate.latest(migrationConfig);
850
+
851
+ if (migrations.length === 0) {
852
+ console.log('✅ Already up to date.\n');
853
+ } else {
854
+ console.log(`Running migrations (batch ${batch}):`);
855
+ for (const m of migrations) {
856
+ console.log(` → ${m}`);
857
+ }
858
+ console.log(`\n✅ Done. ${migrations.length} migration(s) completed.\n`);
859
+ }
860
+ } catch (err) {
861
+ console.error('❌ Migration failed:', err.message);
862
+ process.exit(1);
863
+ } finally {
864
+ await knex.destroy();
865
+ }
866
+ });
867
+
868
+ // db:rollback command
869
+ program
870
+ .command('db:rollback')
871
+ .description('Rollback the last batch of migrations')
872
+ .option('-e, --env <environment>', 'Environment (development, production)', 'development')
873
+ .option('-c, --config <path>', 'Path to database config file')
874
+ .option('-a, --all', 'Rollback all migrations')
875
+ .action(async (options) => {
876
+ const { config, path: configPath } = loadDbConfig(options.config);
877
+ console.log(`\n📦 Using config: ${configPath}`);
878
+ console.log(` Environment: ${options.env}\n`);
879
+
880
+ const knex = await createDbInstance(config, options.env);
881
+
882
+ try {
883
+ const migrationConfig = {
884
+ ...(config.migrations || {}),
885
+ ...(options.all ? { all: true } : {}),
886
+ };
887
+
888
+ const [batch, migrations] = await knex.migrate.rollback(migrationConfig);
889
+
890
+ if (migrations.length === 0) {
891
+ console.log('✅ Nothing to rollback.\n');
892
+ } else {
893
+ console.log(`Rolling back${options.all ? ' all' : ''} migrations:`);
894
+ for (const m of migrations) {
895
+ console.log(` ← ${m}`);
896
+ }
897
+ console.log(`\n✅ Done. ${migrations.length} migration(s) rolled back.\n`);
898
+ }
899
+ } catch (err) {
900
+ console.error('❌ Rollback failed:', err.message);
901
+ process.exit(1);
902
+ } finally {
903
+ await knex.destroy();
904
+ }
905
+ });
906
+
907
+ // db:status command
908
+ program
909
+ .command('db:status')
910
+ .description('Show migration status')
911
+ .option('-e, --env <environment>', 'Environment (development, production)', 'development')
912
+ .option('-c, --config <path>', 'Path to database config file')
913
+ .action(async (options) => {
914
+ const { config, path: configPath } = loadDbConfig(options.config);
915
+ console.log(`\n📦 Using config: ${configPath}`);
916
+ console.log(` Environment: ${options.env}\n`);
917
+
918
+ const knex = await createDbInstance(config, options.env);
919
+
920
+ try {
921
+ const migrationConfig = config.migrations || {};
922
+ const [completed, pending] = await knex.migrate.list(migrationConfig);
923
+
924
+ console.log('Migration Status');
925
+ console.log('================\n');
926
+
927
+ // Sort all migrations by name
928
+ const all = [
929
+ ...completed.map(m => ({ name: m.name || m, completed: true })),
930
+ ...pending.map(m => ({ name: m.name || m, completed: false })),
931
+ ].sort((a, b) => a.name.localeCompare(b.name));
932
+
933
+ if (all.length === 0) {
934
+ console.log(' No migrations found.\n');
935
+ } else {
936
+ for (const m of all) {
937
+ const status = m.completed ? '✓' : '○';
938
+ const suffix = m.completed ? '' : ' (pending)';
939
+ console.log(` ${status} ${m.name}${suffix}`);
940
+ }
941
+ console.log(`\n Total: ${all.length} (${completed.length} completed, ${pending.length} pending)\n`);
942
+ }
943
+ } catch (err) {
944
+ console.error('❌ Failed to get status:', err.message);
945
+ process.exit(1);
946
+ } finally {
947
+ await knex.destroy();
948
+ }
949
+ });
950
+
951
+ // db:make command
952
+ program
953
+ .command('db:make <name>')
954
+ .description('Create a new migration file')
955
+ .option('-c, --config <path>', 'Path to database config file')
956
+ .option('-m, --model <model>', 'Generate migration from model (requires models directory)')
957
+ .action(async (name, options) => {
958
+ const { config, path: configPath } = loadDbConfig(options.config);
959
+ console.log(`\n📦 Using config: ${configPath}\n`);
960
+
961
+ const migrationDir = config.migrations?.directory || './migrations';
962
+
963
+ // Ensure migrations directory exists
964
+ if (!fs.existsSync(migrationDir)) {
965
+ fs.mkdirSync(migrationDir, { recursive: true });
966
+ console.log(`Created directory: ${migrationDir}`);
967
+ }
968
+
969
+ // Generate filename with timestamp
970
+ const now = new Date();
971
+ const timestamp = [
972
+ now.getFullYear(),
973
+ String(now.getMonth() + 1).padStart(2, '0'),
974
+ String(now.getDate()).padStart(2, '0'),
975
+ '_',
976
+ String(now.getHours()).padStart(2, '0'),
977
+ String(now.getMinutes()).padStart(2, '0'),
978
+ String(now.getSeconds()).padStart(2, '0'),
979
+ ].join('');
980
+
981
+ const filename = `${timestamp}_${name}.js`;
982
+ const filepath = path.join(migrationDir, filename);
983
+
984
+ let content;
985
+
986
+ if (options.model) {
987
+ // Try to load model and generate migration from schema
988
+ const modelsDir = config.models || './models';
989
+ const modelPath = path.resolve(process.cwd(), modelsDir, `${options.model}.js`);
990
+
991
+ if (fs.existsSync(modelPath)) {
992
+ try {
993
+ const model = require(modelPath);
994
+ const { scaffoldMigration } = require('../core/orm/migrations/scaffold');
995
+ content = scaffoldMigration(model);
996
+ console.log(`Generated migration from model: ${options.model}`);
997
+ } catch (err) {
998
+ console.warn(`⚠️ Could not generate from model: ${err.message}`);
999
+ console.log(' Creating empty migration instead.\n');
1000
+ content = getDefaultMigrationContent(name);
1001
+ }
1002
+ } else {
1003
+ console.warn(`⚠️ Model not found: ${modelPath}`);
1004
+ console.log(' Creating empty migration instead.\n');
1005
+ content = getDefaultMigrationContent(name);
1006
+ }
1007
+ } else {
1008
+ content = getDefaultMigrationContent(name);
1009
+ }
1010
+
1011
+ fs.writeFileSync(filepath, content);
1012
+ console.log(`✅ Created: ${filepath}\n`);
1013
+ });
1014
+
1015
+ /**
1016
+ * Get default migration content
1017
+ * @param {string} name - Migration name
1018
+ * @returns {string}
1019
+ */
1020
+ function getDefaultMigrationContent(name) {
1021
+ // Parse table name from migration name (e.g., create_users_table -> users)
1022
+ const match = name.match(/^create_(\w+)_table$/);
1023
+ const tableName = match ? match[1] : 'table_name';
1024
+
1025
+ return `/**
1026
+ * Migration: ${name}
1027
+ */
1028
+
1029
+ exports.up = function(knex) {
1030
+ return knex.schema.createTable('${tableName}', (table) => {
1031
+ table.bigIncrements('id').primary();
1032
+ // Add your columns here
1033
+ table.timestamps(true, true);
1034
+ });
1035
+ };
1036
+
1037
+ exports.down = function(knex) {
1038
+ return knex.schema.dropTableIfExists('${tableName}');
1039
+ };
1040
+ `;
1041
+ }
1042
+
788
1043
  // Parse arguments
789
1044
  program.parse();
790
1045
 
@@ -46,3 +46,4 @@ module.exports = {
46
46
  applySchema
47
47
  };
48
48
 
49
+
@@ -66,3 +66,4 @@ module.exports = {
66
66
  clearAllSchemas
67
67
  };
68
68
 
69
+
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Webspresso ORM - Eager Loader
3
+ * Batch loading algorithm for relations
4
+ * @module core/orm/eager-loader
5
+ */
6
+
7
+ const { resolveRelationModel, getRelationKeys } = require('./model');
8
+ const { applyScopes, createScopeContext } = require('./scopes');
9
+
10
+ /**
11
+ * Load relations for a set of records using batch queries
12
+ * @param {Object[]} records - Records to load relations for
13
+ * @param {string[]} relationNames - Names of relations to load
14
+ * @param {import('./types').ModelDefinition} model - Model definition
15
+ * @param {import('knex').Knex|import('knex').Knex.Transaction} knex - Knex instance
16
+ * @param {import('./types').ScopeContext} [scopeContext] - Scope context
17
+ * @returns {Promise<Object[]>} Records with relations attached
18
+ */
19
+ async function loadRelations(records, relationNames, model, knex, scopeContext) {
20
+ if (!records.length || !relationNames.length) {
21
+ return records;
22
+ }
23
+
24
+ const context = scopeContext || createScopeContext();
25
+
26
+ // Process each relation
27
+ for (const relationName of relationNames) {
28
+ const relation = model.relations[relationName];
29
+ if (!relation) {
30
+ console.warn(`Relation "${relationName}" not found on model "${model.name}"`);
31
+ continue;
32
+ }
33
+
34
+ const { localKey, foreignKey, relatedModel } = getRelationKeys(model, relationName);
35
+
36
+ switch (relation.type) {
37
+ case 'belongsTo':
38
+ await loadBelongsTo(records, relationName, localKey, foreignKey, relatedModel, knex, context);
39
+ break;
40
+ case 'hasMany':
41
+ await loadHasMany(records, relationName, localKey, foreignKey, relatedModel, knex, context);
42
+ break;
43
+ case 'hasOne':
44
+ await loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, context);
45
+ break;
46
+ }
47
+ }
48
+
49
+ return records;
50
+ }
51
+
52
+ /**
53
+ * Load belongsTo relation
54
+ * Parent record has foreign key pointing to related record's primary key
55
+ * Example: User belongsTo Company (user.company_id -> company.id)
56
+ *
57
+ * @param {Object[]} records - Parent records
58
+ * @param {string} relationName - Relation name
59
+ * @param {string} localKey - Local primary key (typically 'id')
60
+ * @param {string} foreignKey - Foreign key on parent (e.g., 'company_id')
61
+ * @param {import('./types').ModelDefinition} relatedModel - Related model
62
+ * @param {import('knex').Knex} knex - Knex instance
63
+ * @param {import('./types').ScopeContext} scopeContext - Scope context
64
+ */
65
+ async function loadBelongsTo(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
66
+ // Collect unique foreign key values
67
+ const foreignKeyValues = [...new Set(
68
+ records
69
+ .map(r => r[foreignKey])
70
+ .filter(v => v !== null && v !== undefined)
71
+ )];
72
+
73
+ if (foreignKeyValues.length === 0) {
74
+ // No foreign keys, set all relations to null
75
+ for (const record of records) {
76
+ record[relationName] = null;
77
+ }
78
+ return;
79
+ }
80
+
81
+ // Build query for related records
82
+ let qb = knex(relatedModel.table).whereIn(relatedModel.primaryKey, foreignKeyValues);
83
+
84
+ // Apply scopes to related model
85
+ qb = applyScopes(qb, scopeContext, relatedModel);
86
+
87
+ const relatedRecords = await qb;
88
+
89
+ // Index related records by primary key
90
+ const relatedMap = new Map();
91
+ for (const related of relatedRecords) {
92
+ relatedMap.set(related[relatedModel.primaryKey], related);
93
+ }
94
+
95
+ // Attach related records to parent records
96
+ for (const record of records) {
97
+ const fkValue = record[foreignKey];
98
+ record[relationName] = fkValue !== null && fkValue !== undefined
99
+ ? relatedMap.get(fkValue) || null
100
+ : null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Load hasMany relation
106
+ * Related records have foreign key pointing to parent record's primary key
107
+ * Example: User hasMany Posts (post.user_id -> user.id)
108
+ *
109
+ * @param {Object[]} records - Parent records
110
+ * @param {string} relationName - Relation name
111
+ * @param {string} localKey - Local primary key (typically 'id')
112
+ * @param {string} foreignKey - Foreign key on related records (e.g., 'user_id')
113
+ * @param {import('./types').ModelDefinition} relatedModel - Related model
114
+ * @param {import('knex').Knex} knex - Knex instance
115
+ * @param {import('./types').ScopeContext} scopeContext - Scope context
116
+ */
117
+ async function loadHasMany(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
118
+ // Collect unique primary key values from parent records
119
+ const primaryKeyValues = [...new Set(
120
+ records
121
+ .map(r => r[localKey])
122
+ .filter(v => v !== null && v !== undefined)
123
+ )];
124
+
125
+ if (primaryKeyValues.length === 0) {
126
+ // No primary keys, set all relations to empty arrays
127
+ for (const record of records) {
128
+ record[relationName] = [];
129
+ }
130
+ return;
131
+ }
132
+
133
+ // Build query for related records
134
+ let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
135
+
136
+ // Apply scopes to related model
137
+ qb = applyScopes(qb, scopeContext, relatedModel);
138
+
139
+ const relatedRecords = await qb;
140
+
141
+ // Group related records by foreign key
142
+ const relatedGroups = new Map();
143
+ for (const related of relatedRecords) {
144
+ const fkValue = related[foreignKey];
145
+ if (!relatedGroups.has(fkValue)) {
146
+ relatedGroups.set(fkValue, []);
147
+ }
148
+ relatedGroups.get(fkValue).push(related);
149
+ }
150
+
151
+ // Attach related records to parent records
152
+ for (const record of records) {
153
+ const pkValue = record[localKey];
154
+ record[relationName] = relatedGroups.get(pkValue) || [];
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Load hasOne relation
160
+ * Related record has foreign key pointing to parent record's primary key
161
+ * Returns single related record (or null)
162
+ *
163
+ * @param {Object[]} records - Parent records
164
+ * @param {string} relationName - Relation name
165
+ * @param {string} localKey - Local primary key (typically 'id')
166
+ * @param {string} foreignKey - Foreign key on related record
167
+ * @param {import('./types').ModelDefinition} relatedModel - Related model
168
+ * @param {import('knex').Knex} knex - Knex instance
169
+ * @param {import('./types').ScopeContext} scopeContext - Scope context
170
+ */
171
+ async function loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
172
+ // Collect unique primary key values from parent records
173
+ const primaryKeyValues = [...new Set(
174
+ records
175
+ .map(r => r[localKey])
176
+ .filter(v => v !== null && v !== undefined)
177
+ )];
178
+
179
+ if (primaryKeyValues.length === 0) {
180
+ // No primary keys, set all relations to null
181
+ for (const record of records) {
182
+ record[relationName] = null;
183
+ }
184
+ return;
185
+ }
186
+
187
+ // Build query for related records
188
+ let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
189
+
190
+ // Apply scopes to related model
191
+ qb = applyScopes(qb, scopeContext, relatedModel);
192
+
193
+ const relatedRecords = await qb;
194
+
195
+ // Index related records by foreign key (first occurrence wins for hasOne)
196
+ const relatedMap = new Map();
197
+ for (const related of relatedRecords) {
198
+ const fkValue = related[foreignKey];
199
+ if (!relatedMap.has(fkValue)) {
200
+ relatedMap.set(fkValue, related);
201
+ }
202
+ }
203
+
204
+ // Attach related records to parent records
205
+ for (const record of records) {
206
+ const pkValue = record[localKey];
207
+ record[relationName] = relatedMap.get(pkValue) || null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Load a single relation for a single record
213
+ * @param {Object} record - Record to load relation for
214
+ * @param {string} relationName - Name of relation to load
215
+ * @param {import('./types').ModelDefinition} model - Model definition
216
+ * @param {import('knex').Knex} knex - Knex instance
217
+ * @param {import('./types').ScopeContext} [scopeContext] - Scope context
218
+ * @returns {Promise<Object>} Record with relation attached
219
+ */
220
+ async function loadRelation(record, relationName, model, knex, scopeContext) {
221
+ const result = await loadRelations([record], [relationName], model, knex, scopeContext);
222
+ return result[0];
223
+ }
224
+
225
+ module.exports = {
226
+ loadRelations,
227
+ loadRelation,
228
+ loadBelongsTo,
229
+ loadHasMany,
230
+ loadHasOne,
231
+ };
232
+
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Webspresso ORM
3
+ * Minimal, Eloquent-inspired ORM with Knex and Zod
4
+ * @module core/orm
5
+ */
6
+
7
+ const { createSchemaHelpers, extractColumnsFromSchema, getColumnMeta } = require('./schema-helpers');
8
+ const { defineModel, getModel, getAllModels, hasModel, clearRegistry } = require('./model');
9
+ const { createRepository } = require('./repository');
10
+ const { createQueryBuilder, QueryBuilder } = require('./query-builder');
11
+ const { runTransaction, createTransactionContext } = require('./transaction');
12
+ const { createMigrationManager } = require('./migrations');
13
+ const { scaffoldMigration, scaffoldAlterMigration, scaffoldDropMigration } = require('./migrations/scaffold');
14
+ const { createScopeContext } = require('./scopes');
15
+ const { createSeeder } = require('./seeder');
16
+
17
+ /**
18
+ * Create a database instance
19
+ * @param {import('./types').DatabaseConfig} config - Database configuration
20
+ * @returns {import('./types').DatabaseInstance}
21
+ */
22
+ function createDatabase(config) {
23
+ // Lazy load knex to avoid requiring it if ORM is not used
24
+ let knex;
25
+ try {
26
+ knex = require('knex');
27
+ } catch {
28
+ throw new Error('Knex is required for ORM. Install it with: npm install knex');
29
+ }
30
+
31
+ // Create Knex instance
32
+ const knexInstance = knex(config);
33
+
34
+ // Create migration manager
35
+ const migrationConfig = config.migrations || {};
36
+ const migrate = createMigrationManager(knexInstance, migrationConfig);
37
+
38
+ // Default scope context
39
+ let globalScopeContext = createScopeContext();
40
+
41
+ /**
42
+ * Set global tenant ID
43
+ * @param {*} tenantId - Tenant ID
44
+ * @returns {DatabaseInstance}
45
+ */
46
+ function forTenant(tenantId) {
47
+ globalScopeContext.tenantId = tenantId;
48
+ return db;
49
+ }
50
+
51
+ /**
52
+ * Create a repository for a model
53
+ * @param {import('./types').ModelDefinition} model - Model definition
54
+ * @returns {import('./types').Repository}
55
+ */
56
+ function createRepo(model) {
57
+ return createRepository(model, knexInstance, { ...globalScopeContext });
58
+ }
59
+
60
+ /**
61
+ * Run a callback within a transaction
62
+ * @param {function(import('./types').TransactionContext): Promise<*>} callback
63
+ * @returns {Promise<*>}
64
+ */
65
+ function transaction(callback) {
66
+ return runTransaction(knexInstance, callback, { ...globalScopeContext });
67
+ }
68
+
69
+ /**
70
+ * Get raw Knex instance for advanced queries
71
+ * @returns {import('knex').Knex}
72
+ */
73
+ function raw() {
74
+ return knexInstance;
75
+ }
76
+
77
+ /**
78
+ * Close all database connections
79
+ * @returns {Promise<void>}
80
+ */
81
+ async function destroy() {
82
+ await knexInstance.destroy();
83
+ }
84
+
85
+ /**
86
+ * Create a seeder instance
87
+ * @param {Object} faker - Faker instance (@faker-js/faker)
88
+ * @returns {Object} Seeder API
89
+ */
90
+ function seeder(faker) {
91
+ return createSeeder(faker, knexInstance);
92
+ }
93
+
94
+ const db = {
95
+ knex: knexInstance,
96
+ createRepository: createRepo,
97
+ transaction,
98
+ migrate,
99
+ seeder,
100
+ forTenant,
101
+ raw,
102
+ destroy,
103
+ };
104
+
105
+ return db;
106
+ }
107
+
108
+ // Export everything
109
+ module.exports = {
110
+ // Main factory
111
+ createDatabase,
112
+
113
+ // Schema helpers
114
+ createSchemaHelpers,
115
+ extractColumnsFromSchema,
116
+ getColumnMeta,
117
+
118
+ // Model
119
+ defineModel,
120
+ getModel,
121
+ getAllModels,
122
+ hasModel,
123
+ clearRegistry,
124
+
125
+ // Repository (for direct use if needed)
126
+ createRepository,
127
+
128
+ // Query builder
129
+ createQueryBuilder,
130
+ QueryBuilder,
131
+
132
+ // Transaction
133
+ runTransaction,
134
+ createTransactionContext,
135
+
136
+ // Migrations
137
+ createMigrationManager,
138
+ scaffoldMigration,
139
+ scaffoldAlterMigration,
140
+ scaffoldDropMigration,
141
+
142
+ // Scopes
143
+ createScopeContext,
144
+
145
+ // Seeder
146
+ createSeeder,
147
+ };
148
+