millas 0.1.7 → 0.1.9
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/package.json +32 -19
- package/src/commands/migrate.js +138 -77
- package/src/index.js +10 -2
- package/src/orm/index.js +19 -0
- package/src/orm/migration/MigrationRunner.js +32 -42
- package/src/orm/migration/ModelInspector.js +242 -70
- package/src/orm/migration/dialects/mysql.js +26 -0
- package/src/orm/migration/dialects/postgres.js +18 -0
- package/src/orm/migration/dialects/sqlite.js +24 -0
- package/src/orm/model/Model.js +398 -133
- package/src/orm/query/Aggregates.js +56 -0
- package/src/orm/query/LookupParser.js +308 -0
- package/src/orm/query/Q.js +123 -0
- package/src/orm/query/QueryBuilder.js +266 -82
- package/src/orm/relations/BelongsTo.js +68 -0
- package/src/orm/relations/BelongsToMany.js +188 -0
- package/src/orm/relations/HasMany.js +72 -0
- package/src/orm/relations/HasOne.js +67 -0
- package/src/orm/relations/index.js +8 -0
- package/src/scaffold/maker.js +39 -4
- package/src/scaffold/templates.js +26 -3
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BelongsToMany
|
|
5
|
+
*
|
|
6
|
+
* Many-to-many through a pivot table.
|
|
7
|
+
*
|
|
8
|
+
* class Post extends Model {
|
|
9
|
+
* static relations = {
|
|
10
|
+
* tags: new BelongsToMany(() => Tag, 'post_tag', 'post_id', 'tag_id'),
|
|
11
|
+
* };
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* class Tag extends Model {
|
|
15
|
+
* static relations = {
|
|
16
|
+
* posts: new BelongsToMany(() => Post, 'post_tag', 'tag_id', 'post_id'),
|
|
17
|
+
* };
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* const post = await Post.find(1);
|
|
21
|
+
*
|
|
22
|
+
* // Lazy
|
|
23
|
+
* const tags = await post.tags();
|
|
24
|
+
*
|
|
25
|
+
* // Eager
|
|
26
|
+
* const posts = await Post.with('tags').get();
|
|
27
|
+
*
|
|
28
|
+
* // Attach / detach / sync
|
|
29
|
+
* await post.tags.attach(5)
|
|
30
|
+
* await post.tags.attach([5, 6], { approved: true }) // with pivot data
|
|
31
|
+
* await post.tags.detach(5)
|
|
32
|
+
* await post.tags.detach() // detach all
|
|
33
|
+
* await post.tags.sync([1, 2, 3]) // replace pivot rows
|
|
34
|
+
* await post.tags.toggle([1, 2])
|
|
35
|
+
*/
|
|
36
|
+
class BelongsToMany {
|
|
37
|
+
/**
|
|
38
|
+
* @param {Function} relatedFn — () => RelatedModelClass
|
|
39
|
+
* @param {string} pivotTable — name of the join table
|
|
40
|
+
* @param {string} foreignPivotKey — pivot column pointing to this model
|
|
41
|
+
* @param {string} relatedPivotKey — pivot column pointing to the related model
|
|
42
|
+
* @param {string} localKey — PK on this model (default: 'id')
|
|
43
|
+
* @param {string} relatedKey — PK on related model (default: 'id')
|
|
44
|
+
*/
|
|
45
|
+
constructor(relatedFn, pivotTable, foreignPivotKey, relatedPivotKey, localKey = 'id', relatedKey = 'id') {
|
|
46
|
+
this._relatedFn = relatedFn;
|
|
47
|
+
this._pivotTable = pivotTable;
|
|
48
|
+
this._foreignPivotKey = foreignPivotKey;
|
|
49
|
+
this._relatedPivotKey = relatedPivotKey;
|
|
50
|
+
this._localKey = localKey;
|
|
51
|
+
this._relatedKey = relatedKey;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get _related() { return this._relatedFn(); }
|
|
55
|
+
|
|
56
|
+
// ─── Lazy load ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async load(ownerInstance) {
|
|
59
|
+
const db = this._related._db;
|
|
60
|
+
const key = ownerInstance[this._localKey];
|
|
61
|
+
|
|
62
|
+
const rows = await this._related._db()
|
|
63
|
+
.join(
|
|
64
|
+
this._pivotTable,
|
|
65
|
+
`${this._related.table}.${this._relatedKey}`,
|
|
66
|
+
'=',
|
|
67
|
+
`${this._pivotTable}.${this._relatedPivotKey}`,
|
|
68
|
+
)
|
|
69
|
+
.where(`${this._pivotTable}.${this._foreignPivotKey}`, key)
|
|
70
|
+
.select(`${this._related.table}.*`);
|
|
71
|
+
|
|
72
|
+
return rows.map(r => this._related._hydrate(r));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Eager load ───────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
async eagerLoad(instances, relationName, constraint) {
|
|
78
|
+
const keys = [...new Set(instances.map(i => i[this._localKey]).filter(v => v != null))];
|
|
79
|
+
if (!keys.length) {
|
|
80
|
+
for (const i of instances) i[relationName] = [];
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let q = this._related._db()
|
|
85
|
+
.join(
|
|
86
|
+
this._pivotTable,
|
|
87
|
+
`${this._related.table}.${this._relatedKey}`,
|
|
88
|
+
'=',
|
|
89
|
+
`${this._pivotTable}.${this._relatedPivotKey}`,
|
|
90
|
+
)
|
|
91
|
+
.whereIn(`${this._pivotTable}.${this._foreignPivotKey}`, keys)
|
|
92
|
+
.select(
|
|
93
|
+
`${this._related.table}.*`,
|
|
94
|
+
`${this._pivotTable}.${this._foreignPivotKey} as _pivot_owner_id`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (constraint) {
|
|
98
|
+
const QueryBuilder = require('../query/QueryBuilder');
|
|
99
|
+
const qb = new QueryBuilder(q, this._related);
|
|
100
|
+
constraint(qb);
|
|
101
|
+
q = qb._query;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rows = await q;
|
|
105
|
+
const related = rows.map(r => {
|
|
106
|
+
const instance = this._related._hydrate(r);
|
|
107
|
+
instance._pivotOwnerId = r._pivot_owner_id;
|
|
108
|
+
return instance;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const map = new Map();
|
|
112
|
+
for (const r of related) {
|
|
113
|
+
const ownerId = r._pivotOwnerId;
|
|
114
|
+
if (!map.has(ownerId)) map.set(ownerId, []);
|
|
115
|
+
map.get(ownerId).push(r);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const instance of instances) {
|
|
119
|
+
instance[relationName] = map.get(instance[this._localKey]) ?? [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Pivot management (returned as methods on the instance proxy) ─────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build pivot manager bound to a specific owner instance.
|
|
127
|
+
* Called internally — results attached as instance[relationName].
|
|
128
|
+
*/
|
|
129
|
+
_pivotManager(ownerInstance) {
|
|
130
|
+
const self = this;
|
|
131
|
+
const db = () => ownerInstance.constructor._db().client; // knex instance
|
|
132
|
+
|
|
133
|
+
// We need raw knex for pivot table operations
|
|
134
|
+
const knex = () => {
|
|
135
|
+
const DatabaseManager = require('../drivers/DatabaseManager');
|
|
136
|
+
return DatabaseManager.connection();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
/** Attach related IDs to the pivot table */
|
|
141
|
+
async attach(ids, pivotData = {}) {
|
|
142
|
+
const ownerId = ownerInstance[self._localKey];
|
|
143
|
+
const idArray = Array.isArray(ids) ? ids : [ids];
|
|
144
|
+
const rows = idArray.map(id => ({
|
|
145
|
+
[self._foreignPivotKey]: ownerId,
|
|
146
|
+
[self._relatedPivotKey]: id,
|
|
147
|
+
...pivotData,
|
|
148
|
+
}));
|
|
149
|
+
await knex()(self._pivotTable).insert(rows).onConflict().ignore();
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/** Remove related IDs from the pivot table */
|
|
153
|
+
async detach(ids) {
|
|
154
|
+
const ownerId = ownerInstance[self._localKey];
|
|
155
|
+
let q = knex()(self._pivotTable).where(self._foreignPivotKey, ownerId);
|
|
156
|
+
if (ids != null) {
|
|
157
|
+
const idArray = Array.isArray(ids) ? ids : [ids];
|
|
158
|
+
q = q.whereIn(self._relatedPivotKey, idArray);
|
|
159
|
+
}
|
|
160
|
+
await q.delete();
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/** Replace all pivot rows with the given set of IDs */
|
|
164
|
+
async sync(ids, pivotData = {}) {
|
|
165
|
+
await this.detach();
|
|
166
|
+
if (ids.length) await this.attach(ids, pivotData);
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/** Toggle IDs — attach if not present, detach if present */
|
|
170
|
+
async toggle(ids) {
|
|
171
|
+
const ownerId = ownerInstance[self._localKey];
|
|
172
|
+
const idArray = Array.isArray(ids) ? ids : [ids];
|
|
173
|
+
const existing = await knex()(self._pivotTable)
|
|
174
|
+
.where(self._foreignPivotKey, ownerId)
|
|
175
|
+
.whereIn(self._relatedPivotKey, idArray)
|
|
176
|
+
.pluck(self._relatedPivotKey);
|
|
177
|
+
|
|
178
|
+
const toAttach = idArray.filter(id => !existing.includes(id));
|
|
179
|
+
const toDetach = existing;
|
|
180
|
+
|
|
181
|
+
if (toAttach.length) await this.attach(toAttach);
|
|
182
|
+
if (toDetach.length) await this.detach(toDetach);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = BelongsToMany;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HasMany
|
|
5
|
+
*
|
|
6
|
+
* One-to-many: the foreign key lives on the related table.
|
|
7
|
+
*
|
|
8
|
+
* class User extends Model {
|
|
9
|
+
* static relations = {
|
|
10
|
+
* posts: new HasMany(() => Post, 'user_id'),
|
|
11
|
+
* };
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* const user = await User.find(1);
|
|
15
|
+
* const posts = await user.posts(); // lazy
|
|
16
|
+
* const users = await User.with('posts').get(); // eager
|
|
17
|
+
* const users = await User.with({ // eager + constrained
|
|
18
|
+
* posts: q => q.where('published', true).latest()
|
|
19
|
+
* }).get();
|
|
20
|
+
*/
|
|
21
|
+
class HasMany {
|
|
22
|
+
constructor(relatedFn, foreignKey, localKey = 'id') {
|
|
23
|
+
this._relatedFn = relatedFn;
|
|
24
|
+
this._foreignKey = foreignKey;
|
|
25
|
+
this._localKey = localKey;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get _related() { return this._relatedFn(); }
|
|
29
|
+
|
|
30
|
+
// ─── Lazy load ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async load(ownerInstance) {
|
|
33
|
+
const key = ownerInstance[this._localKey];
|
|
34
|
+
const rows = await this._related._db().where(this._foreignKey, key);
|
|
35
|
+
return rows.map(r => this._related._hydrate(r));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Eager load ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
async eagerLoad(instances, relationName, constraint) {
|
|
41
|
+
const keys = [...new Set(instances.map(i => i[this._localKey]).filter(v => v != null))];
|
|
42
|
+
if (!keys.length) {
|
|
43
|
+
for (const i of instances) i[relationName] = [];
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let q = this._related._db().whereIn(this._foreignKey, keys);
|
|
48
|
+
if (constraint) {
|
|
49
|
+
const QueryBuilder = require('../query/QueryBuilder');
|
|
50
|
+
const qb = new QueryBuilder(q, this._related);
|
|
51
|
+
constraint(qb);
|
|
52
|
+
q = qb._query;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rows = await q;
|
|
56
|
+
const related = rows.map(r => this._related._hydrate(r));
|
|
57
|
+
|
|
58
|
+
// Group by foreign key
|
|
59
|
+
const map = new Map();
|
|
60
|
+
for (const r of related) {
|
|
61
|
+
const fkVal = r[this._foreignKey];
|
|
62
|
+
if (!map.has(fkVal)) map.set(fkVal, []);
|
|
63
|
+
map.get(fkVal).push(r);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const instance of instances) {
|
|
67
|
+
instance[relationName] = map.get(instance[this._localKey]) ?? [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = HasMany;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HasOne
|
|
5
|
+
*
|
|
6
|
+
* Represents a one-to-one relation where the foreign key lives on the
|
|
7
|
+
* related model's table.
|
|
8
|
+
*
|
|
9
|
+
* class User extends Model {
|
|
10
|
+
* static relations = {
|
|
11
|
+
* profile: new HasOne(() => Profile, 'user_id'),
|
|
12
|
+
* };
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* const user = await User.find(1);
|
|
16
|
+
* const profile = await user.profile(); // single instance or null
|
|
17
|
+
* const users = await User.with('profile').get(); // eager loaded
|
|
18
|
+
*/
|
|
19
|
+
class HasOne {
|
|
20
|
+
/**
|
|
21
|
+
* @param {Function} relatedFn — () => RelatedModelClass (thunk avoids circular requires)
|
|
22
|
+
* @param {string} foreignKey — column on the related table pointing back here
|
|
23
|
+
* @param {string} localKey — column on this table (default: 'id')
|
|
24
|
+
*/
|
|
25
|
+
constructor(relatedFn, foreignKey, localKey = 'id') {
|
|
26
|
+
this._relatedFn = relatedFn;
|
|
27
|
+
this._foreignKey = foreignKey;
|
|
28
|
+
this._localKey = localKey;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get _related() { return this._relatedFn(); }
|
|
32
|
+
|
|
33
|
+
// ─── Lazy load (instance method) ─────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
async load(ownerInstance) {
|
|
36
|
+
const key = ownerInstance[this._localKey];
|
|
37
|
+
const row = await this._related._db()
|
|
38
|
+
.where(this._foreignKey, key)
|
|
39
|
+
.first();
|
|
40
|
+
return row ? this._related._hydrate(row) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Eager load ───────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
async eagerLoad(instances, relationName, constraint) {
|
|
46
|
+
const keys = [...new Set(instances.map(i => i[this._localKey]).filter(v => v != null))];
|
|
47
|
+
if (!keys.length) return;
|
|
48
|
+
|
|
49
|
+
let q = this._related._db().whereIn(this._foreignKey, keys);
|
|
50
|
+
if (constraint) {
|
|
51
|
+
const QueryBuilder = require('../query/QueryBuilder');
|
|
52
|
+
const qb = new QueryBuilder(q, this._related);
|
|
53
|
+
constraint(qb);
|
|
54
|
+
q = qb._query;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rows = await q;
|
|
58
|
+
const related = rows.map(r => this._related._hydrate(r));
|
|
59
|
+
const map = new Map(related.map(r => [r[this._foreignKey], r]));
|
|
60
|
+
|
|
61
|
+
for (const instance of instances) {
|
|
62
|
+
instance[relationName] = map.get(instance[this._localKey]) ?? null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = HasOne;
|
package/src/scaffold/maker.js
CHANGED
|
@@ -105,23 +105,58 @@ async function makeModel(name, options = {}) {
|
|
|
105
105
|
|
|
106
106
|
const content = `'use strict';
|
|
107
107
|
|
|
108
|
-
const { Model, fields } = require('millas');
|
|
108
|
+
const { Model, fields, HasMany, HasOne, BelongsTo, BelongsToMany } = require('millas');
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* ${className} Model
|
|
112
112
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* Table: ${tableName}
|
|
114
|
+
*
|
|
115
|
+
* Edit this file, then run:
|
|
116
|
+
* millas makemigrations — generates migration files from your changes
|
|
117
|
+
* millas migrate — applies them to the database
|
|
116
118
|
*/
|
|
117
119
|
class ${className} extends Model {
|
|
118
120
|
static table = '${tableName}';
|
|
119
121
|
|
|
122
|
+
// ── Fields ──────────────────────────────────────────────────────
|
|
120
123
|
static fields = {
|
|
121
124
|
id: fields.id(),
|
|
122
125
|
created_at: fields.timestamp(),
|
|
123
126
|
updated_at: fields.timestamp(),
|
|
127
|
+
// deleted_at: fields.timestamp({ nullable: true }), // uncomment + set softDeletes = true
|
|
124
128
|
};
|
|
129
|
+
|
|
130
|
+
// ── Soft deletes ─────────────────────────────────────────────────
|
|
131
|
+
// static softDeletes = true;
|
|
132
|
+
|
|
133
|
+
// ── Relations ─────────────────────────────────────────────────────
|
|
134
|
+
// static relations = {
|
|
135
|
+
// posts: new HasMany(() => require('./Post'), 'user_id'),
|
|
136
|
+
// profile: new HasOne(() => require('./Profile'), 'user_id'),
|
|
137
|
+
// role: new BelongsTo(() => require('./Role'), 'role_id'),
|
|
138
|
+
// tags: new BelongsToMany(() => require('./Tag'), '${tableName.replace(/s$/, '')}_tag', '${tableName.replace(/s$/, '')}_id', 'tag_id'),
|
|
139
|
+
// };
|
|
140
|
+
|
|
141
|
+
// ── Scopes ───────────────────────────────────────────────────────
|
|
142
|
+
// static scopes = {
|
|
143
|
+
// active: qb => qb.where('active', true),
|
|
144
|
+
// recent: qb => qb.latest().limit(10),
|
|
145
|
+
// byUser: (qb, userId) => qb.where('user_id', userId),
|
|
146
|
+
// };
|
|
147
|
+
|
|
148
|
+
// ── Validation ───────────────────────────────────────────────────
|
|
149
|
+
// static validate(data) {
|
|
150
|
+
// if (!data.name) throw new Error('name is required');
|
|
151
|
+
// }
|
|
152
|
+
|
|
153
|
+
// ── Lifecycle hooks ──────────────────────────────────────────────
|
|
154
|
+
// static async beforeCreate(data) { return data; }
|
|
155
|
+
// static async afterCreate(instance) {}
|
|
156
|
+
// static async beforeUpdate(data) { return data; }
|
|
157
|
+
// static async afterUpdate(instance) {}
|
|
158
|
+
// static async beforeDelete(instance){}
|
|
159
|
+
// static async afterDelete(instance) {}
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
module.exports = ${className};
|
|
@@ -66,6 +66,8 @@ storage/logs/*.log
|
|
|
66
66
|
storage/uploads/*
|
|
67
67
|
!storage/uploads/.gitkeep
|
|
68
68
|
database/database.sqlite
|
|
69
|
+
# Millas migration snapshot — auto-generated, do not commit
|
|
70
|
+
.millas/
|
|
69
71
|
`,
|
|
70
72
|
|
|
71
73
|
// ─── millas.config.js ─────────────────────────────────────────
|
|
@@ -356,17 +358,37 @@ millas make:controller UserController
|
|
|
356
358
|
|
|
357
359
|
# Generate a model
|
|
358
360
|
millas make:model User
|
|
361
|
+
\`\`\`
|
|
362
|
+
|
|
363
|
+
## Database Migrations (Django-style)
|
|
364
|
+
|
|
365
|
+
Millas handles migrations automatically — you only edit your model files.
|
|
366
|
+
|
|
367
|
+
\`\`\`bash
|
|
368
|
+
# 1. Edit app/models/User.js — add, remove, or change fields
|
|
369
|
+
# 2. Generate migration files from your changes
|
|
370
|
+
millas makemigrations
|
|
359
371
|
|
|
360
|
-
#
|
|
372
|
+
# 3. Apply pending migrations to the database
|
|
361
373
|
millas migrate
|
|
362
374
|
\`\`\`
|
|
363
375
|
|
|
376
|
+
Other migration commands:
|
|
377
|
+
|
|
378
|
+
\`\`\`bash
|
|
379
|
+
millas migrate:status # Show which migrations have run
|
|
380
|
+
millas migrate:rollback # Undo the last batch
|
|
381
|
+
millas migrate:fresh # Drop everything and re-run all migrations
|
|
382
|
+
millas migrate:reset # Roll back all migrations
|
|
383
|
+
millas migrate:refresh # Reset + re-run (like fresh but using down() methods)
|
|
384
|
+
\`\`\`
|
|
385
|
+
|
|
364
386
|
## Project Structure
|
|
365
387
|
|
|
366
388
|
\`\`\`
|
|
367
389
|
app/
|
|
368
390
|
controllers/ # HTTP controllers
|
|
369
|
-
models/ # ORM models
|
|
391
|
+
models/ # ORM models ← only file you edit for schema changes
|
|
370
392
|
services/ # Business logic
|
|
371
393
|
middleware/ # HTTP middleware
|
|
372
394
|
jobs/ # Background jobs
|
|
@@ -374,13 +396,14 @@ bootstrap/
|
|
|
374
396
|
app.js # Application entry point
|
|
375
397
|
config/ # Configuration files
|
|
376
398
|
database/
|
|
377
|
-
migrations/ #
|
|
399
|
+
migrations/ # Auto-generated — do not edit by hand
|
|
378
400
|
seeders/ # Database seeders
|
|
379
401
|
routes/
|
|
380
402
|
web.js # Web routes
|
|
381
403
|
api.js # API routes
|
|
382
404
|
storage/ # Logs, uploads
|
|
383
405
|
providers/ # Service providers
|
|
406
|
+
.millas/ # Migration snapshot (gitignored)
|
|
384
407
|
\`\`\`
|
|
385
408
|
`,
|
|
386
409
|
};
|