webspresso 0.0.6 → 0.0.8

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,585 @@
1
+ /**
2
+ * Auto Seeder
3
+ * Generate fake data based on Zod schema metadata
4
+ * Uses @faker-js/faker for data generation
5
+ */
6
+
7
+ const { getModel, getAllModels } = require('./model');
8
+
9
+ /**
10
+ * @typedef {Object} SeederOptions
11
+ * @property {number} [count=10] - Number of records to create
12
+ * @property {Object} [overrides={}] - Override specific fields
13
+ * @property {Object} [with={}] - Create related records { posts: 3 }
14
+ * @property {Object} [generators={}] - Custom generators per field
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} FactoryDefinition
19
+ * @property {Object} model - Model definition
20
+ * @property {Object} [defaults={}] - Default overrides
21
+ * @property {Object} [generators={}] - Custom field generators
22
+ * @property {Object} [states={}] - Named state modifiers
23
+ */
24
+
25
+ /**
26
+ * Create a seeder instance
27
+ * @param {Object} faker - Faker instance (@faker-js/faker)
28
+ * @param {Object} knex - Knex instance
29
+ * @returns {Object} Seeder API
30
+ */
31
+ function createSeeder(faker, knex) {
32
+ if (!faker) {
33
+ throw new Error('Faker instance is required. Install @faker-js/faker and pass it to createSeeder.');
34
+ }
35
+
36
+ /** @type {Map<string, FactoryDefinition>} */
37
+ const factories = new Map();
38
+
39
+ /**
40
+ * Generate fake value based on column metadata
41
+ * @param {string} columnName - Column name
42
+ * @param {Object} meta - Column metadata
43
+ * @returns {*} Generated value
44
+ */
45
+ function generateValue(columnName, meta) {
46
+ // Skip auto-generated fields
47
+ if (meta.autoIncrement || meta.auto === 'create' || meta.auto === 'update') {
48
+ return undefined;
49
+ }
50
+
51
+ // Handle nullable with 10% chance of null
52
+ if (meta.nullable && faker.datatype.boolean({ probability: 0.1 })) {
53
+ return null;
54
+ }
55
+
56
+ // Use default if defined (50% chance)
57
+ if (meta.default !== undefined && faker.datatype.boolean({ probability: 0.5 })) {
58
+ return meta.default;
59
+ }
60
+
61
+ // Smart field detection by name
62
+ const lowerName = columnName.toLowerCase();
63
+
64
+ // Email detection
65
+ if (lowerName.includes('email')) {
66
+ return faker.internet.email().toLowerCase();
67
+ }
68
+
69
+ // Name detection
70
+ if (lowerName === 'name' || lowerName === 'full_name' || lowerName === 'fullname') {
71
+ return faker.person.fullName();
72
+ }
73
+ if (lowerName === 'first_name' || lowerName === 'firstname') {
74
+ return faker.person.firstName();
75
+ }
76
+ if (lowerName === 'last_name' || lowerName === 'lastname') {
77
+ return faker.person.lastName();
78
+ }
79
+ if (lowerName === 'username' || lowerName === 'user_name') {
80
+ return faker.internet.username().toLowerCase();
81
+ }
82
+
83
+ // Title/Slug detection
84
+ if (lowerName === 'title') {
85
+ return faker.lorem.sentence({ min: 3, max: 8 }).slice(0, -1);
86
+ }
87
+ if (lowerName === 'slug') {
88
+ return faker.lorem.slug({ min: 2, max: 4 });
89
+ }
90
+
91
+ // Content detection
92
+ if (lowerName === 'content' || lowerName === 'body' || lowerName === 'description') {
93
+ return faker.lorem.paragraphs({ min: 1, max: 3 });
94
+ }
95
+ if (lowerName === 'bio' || lowerName === 'about') {
96
+ return faker.lorem.paragraph();
97
+ }
98
+ if (lowerName === 'summary' || lowerName === 'excerpt') {
99
+ return faker.lorem.sentence();
100
+ }
101
+
102
+ // URL detection
103
+ if (lowerName.includes('url') || lowerName.includes('link')) {
104
+ return faker.internet.url();
105
+ }
106
+ if (lowerName === 'avatar' || lowerName === 'image' || lowerName === 'photo') {
107
+ return faker.image.avatar();
108
+ }
109
+
110
+ // Phone detection
111
+ if (lowerName.includes('phone') || lowerName.includes('tel')) {
112
+ return faker.phone.number();
113
+ }
114
+
115
+ // Address detection
116
+ if (lowerName === 'address' || lowerName === 'street') {
117
+ return faker.location.streetAddress();
118
+ }
119
+ if (lowerName === 'city') {
120
+ return faker.location.city();
121
+ }
122
+ if (lowerName === 'country') {
123
+ return faker.location.country();
124
+ }
125
+ if (lowerName === 'zip' || lowerName === 'zipcode' || lowerName === 'postal_code') {
126
+ return faker.location.zipCode();
127
+ }
128
+
129
+ // Company detection
130
+ if (lowerName === 'company' || lowerName === 'company_name') {
131
+ return faker.company.name();
132
+ }
133
+
134
+ // Price/Amount detection
135
+ if (lowerName.includes('price') || lowerName.includes('amount') || lowerName.includes('cost')) {
136
+ return parseFloat(faker.commerce.price({ min: 10, max: 1000 }));
137
+ }
138
+
139
+ // Count/Quantity detection
140
+ if (lowerName.includes('count') || lowerName.includes('quantity') || lowerName.includes('qty')) {
141
+ return faker.number.int({ min: 1, max: 100 });
142
+ }
143
+
144
+ // Status detection
145
+ if (lowerName === 'status') {
146
+ return faker.helpers.arrayElement(['active', 'inactive', 'pending']);
147
+ }
148
+
149
+ // Generate by type
150
+ return generateByType(meta);
151
+ }
152
+
153
+ /**
154
+ * Generate value by column type
155
+ * @param {Object} meta - Column metadata
156
+ * @returns {*} Generated value
157
+ */
158
+ function generateByType(meta) {
159
+ switch (meta.type) {
160
+ case 'bigint':
161
+ case 'integer':
162
+ return faker.number.int({ min: 1, max: 10000 });
163
+
164
+ case 'float':
165
+ return faker.number.float({ min: 0, max: 1000, fractionDigits: 2 });
166
+
167
+ case 'decimal':
168
+ const precision = meta.precision || 10;
169
+ const scale = meta.scale || 2;
170
+ const max = Math.pow(10, precision - scale) - 1;
171
+ return parseFloat(faker.number.float({ min: 0, max, fractionDigits: scale }).toFixed(scale));
172
+
173
+ case 'boolean':
174
+ return faker.datatype.boolean();
175
+
176
+ case 'date':
177
+ return faker.date.past().toISOString().split('T')[0];
178
+
179
+ case 'datetime':
180
+ case 'timestamp':
181
+ return faker.date.past().toISOString();
182
+
183
+ case 'uuid':
184
+ return faker.string.uuid();
185
+
186
+ case 'json':
187
+ return { key: faker.lorem.word(), value: faker.lorem.sentence() };
188
+
189
+ case 'enum':
190
+ if (meta.enumValues && meta.enumValues.length > 0) {
191
+ return faker.helpers.arrayElement(meta.enumValues);
192
+ }
193
+ return null;
194
+
195
+ case 'text':
196
+ return faker.lorem.paragraphs({ min: 1, max: 3 });
197
+
198
+ case 'string':
199
+ default:
200
+ const maxLength = meta.maxLength || 255;
201
+ const text = faker.lorem.sentence();
202
+ return text.length > maxLength ? text.substring(0, maxLength) : text;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Generate a single record for a model
208
+ * @param {Object} model - Model definition
209
+ * @param {Object} overrides - Field overrides
210
+ * @param {Object} generators - Custom generators
211
+ * @returns {Object} Generated record
212
+ */
213
+ function generateRecord(model, overrides = {}, generators = {}) {
214
+ const record = {};
215
+
216
+ if (!model.columns) {
217
+ throw new Error(`Model "${model.name}" has no columns defined`);
218
+ }
219
+
220
+ for (const [columnName, meta] of model.columns) {
221
+ // Skip if overridden
222
+ if (columnName in overrides) {
223
+ record[columnName] = overrides[columnName];
224
+ continue;
225
+ }
226
+
227
+ // Use custom generator if provided
228
+ if (columnName in generators) {
229
+ const generator = generators[columnName];
230
+ record[columnName] = typeof generator === 'function' ? generator(faker) : generator;
231
+ continue;
232
+ }
233
+
234
+ // Skip foreign keys (will be handled by relations)
235
+ if (meta.references) {
236
+ continue;
237
+ }
238
+
239
+ // Generate value
240
+ const value = generateValue(columnName, meta);
241
+ if (value !== undefined) {
242
+ record[columnName] = value;
243
+ }
244
+ }
245
+
246
+ return record;
247
+ }
248
+
249
+ /**
250
+ * Define a factory for a model
251
+ * @param {string|Object} modelOrName - Model or model name
252
+ * @param {Object} options - Factory options
253
+ * @returns {Object} Factory builder
254
+ */
255
+ function defineFactory(modelOrName, options = {}) {
256
+ const model = typeof modelOrName === 'string' ? getModel(modelOrName) : modelOrName;
257
+
258
+ if (!model) {
259
+ throw new Error(`Model not found: ${modelOrName}`);
260
+ }
261
+
262
+ const factory = {
263
+ model,
264
+ defaults: options.defaults || {},
265
+ generators: options.generators || {},
266
+ states: options.states || {},
267
+ };
268
+
269
+ factories.set(model.name, factory);
270
+
271
+ return createFactoryBuilder(factory);
272
+ }
273
+
274
+ /**
275
+ * Get factory for a model
276
+ * @param {string|Object} modelOrName - Model or model name
277
+ * @returns {Object} Factory builder
278
+ */
279
+ function factory(modelOrName) {
280
+ const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName.name;
281
+
282
+ let factoryDef = factories.get(modelName);
283
+
284
+ // Auto-create factory if not defined
285
+ if (!factoryDef) {
286
+ const model = typeof modelOrName === 'string' ? getModel(modelOrName) : modelOrName;
287
+ if (!model) {
288
+ throw new Error(`Model not found: ${modelName}`);
289
+ }
290
+ factoryDef = { model, defaults: {}, generators: {}, states: {} };
291
+ factories.set(modelName, factoryDef);
292
+ }
293
+
294
+ return createFactoryBuilder(factoryDef);
295
+ }
296
+
297
+ /**
298
+ * Create factory builder with fluent API
299
+ * @param {FactoryDefinition} factoryDef - Factory definition
300
+ * @returns {Object} Factory builder
301
+ */
302
+ function createFactoryBuilder(factoryDef) {
303
+ let currentOverrides = { ...factoryDef.defaults };
304
+ let currentGenerators = { ...factoryDef.generators };
305
+ let currentRelations = {};
306
+ let activeStates = [];
307
+
308
+ const builder = {
309
+ /**
310
+ * Apply a named state
311
+ * @param {string} stateName - State name
312
+ * @returns {Object} Builder
313
+ */
314
+ state(stateName) {
315
+ if (factoryDef.states[stateName]) {
316
+ activeStates.push(stateName);
317
+ const stateConfig = factoryDef.states[stateName];
318
+ if (typeof stateConfig === 'function') {
319
+ const stateOverrides = stateConfig(faker);
320
+ currentOverrides = { ...currentOverrides, ...stateOverrides };
321
+ } else {
322
+ currentOverrides = { ...currentOverrides, ...stateConfig };
323
+ }
324
+ }
325
+ return builder;
326
+ },
327
+
328
+ /**
329
+ * Override specific fields
330
+ * @param {Object} overrides - Field overrides
331
+ * @returns {Object} Builder
332
+ */
333
+ override(overrides) {
334
+ currentOverrides = { ...currentOverrides, ...overrides };
335
+ return builder;
336
+ },
337
+
338
+ /**
339
+ * Add custom generators
340
+ * @param {Object} generators - Custom generators
341
+ * @returns {Object} Builder
342
+ */
343
+ generators(generators) {
344
+ currentGenerators = { ...currentGenerators, ...generators };
345
+ return builder;
346
+ },
347
+
348
+ /**
349
+ * Create related records
350
+ * @param {string} relationName - Relation name
351
+ * @param {number} [count=1] - Number of related records
352
+ * @returns {Object} Builder
353
+ */
354
+ with(relationName, count = 1) {
355
+ currentRelations[relationName] = count;
356
+ return builder;
357
+ },
358
+
359
+ /**
360
+ * Generate record(s) without saving
361
+ * @param {number} [count=1] - Number of records
362
+ * @returns {Object|Object[]} Generated record(s)
363
+ */
364
+ make(count = 1) {
365
+ const records = [];
366
+ for (let i = 0; i < count; i++) {
367
+ records.push(generateRecord(factoryDef.model, currentOverrides, currentGenerators));
368
+ }
369
+ return count === 1 ? records[0] : records;
370
+ },
371
+
372
+ /**
373
+ * Create and save record(s)
374
+ * @param {number} [count=1] - Number of records
375
+ * @returns {Promise<Object|Object[]>} Created record(s)
376
+ */
377
+ async create(count = 1) {
378
+ const records = [];
379
+ const model = factoryDef.model;
380
+
381
+ for (let i = 0; i < count; i++) {
382
+ const record = generateRecord(model, currentOverrides, currentGenerators);
383
+
384
+ // Handle belongsTo relations first (need parent IDs)
385
+ if (model.relations) {
386
+ for (const [relName, relation] of Object.entries(model.relations)) {
387
+ if (relation.type === 'belongsTo' && relation.foreignKey) {
388
+ // Check if foreign key is already set
389
+ if (record[relation.foreignKey]) continue;
390
+
391
+ // Check if we should create related or skip
392
+ const relatedModel = relation.model();
393
+ if (relatedModel) {
394
+ const parent = await factory(relatedModel).create();
395
+ record[relation.foreignKey] = parent[relatedModel.primaryKey || 'id'];
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ // Insert record
402
+ const [insertedId] = await knex(model.table).insert(record);
403
+ const primaryKey = model.primaryKey || 'id';
404
+ const id = record[primaryKey] || insertedId;
405
+
406
+ // Fetch the created record
407
+ const created = await knex(model.table).where(primaryKey, id).first();
408
+
409
+ // Handle hasMany relations
410
+ if (model.relations) {
411
+ for (const [relName, count] of Object.entries(currentRelations)) {
412
+ const relation = model.relations[relName];
413
+ if (relation && relation.type === 'hasMany') {
414
+ const relatedModel = relation.model();
415
+ if (relatedModel) {
416
+ const children = await factory(relatedModel)
417
+ .override({ [relation.foreignKey]: created[primaryKey] })
418
+ .create(count);
419
+ created[relName] = Array.isArray(children) ? children : [children];
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ records.push(created);
426
+ }
427
+
428
+ return count === 1 ? records[0] : records;
429
+ },
430
+
431
+ /**
432
+ * Create records in a transaction
433
+ * @param {number} [count=1] - Number of records
434
+ * @returns {Promise<Object|Object[]>} Created record(s)
435
+ */
436
+ async createInTransaction(count = 1) {
437
+ return knex.transaction(async (trx) => {
438
+ const originalKnex = knex;
439
+ // Temporarily replace knex with transaction
440
+ const records = [];
441
+ const model = factoryDef.model;
442
+
443
+ for (let i = 0; i < count; i++) {
444
+ const record = generateRecord(model, currentOverrides, currentGenerators);
445
+
446
+ // Handle belongsTo relations
447
+ if (model.relations) {
448
+ for (const [relName, relation] of Object.entries(model.relations)) {
449
+ if (relation.type === 'belongsTo' && relation.foreignKey) {
450
+ if (record[relation.foreignKey]) continue;
451
+ const relatedModel = relation.model();
452
+ if (relatedModel) {
453
+ const parentRecord = generateRecord(relatedModel, {}, {});
454
+ const [parentId] = await trx(relatedModel.table).insert(parentRecord);
455
+ record[relation.foreignKey] = parentRecord[relatedModel.primaryKey || 'id'] || parentId;
456
+ }
457
+ }
458
+ }
459
+ }
460
+
461
+ const [insertedId] = await trx(model.table).insert(record);
462
+ const primaryKey = model.primaryKey || 'id';
463
+ const id = record[primaryKey] || insertedId;
464
+ const created = await trx(model.table).where(primaryKey, id).first();
465
+ records.push(created);
466
+ }
467
+
468
+ return count === 1 ? records[0] : records;
469
+ });
470
+ },
471
+ };
472
+
473
+ return builder;
474
+ }
475
+
476
+ /**
477
+ * Seed a model with records
478
+ * @param {string|Object} modelOrName - Model or model name
479
+ * @param {number} count - Number of records
480
+ * @param {Object} [options={}] - Seed options
481
+ * @returns {Promise<Object[]>} Created records
482
+ */
483
+ async function seed(modelOrName, count, options = {}) {
484
+ const builder = factory(modelOrName);
485
+
486
+ if (options.overrides) {
487
+ builder.override(options.overrides);
488
+ }
489
+
490
+ if (options.generators) {
491
+ builder.generators(options.generators);
492
+ }
493
+
494
+ if (options.state) {
495
+ builder.state(options.state);
496
+ }
497
+
498
+ const records = await builder.create(count);
499
+ return Array.isArray(records) ? records : [records];
500
+ }
501
+
502
+ /**
503
+ * Run multiple seeders
504
+ * @param {Object[]} seeders - Array of seeder configs
505
+ * @returns {Promise<Object>} Results by model name
506
+ */
507
+ async function run(seeders) {
508
+ const results = {};
509
+
510
+ for (const seederConfig of seeders) {
511
+ const { model, count = 10, ...options } = seederConfig;
512
+ const modelName = typeof model === 'string' ? model : model.name;
513
+ results[modelName] = await seed(model, count, options);
514
+ }
515
+
516
+ return results;
517
+ }
518
+
519
+ /**
520
+ * Truncate table(s)
521
+ * @param {string|string[]|Object|Object[]} models - Model(s) to truncate
522
+ * @returns {Promise<void>}
523
+ */
524
+ async function truncate(models) {
525
+ const modelList = Array.isArray(models) ? models : [models];
526
+
527
+ for (const modelOrName of modelList) {
528
+ const model = typeof modelOrName === 'string' ? getModel(modelOrName) : modelOrName;
529
+ if (model) {
530
+ await knex(model.table).truncate();
531
+ }
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Clear all tables (respecting foreign key order)
537
+ * @returns {Promise<void>}
538
+ */
539
+ async function clearAll() {
540
+ const allModels = getAllModels();
541
+ const tables = [];
542
+
543
+ // Collect tables with dependency info
544
+ for (const [name, model] of allModels) {
545
+ const deps = [];
546
+ if (model.relations) {
547
+ for (const relation of Object.values(model.relations)) {
548
+ if (relation.type === 'belongsTo') {
549
+ const relatedModel = relation.model();
550
+ if (relatedModel) {
551
+ deps.push(relatedModel.table);
552
+ }
553
+ }
554
+ }
555
+ }
556
+ tables.push({ table: model.table, deps });
557
+ }
558
+
559
+ // Sort by dependencies (tables with no deps first)
560
+ tables.sort((a, b) => a.deps.length - b.deps.length);
561
+
562
+ // Reverse to delete children first
563
+ for (const { table } of tables.reverse()) {
564
+ try {
565
+ await knex(table).del();
566
+ } catch (e) {
567
+ // Ignore errors (table might not exist)
568
+ }
569
+ }
570
+ }
571
+
572
+ return {
573
+ defineFactory,
574
+ factory,
575
+ seed,
576
+ run,
577
+ truncate,
578
+ clearAll,
579
+ // Expose faker for custom generators
580
+ faker,
581
+ };
582
+ }
583
+
584
+ module.exports = { createSeeder };
585
+
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Webspresso ORM - Transaction
3
+ * Transaction wrapper with scoped repositories
4
+ * @module core/orm/transaction
5
+ */
6
+
7
+ const { createRepository } = require('./repository');
8
+ const { createScopeContext } = require('./scopes');
9
+
10
+ /**
11
+ * Create a transaction context
12
+ * @param {import('knex').Knex.Transaction} trx - Knex transaction
13
+ * @param {import('./types').ScopeContext} [scopeContext] - Scope context
14
+ * @returns {import('./types').TransactionContext}
15
+ */
16
+ function createTransactionContext(trx, scopeContext) {
17
+ const context = scopeContext || createScopeContext();
18
+
19
+ return {
20
+ trx,
21
+
22
+ /**
23
+ * Create a repository bound to this transaction
24
+ * @param {import('./types').ModelDefinition} model - Model definition
25
+ * @returns {import('./types').Repository}
26
+ */
27
+ createRepository(model) {
28
+ return createRepository(model, trx, context);
29
+ },
30
+
31
+ /**
32
+ * Set tenant context for this transaction
33
+ * @param {*} tenantId - Tenant ID
34
+ * @returns {this}
35
+ */
36
+ forTenant(tenantId) {
37
+ context.tenantId = tenantId;
38
+ return this;
39
+ },
40
+
41
+ /**
42
+ * Get the scope context
43
+ * @returns {import('./types').ScopeContext}
44
+ */
45
+ getScopeContext() {
46
+ return { ...context };
47
+ },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Run a callback within a transaction
53
+ * @param {import('knex').Knex} knex - Knex instance
54
+ * @param {function(import('./types').TransactionContext): Promise<*>} callback - Transaction callback
55
+ * @param {import('./types').ScopeContext} [scopeContext] - Scope context
56
+ * @returns {Promise<*>} Result of callback
57
+ */
58
+ async function runTransaction(knex, callback, scopeContext) {
59
+ return knex.transaction(async (trx) => {
60
+ const ctx = createTransactionContext(trx, scopeContext);
61
+ return callback(ctx);
62
+ });
63
+ }
64
+
65
+ module.exports = {
66
+ createTransactionContext,
67
+ runTransaction,
68
+ };
69
+