webspresso 0.0.100 → 0.0.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +3 -1
  3. package/.agents/ARCHITECTURE.md +11 -0
  4. package/.agents/AUTH.md +63 -0
  5. package/.agents/CLI.md +81 -0
  6. package/.agents/CONVENTIONS.md +4 -0
  7. package/.agents/EMAIL.md +84 -2
  8. package/.agents/EVENTS.md +179 -0
  9. package/.agents/ORM.md +59 -0
  10. package/.agents/PLUGINS.md +32 -6
  11. package/.agents/ROUTING.md +163 -3
  12. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  13. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  14. package/AGENTS.md +7 -0
  15. package/README.md +18 -5
  16. package/bin/commands/add-tailwind.js +10 -0
  17. package/bin/commands/build.js +34 -0
  18. package/bin/commands/db-makemigrations.js +83 -0
  19. package/bin/commands/list.js +96 -0
  20. package/bin/commands/make-command.js +93 -0
  21. package/bin/commands/make-event.js +80 -0
  22. package/bin/commands/make-factory.js +72 -0
  23. package/bin/commands/make-mail.js +93 -0
  24. package/bin/commands/make-policy.js +106 -0
  25. package/bin/commands/routes.js +8 -0
  26. package/bin/commands/run.js +195 -0
  27. package/bin/webspresso.js +17 -0
  28. package/core/applySchema.js +18 -1
  29. package/core/auth/bouncer.js +196 -0
  30. package/core/auth/index.js +10 -0
  31. package/core/auth/middleware.js +15 -1
  32. package/core/commands/define-command.js +48 -0
  33. package/core/commands/index.js +18 -0
  34. package/core/commands/registry.js +156 -0
  35. package/core/commands/runner.js +76 -0
  36. package/core/compression/compressible.js +1 -1
  37. package/core/compression/middleware.js +35 -251
  38. package/core/content/service.js +8 -2
  39. package/core/events/discovery.js +142 -0
  40. package/core/events/dispatcher.js +552 -0
  41. package/core/events/index.js +23 -0
  42. package/core/events/signal.js +92 -0
  43. package/core/forms/flash.js +234 -0
  44. package/core/forms/form-builder.js +214 -0
  45. package/core/forms/index.js +16 -0
  46. package/core/mail/index.js +23 -0
  47. package/core/mail/mailable.js +250 -0
  48. package/core/mail/mailer.js +184 -0
  49. package/core/mail/transports.js +238 -0
  50. package/core/orm/factory.js +250 -0
  51. package/core/orm/index.js +6 -0
  52. package/core/orm/migrations/diff.js +185 -0
  53. package/core/orm/migrations/index.js +22 -4
  54. package/core/orm/model.js +4 -0
  55. package/index.d.ts +422 -1
  56. package/index.js +111 -16
  57. package/package.json +9 -5
  58. package/plugins/admin-panel/api.js +509 -3
  59. package/plugins/admin-panel/index.js +29 -0
  60. package/plugins/content/models/content-entry.js +4 -3
  61. package/plugins/content/models/content-type.js +4 -3
  62. package/plugins/cors.js +2 -2
  63. package/plugins/csrf/index.js +1 -1
  64. package/plugins/email/index.js +48 -0
  65. package/plugins/email/preview-handler.js +555 -0
  66. package/plugins/email/service.js +23 -12
  67. package/plugins/email/transport.js +30 -11
  68. package/plugins/file-manager/index.js +13 -0
  69. package/plugins/index.js +2 -0
  70. package/plugins/lazy.js +258 -0
  71. package/plugins/redirect/index.js +1 -1
  72. package/src/api/api-loader.js +18 -2
  73. package/src/api/validation-i18n.js +231 -0
  74. package/src/app-context.js +48 -6
  75. package/src/dev-reload/index.js +9 -1
  76. package/src/discovery/route-manifest.js +197 -0
  77. package/src/discovery/scan-routes.js +80 -5
  78. package/src/file-router.js +153 -91
  79. package/src/helpers.js +143 -0
  80. package/src/njk-frontmatter.js +17 -1
  81. package/src/pages/page-loader.js +1 -1
  82. package/src/plugin-manager.js +12 -0
  83. package/src/routing/cascading.js +14 -10
  84. package/src/routing/route-table.js +1 -1
  85. package/src/server.js +243 -34
  86. package/src/services/builtins/system.js +4 -6
  87. package/src/services/registry.js +12 -2
  88. package/src/template/precompile.js +247 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Webspresso ORM - Model Factory & Seeding (AdonisJS Lucid-inspired)
