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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Webspresso ORM - Migration Manager
3
+ * Wraps Knex migrations API
4
+ * @module core/orm/migrations
5
+ */
6
+
7
+ const { generateMigrationTimestamp } = require('../utils');
8
+
9
+ /**
10
+ * Create a migration manager
11
+ * @param {import('knex').Knex} knex - Knex instance
12
+ * @param {import('../types').MigrationConfig} config - Migration configuration
13
+ * @returns {import('../types').MigrationManager}
14
+ */
15
+ function createMigrationManager(knex, config = {}) {
16
+ const {
17
+ directory = './migrations',
18
+ tableName = 'knex_migrations',
19
+ } = config;
20
+
21
+ const migrationConfig = {
22
+ directory,
23
+ tableName,
24
+ };
25
+
26
+ return {
27
+ /**
28
+ * Run all pending migrations
29
+ * @returns {Promise<import('../types').MigrationResult>}
30
+ */
31
+ async latest() {
32
+ const [batch, migrations] = await knex.migrate.latest(migrationConfig);
33
+ return {
34
+ batch,
35
+ migrations,
36
+ };
37
+ },
38
+
39
+ /**
40
+ * Rollback migrations
41
+ * @param {Object} [options={}]
42
+ * @param {boolean} [options.all=false] - Rollback all migrations
43
+ * @returns {Promise<import('../types').MigrationResult>}
44
+ */
45
+ async rollback(options = {}) {
46
+ const rollbackConfig = {
47
+ ...migrationConfig,
48
+ ...(options.all ? { all: true } : {}),
49
+ };
50
+ const [batch, migrations] = await knex.migrate.rollback(rollbackConfig);
51
+ return {
52
+ batch,
53
+ migrations,
54
+ };
55
+ },
56
+
57
+ /**
58
+ * Get current migration version
59
+ * @returns {Promise<string>}
60
+ */
61
+ async currentVersion() {
62
+ return knex.migrate.currentVersion(migrationConfig);
63
+ },
64
+
65
+ /**
66
+ * Get migration status
67
+ * @returns {Promise<import('../types').MigrationStatus[]>}
68
+ */
69
+ async status() {
70
+ // Get completed migrations from database
71
+ const completedResult = await knex.migrate.list(migrationConfig);
72
+ const [completed, pending] = completedResult;
73
+
74
+ const statuses = [];
75
+
76
+ // Add completed migrations
77
+ for (const migration of completed) {
78
+ statuses.push({
79
+ name: migration.name || migration,
80
+ completed: true,
81
+ ran_at: migration.migration_time || null,
82
+ batch: migration.batch || null,
83
+ });
84
+ }
85
+
86
+ // Add pending migrations
87
+ for (const migration of pending) {
88
+ statuses.push({
89
+ name: migration.name || migration,
90
+ completed: false,
91
+ ran_at: null,
92
+ batch: null,
93
+ });
94
+ }
95
+
96
+ // Sort by name
97
+ statuses.sort((a, b) => a.name.localeCompare(b.name));
98
+
99
+ return statuses;
100
+ },
101
+
102
+ /**
103
+ * Create a new migration file
104
+ * @param {string} name - Migration name
105
+ * @param {Object} [options={}]
106
+ * @param {string} [options.content] - Custom migration content
107
+ * @returns {Promise<string>} Created file path
108
+ */
109
+ async make(name, options = {}) {
110
+ const { content } = options;
111
+
112
+ if (content) {
113
+ // Use custom stub with content
114
+ const timestamp = generateMigrationTimestamp();
115
+ const filename = `${timestamp}_${name}.js`;
116
+
117
+ // Knex's make doesn't support custom content directly,
118
+ // so we return the filename and content for the CLI to write
119
+ return {
120
+ filename,
121
+ filepath: `${directory}/${filename}`,
122
+ content,
123
+ };
124
+ }
125
+
126
+ // Use default Knex make
127
+ const result = await knex.migrate.make(name, migrationConfig);
128
+ return {
129
+ filename: result.split('/').pop(),
130
+ filepath: result,
131
+ content: null,
132
+ };
133
+ },
134
+
135
+ /**
136
+ * Run specific migration up
137
+ * @param {string} name - Migration name
138
+ * @returns {Promise<void>}
139
+ */
140
+ async up(name) {
141
+ await knex.migrate.up({ ...migrationConfig, name });
142
+ },
143
+
144
+ /**
145
+ * Run specific migration down
146
+ * @param {string} name - Migration name
147
+ * @returns {Promise<void>}
148
+ */
149
+ async down(name) {
150
+ await knex.migrate.down({ ...migrationConfig, name });
151
+ },
152
+
153
+ /**
154
+ * Get the migration configuration
155
+ * @returns {Object}
156
+ */
157
+ getConfig() {
158
+ return { ...migrationConfig };
159
+ },
160
+
161
+ /**
162
+ * Check if migrations table exists
163
+ * @returns {Promise<boolean>}
164
+ */
165
+ async hasTable() {
166
+ return knex.schema.hasTable(tableName);
167
+ },
168
+
169
+ /**
170
+ * Unlock stuck migrations
171
+ * @returns {Promise<void>}
172
+ */
173
+ async unlock() {
174
+ await knex.migrate.forceFreeMigrationsLock(migrationConfig);
175
+ },
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Default migration template
181
+ * @returns {string}
182
+ */
183
+ function getDefaultMigrationTemplate() {
184
+ return `/**
185
+ * Migration:
186
+ */
187
+
188
+ exports.up = function(knex) {
189
+ return knex.schema.createTable('table_name', (table) => {
190
+ table.bigIncrements('id').primary();
191
+ table.timestamps(true, true);
192
+ });
193
+ };
194
+
195
+ exports.down = function(knex) {
196
+ return knex.schema.dropTableIfExists('table_name');
197
+ };
198
+ `;
199
+ }
200
+
201
+ module.exports = {
202
+ createMigrationManager,
203
+ getDefaultMigrationTemplate,
204
+ };
205
+
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Webspresso ORM - Migration Scaffolding
3
+ * Generate migration code from model schema
4
+ * @module core/orm/migrations/scaffold
5
+ */
6
+
7
+ const { getColumnMeta } = require('../schema-helpers');
8
+
9
+ /**
10
+ * Generate migration code from a model definition
11
+ * @param {import('../types').ModelDefinition} model - Model definition
12
+ * @returns {string} Migration file content
13
+ */
14
+ function scaffoldMigration(model) {
15
+ const { table, columns, scopes } = model;
16
+
17
+ const columnLines = [];
18
+ const indexLines = [];
19
+ const foreignKeyLines = [];
20
+
21
+ // Process each column
22
+ for (const [columnName, meta] of columns.entries()) {
23
+ const { line, indexLine, fkLine } = generateColumnLine(columnName, meta);
24
+ columnLines.push(line);
25
+ if (indexLine) indexLines.push(indexLine);
26
+ if (fkLine) foreignKeyLines.push(fkLine);
27
+ }
28
+
29
+ // Generate the migration content
30
+ const lines = [
31
+ '/**',
32
+ ` * Migration: Create ${table} table`,
33
+ ' * Auto-generated from model schema',
34
+ ' */',
35
+ '',
36
+ 'exports.up = function(knex) {',
37
+ ` return knex.schema.createTable('${table}', (table) => {`,
38
+ ];
39
+
40
+ // Add column definitions
41
+ for (const line of columnLines) {
42
+ lines.push(` ${line}`);
43
+ }
44
+
45
+ // Add indexes
46
+ if (indexLines.length > 0) {
47
+ lines.push('');
48
+ lines.push(' // Indexes');
49
+ for (const line of indexLines) {
50
+ lines.push(` ${line}`);
51
+ }
52
+ }
53
+
54
+ // Add foreign keys
55
+ if (foreignKeyLines.length > 0) {
56
+ lines.push('');
57
+ lines.push(' // Foreign keys');
58
+ for (const line of foreignKeyLines) {
59
+ lines.push(` ${line}`);
60
+ }
61
+ }
62
+
63
+ lines.push(' });');
64
+ lines.push('};');
65
+ lines.push('');
66
+ lines.push('exports.down = function(knex) {');
67
+ lines.push(` return knex.schema.dropTableIfExists('${table}');`);
68
+ lines.push('};');
69
+ lines.push('');
70
+
71
+ return lines.join('\n');
72
+ }
73
+
74
+ /**
75
+ * Generate a single column line for migration
76
+ * @param {string} columnName - Column name
77
+ * @param {import('../types').ColumnMeta} meta - Column metadata
78
+ * @returns {{ line: string, indexLine: string|null, fkLine: string|null }}
79
+ */
80
+ function generateColumnLine(columnName, meta) {
81
+ const parts = [];
82
+ let indexLine = null;
83
+ let fkLine = null;
84
+
85
+ // Determine column type and method
86
+ switch (meta.type) {
87
+ case 'bigint':
88
+ if (meta.primary && meta.autoIncrement) {
89
+ parts.push(`table.bigIncrements('${columnName}')`);
90
+ } else if (meta.references) {
91
+ parts.push(`table.bigInteger('${columnName}').unsigned()`);
92
+ fkLine = `table.foreign('${columnName}').references('${meta.referenceColumn || 'id'}').inTable('${meta.references}');`;
93
+ } else {
94
+ parts.push(`table.bigInteger('${columnName}')`);
95
+ }
96
+ break;
97
+
98
+ case 'integer':
99
+ if (meta.primary && meta.autoIncrement) {
100
+ parts.push(`table.increments('${columnName}')`);
101
+ } else {
102
+ parts.push(`table.integer('${columnName}')`);
103
+ }
104
+ break;
105
+
106
+ case 'string':
107
+ const maxLength = meta.maxLength || 255;
108
+ parts.push(`table.string('${columnName}', ${maxLength})`);
109
+ break;
110
+
111
+ case 'text':
112
+ parts.push(`table.text('${columnName}')`);
113
+ break;
114
+
115
+ case 'float':
116
+ parts.push(`table.float('${columnName}')`);
117
+ break;
118
+
119
+ case 'decimal':
120
+ const precision = meta.precision || 10;
121
+ const scale = meta.scale || 2;
122
+ parts.push(`table.decimal('${columnName}', ${precision}, ${scale})`);
123
+ break;
124
+
125
+ case 'boolean':
126
+ parts.push(`table.boolean('${columnName}')`);
127
+ break;
128
+
129
+ case 'date':
130
+ parts.push(`table.date('${columnName}')`);
131
+ break;
132
+
133
+ case 'datetime':
134
+ parts.push(`table.datetime('${columnName}')`);
135
+ break;
136
+
137
+ case 'timestamp':
138
+ parts.push(`table.timestamp('${columnName}')`);
139
+ break;
140
+
141
+ case 'json':
142
+ parts.push(`table.json('${columnName}')`);
143
+ break;
144
+
145
+ case 'enum':
146
+ const enumValues = meta.enumValues || [];
147
+ const valuesStr = enumValues.map(v => `'${v}'`).join(', ');
148
+ parts.push(`table.enum('${columnName}', [${valuesStr}])`);
149
+ break;
150
+
151
+ case 'uuid':
152
+ if (meta.primary) {
153
+ parts.push(`table.uuid('${columnName}')`);
154
+ } else if (meta.references) {
155
+ parts.push(`table.uuid('${columnName}')`);
156
+ fkLine = `table.foreign('${columnName}').references('${meta.referenceColumn || 'id'}').inTable('${meta.references}');`;
157
+ } else {
158
+ parts.push(`table.uuid('${columnName}')`);
159
+ }
160
+ break;
161
+
162
+ default:
163
+ parts.push(`table.string('${columnName}')`);
164
+ }
165
+
166
+ // Add constraints
167
+ if (meta.primary && !meta.autoIncrement) {
168
+ parts.push('.primary()');
169
+ }
170
+
171
+ if (meta.unique) {
172
+ parts.push('.unique()');
173
+ }
174
+
175
+ if (meta.nullable) {
176
+ parts.push('.nullable()');
177
+ } else if (!meta.primary) {
178
+ parts.push('.notNullable()');
179
+ }
180
+
181
+ if (meta.default !== undefined) {
182
+ if (typeof meta.default === 'string') {
183
+ parts.push(`.defaultTo('${meta.default}')`);
184
+ } else if (meta.default === null) {
185
+ parts.push('.defaultTo(null)');
186
+ } else {
187
+ parts.push(`.defaultTo(${meta.default})`);
188
+ }
189
+ }
190
+
191
+ // Auto timestamps get default to knex.fn.now()
192
+ if (meta.auto === 'create' || meta.auto === 'update') {
193
+ parts.push('.defaultTo(knex.fn.now())');
194
+ }
195
+
196
+ // Generate index line if needed
197
+ if (meta.index && !meta.unique && !meta.primary) {
198
+ indexLine = `table.index(['${columnName}']);`;
199
+ }
200
+
201
+ return {
202
+ line: parts.join('') + ';',
203
+ indexLine,
204
+ fkLine,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Generate migration code for adding columns to existing table
210
+ * @param {string} tableName - Table name
211
+ * @param {Map<string, import('../types').ColumnMeta>} columns - Columns to add
212
+ * @returns {string} Migration file content
213
+ */
214
+ function scaffoldAlterMigration(tableName, columns) {
215
+ const columnLines = [];
216
+ const indexLines = [];
217
+ const foreignKeyLines = [];
218
+ const dropLines = [];
219
+
220
+ for (const [columnName, meta] of columns.entries()) {
221
+ const { line, indexLine, fkLine } = generateColumnLine(columnName, meta);
222
+ columnLines.push(line);
223
+ if (indexLine) indexLines.push(indexLine);
224
+ if (fkLine) foreignKeyLines.push(fkLine);
225
+ dropLines.push(`table.dropColumn('${columnName}');`);
226
+ }
227
+
228
+ const lines = [
229
+ '/**',
230
+ ` * Migration: Alter ${tableName} table`,
231
+ ' * Auto-generated',
232
+ ' */',
233
+ '',
234
+ 'exports.up = function(knex) {',
235
+ ` return knex.schema.alterTable('${tableName}', (table) => {`,
236
+ ];
237
+
238
+ for (const line of columnLines) {
239
+ lines.push(` ${line}`);
240
+ }
241
+
242
+ if (indexLines.length > 0) {
243
+ lines.push('');
244
+ for (const line of indexLines) {
245
+ lines.push(` ${line}`);
246
+ }
247
+ }
248
+
249
+ if (foreignKeyLines.length > 0) {
250
+ lines.push('');
251
+ for (const line of foreignKeyLines) {
252
+ lines.push(` ${line}`);
253
+ }
254
+ }
255
+
256
+ lines.push(' });');
257
+ lines.push('};');
258
+ lines.push('');
259
+ lines.push('exports.down = function(knex) {');
260
+ lines.push(` return knex.schema.alterTable('${tableName}', (table) => {`);
261
+
262
+ for (const line of dropLines) {
263
+ lines.push(` ${line}`);
264
+ }
265
+
266
+ lines.push(' });');
267
+ lines.push('};');
268
+ lines.push('');
269
+
270
+ return lines.join('\n');
271
+ }
272
+
273
+ /**
274
+ * Generate migration code for a drop table
275
+ * @param {string} tableName - Table name
276
+ * @returns {string} Migration file content
277
+ */
278
+ function scaffoldDropMigration(tableName) {
279
+ return `/**
280
+ * Migration: Drop ${tableName} table
281
+ */
282
+
283
+ exports.up = function(knex) {
284
+ return knex.schema.dropTableIfExists('${tableName}');
285
+ };
286
+
287
+ exports.down = function(knex) {
288
+ // Note: This down migration is empty because we don't know the original schema.
289
+ // If you need to restore the table, please add the schema manually.
290
+ return Promise.resolve();
291
+ };
292
+ `;
293
+ }
294
+
295
+ /**
296
+ * Generate migration name from model
297
+ * @param {import('../types').ModelDefinition} model - Model definition
298
+ * @param {string} [action='create'] - Action (create, alter, drop)
299
+ * @returns {string}
300
+ */
301
+ function generateMigrationName(model, action = 'create') {
302
+ return `${action}_${model.table}_table`;
303
+ }
304
+
305
+ module.exports = {
306
+ scaffoldMigration,
307
+ scaffoldAlterMigration,
308
+ scaffoldDropMigration,
309
+ generateColumnLine,
310
+ generateMigrationName,
311
+ };
312
+
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Webspresso ORM - Model Definition
3
+ * Define models and maintain a registry
4
+ * @module core/orm/model
5
+ */
6
+
7
+ const { extractColumnsFromSchema } = require('./schema-helpers');
8
+
9
+ /**
10
+ * Global model registry
11
+ * @type {Map<string, import('./types').ModelDefinition>}
12
+ */
13
+ const modelRegistry = new Map();
14
+
15
+ /**
16
+ * Define a new model
17
+ * @param {import('./types').ModelOptions} options - Model configuration
18
+ * @returns {import('./types').ModelDefinition}
19
+ */
20
+ function defineModel(options) {
21
+ const {
22
+ name,
23
+ table,
24
+ schema,
25
+ primaryKey = 'id',
26
+ relations = {},
27
+ scopes = {},
28
+ } = options;
29
+
30
+ // Validate required fields
31
+ if (!name || typeof name !== 'string') {
32
+ throw new Error('Model name is required and must be a string');
33
+ }
34
+ if (!table || typeof table !== 'string') {
35
+ throw new Error('Model table is required and must be a string');
36
+ }
37
+ if (!schema || typeof schema.parse !== 'function') {
38
+ throw new Error('Model schema is required and must be a Zod schema');
39
+ }
40
+
41
+ // Check for duplicate registration
42
+ if (modelRegistry.has(name)) {
43
+ throw new Error(`Model "${name}" is already defined`);
44
+ }
45
+
46
+ // Extract column metadata from schema
47
+ const columns = extractColumnsFromSchema(schema);
48
+
49
+ // Validate relations
50
+ for (const [relationName, relation] of Object.entries(relations)) {
51
+ if (!['belongsTo', 'hasMany', 'hasOne'].includes(relation.type)) {
52
+ throw new Error(
53
+ `Invalid relation type "${relation.type}" for "${relationName}" in model "${name}"`
54
+ );
55
+ }
56
+ if (typeof relation.model !== 'function') {
57
+ throw new Error(
58
+ `Relation "${relationName}" in model "${name}" must have a model function`
59
+ );
60
+ }
61
+ if (!relation.foreignKey || typeof relation.foreignKey !== 'string') {
62
+ throw new Error(
63
+ `Relation "${relationName}" in model "${name}" must have a foreignKey string`
64
+ );
65
+ }
66
+ }
67
+
68
+ // Create model definition
69
+ const model = {
70
+ name,
71
+ table,
72
+ schema,
73
+ primaryKey,
74
+ relations,
75
+ scopes: {
76
+ softDelete: scopes.softDelete || false,
77
+ timestamps: scopes.timestamps || false,
78
+ tenant: scopes.tenant || null,
79
+ },
80
+ columns,
81
+ };
82
+
83
+ // Register model
84
+ modelRegistry.set(name, model);
85
+
86
+ return model;
87
+ }
88
+
89
+ /**
90
+ * Get a model by name
91
+ * @param {string} name - Model name
92
+ * @returns {import('./types').ModelDefinition|undefined}
93
+ */
94
+ function getModel(name) {
95
+ return modelRegistry.get(name);
96
+ }
97
+
98
+ /**
99
+ * Get all registered models
100
+ * @returns {Map<string, import('./types').ModelDefinition>}
101
+ */
102
+ function getAllModels() {
103
+ return new Map(modelRegistry);
104
+ }
105
+
106
+ /**
107
+ * Check if a model exists
108
+ * @param {string} name - Model name
109
+ * @returns {boolean}
110
+ */
111
+ function hasModel(name) {
112
+ return modelRegistry.has(name);
113
+ }
114
+
115
+ /**
116
+ * Clear the model registry (useful for testing)
117
+ */
118
+ function clearRegistry() {
119
+ modelRegistry.clear();
120
+ }
121
+
122
+ /**
123
+ * Unregister a model by name
124
+ * @param {string} name - Model name
125
+ * @returns {boolean} Whether the model was removed
126
+ */
127
+ function unregisterModel(name) {
128
+ return modelRegistry.delete(name);
129
+ }
130
+
131
+ /**
132
+ * Resolve a relation's model (handles lazy loading)
133
+ * @param {import('./types').RelationDefinition} relation - Relation definition
134
+ * @returns {import('./types').ModelDefinition}
135
+ */
136
+ function resolveRelationModel(relation) {
137
+ const model = relation.model();
138
+ if (!model || !model.name) {
139
+ throw new Error('Invalid relation model reference');
140
+ }
141
+ return model;
142
+ }
143
+
144
+ /**
145
+ * Get the foreign key column info for a relation
146
+ * @param {import('./types').ModelDefinition} model - Parent model
147
+ * @param {string} relationName - Relation name
148
+ * @returns {{ localKey: string, foreignKey: string, relatedModel: import('./types').ModelDefinition }}
149
+ */
150
+ function getRelationKeys(model, relationName) {
151
+ const relation = model.relations[relationName];
152
+ if (!relation) {
153
+ throw new Error(`Relation "${relationName}" not found on model "${model.name}"`);
154
+ }
155
+
156
+ const relatedModel = resolveRelationModel(relation);
157
+ const localKey = relation.localKey || model.primaryKey;
158
+
159
+ return {
160
+ localKey,
161
+ foreignKey: relation.foreignKey,
162
+ relatedModel,
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ defineModel,
168
+ getModel,
169
+ getAllModels,
170
+ hasModel,
171
+ clearRegistry,
172
+ unregisterModel,
173
+ resolveRelationModel,
174
+ getRelationKeys,
175
+ // Export registry for testing
176
+ _registry: modelRegistry,
177
+ };
178
+