webspresso 0.0.5 → 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
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Schema Applicator
3
+ * Parses and validates request input against compiled schemas
4
+ */
5
+
6
+ /**
7
+ * Apply compiled schema to request
8
+ * Parses body, params, and query against their respective schemas
9
+ * Stores validated results in req.input
10
+ *
11
+ * @param {Object} req - Express request object
12
+ * @param {Object|null} compiledSchema - Compiled schema from compileSchema
13
+ * @returns {void}
14
+ * @throws {ZodError} If validation fails
15
+ */
16
+ function applySchema(req, compiledSchema) {
17
+ // Initialize req.input
18
+ req.input = {
19
+ body: undefined,
20
+ params: undefined,
21
+ query: undefined
22
+ };
23
+
24
+ // No schema means no validation
25
+ if (!compiledSchema) {
26
+ return;
27
+ }
28
+
29
+ // Parse body if schema exists
30
+ if (compiledSchema.body) {
31
+ req.input.body = compiledSchema.body.parse(req.body);
32
+ }
33
+
34
+ // Parse params if schema exists
35
+ if (compiledSchema.params) {
36
+ req.input.params = compiledSchema.params.parse(req.params);
37
+ }
38
+
39
+ // Parse query if schema exists
40
+ if (compiledSchema.query) {
41
+ req.input.query = compiledSchema.query.parse(req.query);
42
+ }
43
+ }
44
+
45
+ module.exports = {
46
+ applySchema
47
+ };
48
+
49
+
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Schema Compiler
3
+ * Compiles schema definitions from API files
4
+ */
5
+
6
+ const z = require('zod');
7
+ const schemaCache = require('../utils/schemaCache');
8
+
9
+ /**
10
+ * Compile schema from an API module
11
+ * @param {string} filePath - Absolute file path to API module
12
+ * @param {Object} apiModule - The loaded API module
13
+ * @returns {Object|null} Compiled schema object or null if no schema
14
+ */
15
+ function compileSchema(filePath, apiModule) {
16
+ // Return cached schema if exists
17
+ if (schemaCache.has(filePath)) {
18
+ return schemaCache.get(filePath);
19
+ }
20
+
21
+ // Check if module exports schema
22
+ const schemaFn = apiModule.schema;
23
+
24
+ // Schema is optional
25
+ if (schemaFn === undefined) {
26
+ schemaCache.set(filePath, null);
27
+ return null;
28
+ }
29
+
30
+ // Schema must be a function
31
+ if (typeof schemaFn !== 'function') {
32
+ throw new Error(`Schema in ${filePath} must be a function`);
33
+ }
34
+
35
+ // Call schema function with { z }
36
+ const compiled = schemaFn({ z });
37
+
38
+ // Validate compiled schema structure
39
+ if (compiled !== null && typeof compiled !== 'object') {
40
+ throw new Error(`Schema function in ${filePath} must return an object or null`);
41
+ }
42
+
43
+ // Cache and return
44
+ schemaCache.set(filePath, compiled);
45
+ return compiled;
46
+ }
47
+
48
+ /**
49
+ * Clear schema cache for a file (for hot-reload)
50
+ * @param {string} filePath - Absolute file path
51
+ */
52
+ function invalidateSchema(filePath) {
53
+ schemaCache.del(filePath);
54
+ }
55
+
56
+ /**
57
+ * Clear all cached schemas
58
+ */
59
+ function clearAllSchemas() {
60
+ schemaCache.clear();
61
+ }
62
+
63
+ module.exports = {
64
+ compileSchema,
65
+ invalidateSchema,
66
+ clearAllSchemas
67
+ };
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
+