3
+ * Declarative mock data generation and seeding for tests and development
4
+ * @module core/orm/factory
5
+ */
6
+
7
+ /**
8
+ * Fallback lightweight mock faker when @faker-js/faker is not present
9
+ */
10
+ function createMinimalFaker() {
11
+ let counter = 1000;
12
+ return {
13
+ person: {
14
+ fullName: () => `User_${++counter}`,
15
+ firstName: () => `First_${++counter}`,
16
+ lastName: () => `Last_${++counter}`,
17
+ },
18
+ internet: {
19
+ email: () => `user_${++counter}@example.com`,
20
+ userName: () => `user_${++counter}`,
21
+ url: () => `https://example.com/item/${++counter}`,
22
+ },
23
+ lorem: {
24
+ sentence: (words = 5) => `Sample text description ${++counter}`,
25
+ paragraph: () => `Sample paragraph for model testing with counter ${++counter}.`,
26
+ words: (n = 3) => `words_${++counter}`,
27
+ },
28
+ number: {
29
+ int: (opts = { min: 1, max: 1000 }) => {
30
+ const min = opts.min ?? 1;
31
+ const max = opts.max ?? 1000;
32
+ return Math.floor(Math.random() * (max - min + 1)) + min;
33
+ },
34
+ float: (opts = { min: 1, max: 100, precision: 0.01 }) => {
35
+ const min = opts.min ?? 1;
36
+ const max = opts.max ?? 100;
37
+ return Number((Math.random() * (max - min) + min).toFixed(2));
38
+ },
39
+ },
40
+ date: {
41
+ recent: () => new Date(),
42
+ past: () => new Date(Date.now() - 86400000 * 30),
43
+ future: () => new Date(Date.now() + 86400000 * 30),
44
+ },
45
+ datatype: {
46
+ boolean: () => Math.random() > 0.5,
47
+ },
48
+ string: {
49
+ nanoid: () => `nano_${Math.random().toString(36).slice(2, 10)}`,
50
+ uuid: () => `00000000-0000-4000-8000-${String(++counter).padStart(12, '0')}`,
51
+ },
52
+ };
53
+ }
54
+
55
+ function resolveFaker() {
56
+ try {
57
+ const fakerMod = require('@faker-js/faker');
58
+ return fakerMod.faker || fakerMod.default?.faker || fakerMod;
59
+ } catch {
60
+ return createMinimalFaker();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Factory Builder for generating single or multiple model instances
66
+ */
67
+ class FactoryBuilder {
68
+ /**
69
+ * @param {string} modelName
70
+ * @param {Function} definitionFn
71
+ * @param {Map<string, Function|Object>} states
72
+ */
73
+ constructor(modelName, definitionFn, states = new Map()) {
74
+ this._modelName = modelName;
75
+ this._definitionFn = definitionFn;
76
+ this._states = states;
77
+ this._appliedStates = [];
78
+ this._count = 1;
79
+ this._overrides = {};
80
+ }
81
+
82
+ /**
83
+ * Set number of records to generate
84
+ * @param {number} n
85
+ * @returns {FactoryBuilder}
86
+ */
87
+ count(n) {
88
+ const clone = this._clone();
89
+ clone._count = Math.max(1, Math.floor(n));
90
+ return clone;
91
+ }
92
+
93
+ /**
94
+ * Apply one or more named states
95
+ * @param {...string} stateNames
96
+ * @returns {FactoryBuilder}
97
+ */
98
+ state(...stateNames) {
99
+ return this.apply(...stateNames);
100
+ }
101
+
102
+ /**
103
+ * Apply one or more named states
104
+ * @param {...string} stateNames
105
+ * @returns {FactoryBuilder}
106
+ */
107
+ apply(...stateNames) {
108
+ const clone = this._clone();
109
+ clone._appliedStates.push(...stateNames);
110
+ return clone;
111
+ }
112
+
113
+ /**
114
+ * Merge field overrides
115
+ * @param {Object} overrides
116
+ * @returns {FactoryBuilder}
117
+ */
118
+ merge(overrides = {}) {
119
+ const clone = this._clone();
120
+ clone._overrides = { ...clone._overrides, ...overrides };
121
+ return clone;
122
+ }
123
+
124
+ /**
125
+ * Build single raw record object without persisting
126
+ * @param {number} index
127
+ * @param {Object} [additionalOverrides={}]
128
+ * @returns {Object}
129
+ */
130
+ _buildOne(index = 0, additionalOverrides = {}) {
131
+ const faker = resolveFaker();
132
+ let data = this._definitionFn({ faker, index }) || {};
133
+
134
+ for (const stateName of this._appliedStates) {
135
+ const stateHandler = this._states.get(stateName);
136
+ if (stateHandler) {
137
+ const stateData =
138
+ typeof stateHandler === 'function'
139
+ ? stateHandler({ faker, index, current: data })
140
+ : stateHandler;
141
+ data = { ...data, ...stateData };
142
+ }
143
+ }
144
+
145
+ return { ...data, ...this._overrides, ...additionalOverrides };
146
+ }
147
+
148
+ /**
149
+ * Generate raw data objects in memory (does not insert into DB)
150
+ * @param {Object} [overrides={}]
151
+ * @returns {Object|Object[]}
152
+ */
153
+ make(overrides = {}) {
154
+ if (this._count === 1) {
155
+ return this._buildOne(0, overrides);
156
+ }
157
+ return Array.from({ length: this._count }, (_, i) => this._buildOne(i, overrides));
158
+ }
159
+
160
+ /**
161
+ * Generate and persist record(s) to the database using repository
162
+ * @param {Object} options
163
+ * @param {import('./types').DatabaseInstance|import('./types').Repository} options.db - Database or repository
164
+ * @param {Object} [options.merge={}] - Additional field overrides
165
+ * @returns {Promise<Object|Object[]>}
166
+ */
167
+ async create(options = {}) {
168
+ const target = options.db || options;
169
+ let repo;
170
+
171
+ if (target && typeof target.getRepository === 'function') {
172
+ repo = target.getRepository(this._modelName);
173
+ } else if (target && typeof target.create === 'function') {
174
+ repo = target;
175
+ } else {
176
+ throw new Error(
177
+ `Factory.create() requires a database instance: .create({ db }) for model "${this._modelName}"`
178
+ );
179
+ }
180
+
181
+ const mergeData = options.merge || {};
182
+
183
+ if (this._count === 1) {
184
+ const attributes = this._buildOne(0, mergeData);
185
+ return await repo.create(attributes);
186
+ }
187
+
188
+ const items = Array.from({ length: this._count }, (_, i) => this._buildOne(i, mergeData));
189
+ const results = [];
190
+ for (const item of items) {
191
+ results.push(await repo.create(item));
192
+ }
193
+ return results;
194
+ }
195
+
196
+ /**
197
+ * Clone builder preserving definition and state
198
+ * @protected
199
+ */
200
+ _clone() {
201
+ const clone = new FactoryBuilder(this._modelName, this._definitionFn, this._states);
202
+ clone._appliedStates = [...this._appliedStates];
203
+ clone._count = this._count;
204
+ clone._overrides = { ...this._overrides };
205
+ return clone;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Define a model factory (AdonisJS Lucid-inspired)
211
+ * @param {string} modelName - Model identifier in registry (e.g. 'User')
212
+ * @param {Function} definitionFn - Factory blueprint `({ faker, index }) => ({ ... })`
213
+ * @returns {FactoryBuilder & { state: (name: string, handler: Function|Object) => any }}
214
+ */
215
+ function defineFactory(modelName, definitionFn) {
216
+ if (typeof modelName !== 'string' || !modelName.trim()) {
217
+ throw new Error('defineFactory expects a model name string as first argument');
218
+ }
219
+ if (typeof definitionFn !== 'function') {
220
+ throw new Error(`defineFactory expects a definition function for model "${modelName}"`);
221
+ }
222
+
223
+ const states = new Map();
224
+ const rootBuilder = new FactoryBuilder(modelName, definitionFn, states);
225
+
226
+ // Extend root builder with fluent state registration
227
+ rootBuilder.registerState = function (name, handler) {
228
+ states.set(name, handler);
229
+ return rootBuilder;
230
+ };
231
+
232
+ // Allow .state('admin', fn) as registration when called with 2 args,
233
+ // or as apply when called with 1 arg.
234
+ const originalState = rootBuilder.state.bind(rootBuilder);
235
+ rootBuilder.state = function (nameOrFirst, maybeHandler, ...rest) {
236
+ if (typeof maybeHandler === 'function' || (maybeHandler && typeof maybeHandler === 'object')) {
237
+ states.set(nameOrFirst, maybeHandler);
238
+ return rootBuilder;
239
+ }
240
+ return originalState(nameOrFirst, ...rest);
241
+ };
242
+
243
+ return rootBuilder;
244
+ }
245
+
246
+ module.exports = {
247
+ defineFactory,
248
+ FactoryBuilder,
249
+ resolveFaker,
250
+ };
package/core/orm/index.js CHANGED
@@ -386,4 +386,10 @@ module.exports = {
386
386
  buildSearchClause: require('./search').buildSearchClause,
387
387
  tokenizeSearchTerm: require('./search').tokenizeSearchTerm,
388
388
  detectDialect: require('./search').detectDialect,
389
+ // Model Factory & Seeding (AdonisJS Lucid-inspired)
390
+ defineFactory: require('./factory').defineFactory,
391
+ FactoryBuilder: require('./factory').FactoryBuilder,
392
+ // Schema Diff & Makemigrations (Django-inspired)
393
+ diffSchema: require('./migrations/diff').diffSchema,
394
+ makeMigrations: require('./migrations/diff').makeMigrations,
389
395
  };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Webspresso ORM - Schema Diff & Auto-Migration Generator (Django-inspired makemigrations)
3
+ * Compares ORM models with live database schema and generates migrations
4
+ * @module core/orm/migrations/diff
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { getAllModels } = require('../model');
10
+ const {
11
+ scaffoldMigration,
12
+ scaffoldAlterMigration,
13
+ generateMigrationName,
14
+ } = require('./scaffold');
15
+ const {
16
+ migrationTimestamp,
17
+ sortModelsByDependencies,
18
+ loadModelsFromDirectory,
19
+ } = require('../../../bin/utils/model-migrations');
20
+
21
+ /**
22
+ * Compare models with existing database schema to find diffs
23
+ * @param {Object} options
24
+ * @param {import('knex').Knex} options.knex - Active Knex instance
25
+ * @param {import('../types').ModelDefinition[]} [options.models] - List of models to diff
26
+ * @param {boolean} [options.includeDrops=false] - Whether to generate dropColumn for removed fields
27
+ * @returns {Promise<{ hasChanges: boolean, changes: Array<Object> }>}
28
+ */
29
+ async function diffSchema(options = {}) {
30
+ const {
31
+ knex,
32
+ models = [...getAllModels().values()],
33
+ includeDrops = false,
34
+ } = options;
35
+
36
+ if (!knex) {
37
+ throw new Error('diffSchema requires an active knex instance');
38
+ }
39
+
40
+ const sortedModels = sortModelsByDependencies(models);
41
+ const changes = [];
42
+
43
+ for (const model of sortedModels) {
44
+ const tableExists = await knex.schema.hasTable(model.table);
45
+
46
+ if (!tableExists) {
47
+ // Table doesn't exist at all -> generate createTable
48
+ const migrationName = generateMigrationName(model, 'create');
49
+ const content = scaffoldMigration(model);
50
+ changes.push({
51
+ model: model.name,
52
+ table: model.table,
53
+ type: 'create_table',
54
+ addedColumns: Array.from(model.columns.keys()),
55
+ removedColumns: [],
56
+ migrationName,
57
+ content,
58
+ });
59
+ continue;
60
+ }
61
+
62
+ // Table exists -> inspect columns
63
+ const columnInfo = await knex(model.table).columnInfo();
64
+ const existingColNames = new Set(Object.keys(columnInfo));
65
+
66
+ const addedColumns = new Map();
67
+ for (const [colName, colMeta] of model.columns.entries()) {
68
+ if (!existingColNames.has(colName)) {
69
+ addedColumns.set(colName, colMeta);
70
+ }
71
+ }
72
+
73
+ const removedColumnNames = [];
74
+ if (includeDrops) {
75
+ for (const existingCol of existingColNames) {
76
+ // Skip knex internal migrations or common internal columns if needed
77
+ if (!model.columns.has(existingCol)) {
78
+ removedColumnNames.push(existingCol);
79
+ }
80
+ }
81
+ }
82
+
83
+ if (addedColumns.size > 0) {
84
+ const addedKeys = Array.from(addedColumns.keys());
85
+ const prefix = addedKeys.slice(0, 3).join('_');
86
+ const migrationName = `add_${prefix}${addedKeys.length > 3 ? '_etc' : ''}_to_${model.table}_table`;
87
+ const content = scaffoldAlterMigration(model.table, addedColumns);
88
+
89
+ changes.push({
90
+ model: model.name,
91
+ table: model.table,
92
+ type: 'alter_table',
93
+ addedColumns: addedKeys,
94
+ removedColumns: removedColumnNames,
95
+ migrationName,
96
+ content,
97
+ });
98
+ }
99
+ }
100
+
101
+ return {
102
+ hasChanges: changes.length > 0,
103
+ changes,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * High-level makemigrations execution: diffs schema and writes migration files
109
+ * @param {Object} options
110
+ * @param {import('knex').Knex} options.knex - Knex instance
111
+ * @param {string} [options.modelsDir='./models'] - Models directory
112
+ * @param {string} [options.migrationDir='./migrations'] - Target migrations directory
113
+ * @param {boolean} [options.dryRun=false] - Preview without writing files
114
+ * @param {boolean} [options.includeDrops=false] - Include dropColumn operations
115
+ * @param {string} [options.name] - Custom migration name override
116
+ * @returns {Promise<{ hasChanges: boolean, created: Array<Object>, skipped: Array<Object> }>}
117
+ */
118
+ async function makeMigrations(options = {}) {
119
+ const {
120
+ knex,
121
+ modelsDir = './models',
122
+ migrationDir = './migrations',
123
+ dryRun = false,
124
+ includeDrops = false,
125
+ name = null,
126
+ } = options;
127
+
128
+ let models;
129
+ if (fs.existsSync(path.resolve(process.cwd(), modelsDir))) {
130
+ models = loadModelsFromDirectory(modelsDir);
131
+ } else {
132
+ models = [...getAllModels().values()];
133
+ }
134
+
135
+ const { hasChanges, changes } = await diffSchema({
136
+ knex,
137
+ models,
138
+ includeDrops,
139
+ });
140
+
141
+ const created = [];
142
+ const skipped = [];
143
+
144
+ if (!hasChanges) {
145
+ return { hasChanges: false, created, skipped };
146
+ }
147
+
148
+ const absoluteMigrationDir = path.resolve(process.cwd(), migrationDir);
149
+ if (!dryRun && !fs.existsSync(absoluteMigrationDir)) {
150
+ fs.mkdirSync(absoluteMigrationDir, { recursive: true });
151
+ }
152
+
153
+ changes.forEach((change, index) => {
154
+ const customName = name && changes.length === 1 ? name : change.migrationName;
155
+ const stamp = migrationTimestamp(new Date(Date.now() + index * 1000));
156
+ const filename = `${stamp}_${customName}.js`;
157
+ const filepath = path.join(absoluteMigrationDir, filename);
158
+
159
+ if (!dryRun) {
160
+ fs.writeFileSync(filepath, change.content, 'utf8');
161
+ }
162
+
163
+ created.push({
164
+ model: change.model,
165
+ table: change.table,
166
+ type: change.type,
167
+ addedColumns: change.addedColumns,
168
+ removedColumns: change.removedColumns,
169
+ file: filename,
170
+ path: filepath,
171
+ content: change.content,
172
+ });
173
+ });
174
+
175
+ return {
176
+ hasChanges: true,
177
+ created,
178
+ skipped,
179
+ };
180
+ }
181
+
182
+ module.exports = {
183
+ diffSchema,
184
+ makeMigrations,
185
+ };
@@ -167,11 +167,27 @@ function createMigrationManager(knex, config = {}) {
167
167
  },
168
168
 
169
169
  /**
170
- * Unlock stuck migrations
171
- * @returns {Promise<void>}
170
+ * Compare models with live database schema
171
+ * @param {Object} [options={}]
172
+ * @returns {Promise<{ hasChanges: boolean, changes: Array<Object> }>}
173
+ */
174
+ async diff(options = {}) {
175
+ const { diffSchema } = require('./diff');
176
+ return diffSchema({ knex, ...options });
177
+ },
178
+
179
+ /**
180
+ * Auto-generate migrations from schema diffs (Django-inspired makemigrations)
181
+ * @param {Object} [options={}]
182
+ * @returns {Promise<{ hasChanges: boolean, created: Array<Object>, skipped: Array<Object> }>}
172
183
  */
173
- async unlock() {
174
- await knex.migrate.forceFreeMigrationsLock(migrationConfig);
184
+ async makeMigrations(options = {}) {
185
+ const { makeMigrations } = require('./diff');
186
+ return makeMigrations({
187
+ knex,
188
+ migrationDir: directory,
189
+ ...options,
190
+ });
175
191
  },
176
192
  };
177
193
  }
@@ -201,5 +217,7 @@ exports.down = function(knex) {
201
217
  module.exports = {
202
218
  createMigrationManager,
203
219
  getDefaultMigrationTemplate,
220
+ diffSchema: require('./diff').diffSchema,
221
+ makeMigrations: require('./diff').makeMigrations,
204
222
  };
205
223
 
package/core/orm/model.js CHANGED
@@ -138,6 +138,10 @@ function defineModel(options) {
138
138
  bulkActions: Array.isArray(admin.bulkActions) ? admin.bulkActions : null,
139
139
  hooks: typeof admin.hooks === 'object' && admin.hooks !== null ? admin.hooks : null,
140
140
  relations: typeof admin.relations === 'object' && admin.relations !== null ? admin.relations : null,
141
+ inlines: Array.isArray(admin.inlines) ? admin.inlines : null,
142
+ listEditable: Array.isArray(admin.listEditable) ? admin.listEditable : null,
143
+ dateHierarchy: typeof admin.dateHierarchy === 'string' ? admin.dateHierarchy : null,
144
+ listFilter: Array.isArray(admin.listFilter) ? admin.listFilter : (Array.isArray(admin.filterable) ? admin.filterable : null),
141
145
  },
142
146
  rest: {
143
147
  enabled: rest.enabled === true,