millas 0.2.12-beta-1 → 0.2.13

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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ const { CreateModel, DeleteModel, RenameModel } = require('./models');
4
+ const { AddField, RemoveField, AlterField, RenameField } = require('./fields');
5
+ const { RunSQL } = require('./special');
6
+ const { modelNameToTable, isSnakeCase } = require('../utils');
7
+
8
+ /**
9
+ * registry.js
10
+ *
11
+ * Two responsibilities:
12
+ *
13
+ * 1. deserialise(op)
14
+ * Converts a plain JSON descriptor (loaded from a migration file) back
15
+ * into a live operation instance. Used by MigrationGraph.loadAll().
16
+ *
17
+ * 2. migrations proxy
18
+ * The named-argument API used inside generated migration files:
19
+ *
20
+ * const { migrations, fields } = require('millas/core/db');
21
+ * static operations = [
22
+ * migrations.CreateModel({ name: 'posts', fields: [...] }),
23
+ * migrations.AddField({ modelName: 'posts', name: 'slug', field: fields.string() }),
24
+ * ];
25
+ *
26
+ * Each proxy method returns a PLAIN DESCRIPTOR OBJECT — no live instances,
27
+ * no side-effects at require() time. MigrationGraph feeds these through
28
+ * deserialise() when it needs live operation objects.
29
+ *
30
+ * _tableFromName() handles the legacy PascalCase → snake_case conversion
31
+ * for any hand-written migrations that used model names instead of table names.
32
+ */
33
+
34
+ // ─── Deserialise ──────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Convert a plain operation descriptor into a live operation instance.
38
+ *
39
+ * @param {object} op — plain descriptor with a `type` field
40
+ * @returns {BaseOperation}
41
+ * @throws {Error} if op.type is unrecognised
42
+ */
43
+ function deserialise(op) {
44
+ switch (op.type) {
45
+ case 'CreateModel': return new CreateModel(op.table, op.fields);
46
+ case 'DeleteModel': return new DeleteModel(op.table, op.fields);
47
+ case 'RenameModel': return new RenameModel(op.oldTable, op.newTable);
48
+ case 'AddField': return new AddField(op.table, op.column, op.field, op.oneOffDefault);
49
+ case 'RemoveField': return new RemoveField(op.table, op.column, op.field);
50
+ case 'AlterField': return new AlterField(op.table, op.column, op.field, op.previousField);
51
+ case 'RenameField': return new RenameField(op.table, op.oldColumn, op.newColumn);
52
+ case 'RunSQL': return new RunSQL(op.sql, op.reverseSql);
53
+ default:
54
+ throw new Error(`Unknown migration operation type: "${op.type}"`);
55
+ }
56
+ }
57
+
58
+ // ─── migrations proxy ─────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Named-argument API used in generated migration files.
62
+ * Returns plain descriptor objects — zero side-effects at require() time.
63
+ */
64
+ const migrations = {
65
+
66
+ CreateModel({ name, fields: fieldList = [] }) {
67
+ const fields = {};
68
+ for (const [col, def] of fieldList) fields[col] = def;
69
+ return { type: 'CreateModel', table: _tableFromName(name), fields };
70
+ },
71
+
72
+ DeleteModel({ name, fields: fieldList = [] }) {
73
+ const fields = {};
74
+ for (const [col, def] of (fieldList || [])) fields[col] = def;
75
+ return { type: 'DeleteModel', table: _tableFromName(name), fields };
76
+ },
77
+
78
+ RenameModel({ oldName, newName }) {
79
+ return {
80
+ type: 'RenameModel',
81
+ oldTable: _tableFromName(oldName),
82
+ newTable: _tableFromName(newName),
83
+ };
84
+ },
85
+
86
+ AddField({ modelName, name, field, oneOffDefault }) {
87
+ const d = { type: 'AddField', table: modelName, column: name, field };
88
+ if (oneOffDefault !== undefined) d.oneOffDefault = oneOffDefault;
89
+ return d;
90
+ },
91
+
92
+ RemoveField({ modelName, name, field }) {
93
+ return { type: 'RemoveField', table: modelName, column: name, field };
94
+ },
95
+
96
+ AlterField({ modelName, name, field, previousField }) {
97
+ return { type: 'AlterField', table: modelName, column: name, field, previousField };
98
+ },
99
+
100
+ RenameField({ modelName, oldName, newName }) {
101
+ return { type: 'RenameField', table: modelName, oldColumn: oldName, newColumn: newName };
102
+ },
103
+
104
+ RunSQL({ sql, reverseSql = null }) {
105
+ return { type: 'RunSQL', sql, reverseSql };
106
+ },
107
+
108
+ };
109
+
110
+ // ─── _tableFromName ───────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Resolve a migration file `name:` field to a table name.
114
+ *
115
+ * MigrationWriter now writes the actual table name directly
116
+ * (e.g. name: 'landlord_verification') so this is an identity function
117
+ * for all newly generated migrations.
118
+ *
119
+ * Kept for backward compatibility with hand-written migrations that used
120
+ * PascalCase model names (e.g. name: 'Post') — those get converted to
121
+ * snake_case plural table names.
122
+ *
123
+ * @param {string} name
124
+ * @returns {string} table name
125
+ */
126
+ function _tableFromName(name) {
127
+ // Already snake_case → return as-is. PascalCase → convert via utils.
128
+ return isSnakeCase(name) ? name : modelNameToTable(name);
129
+ }
130
+
131
+ module.exports = { deserialise, migrations, _tableFromName };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { BaseOperation } = require('./base');
4
+
5
+ /**
6
+ * special.js
7
+ *
8
+ * Escape-hatch operations that don't fit the structured field/model pattern:
9
+ * RunSQL — execute arbitrary SQL (forward and optionally reverse)
10
+ *
11
+ * applyState() is a no-op for RunSQL — the migration system cannot know
12
+ * what arbitrary SQL does to the schema, so ProjectState is not mutated.
13
+ * This means RunSQL migrations are opaque to makemigrations diffing.
14
+ */
15
+
16
+ // ─── RunSQL ───────────────────────────────────────────────────────────────────
17
+
18
+ class RunSQL extends BaseOperation {
19
+ /**
20
+ * @param {string} sql — SQL to run on migrate
21
+ * @param {string|null} reverseSql — SQL to run on rollback (optional)
22
+ */
23
+ constructor(sql, reverseSql = null) {
24
+ super();
25
+ this.type = 'RunSQL';
26
+ this.sql = sql;
27
+ this.reverseSql = reverseSql;
28
+ }
29
+
30
+ // Opaque — RunSQL does not mutate ProjectState.
31
+ // makemigrations cannot infer schema changes from raw SQL.
32
+ applyState(/* _state */) {}
33
+
34
+ async up(db) {
35
+ await db.raw(this.sql);
36
+ }
37
+
38
+ async down(db) {
39
+ if (this.reverseSql) await db.raw(this.reverseSql);
40
+ }
41
+
42
+ toJSON() {
43
+ return {
44
+ type: 'RunSQL',
45
+ sql: this.sql,
46
+ reverseSql: this.reverseSql,
47
+ };
48
+ }
49
+ }
50
+
51
+ module.exports = { RunSQL };
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * utils.js — shared utilities for the migration system
8
+ *
9
+ * Every function here was previously duplicated across two or more files.
10
+ * Single source of truth — import from here, never re-implement.
11
+ *
12
+ * Exports:
13
+ * walkJs(dir) — recursively collect .js files
14
+ * extractClasses(exported) — pull class candidates from a module export
15
+ * isMillasModel(cls) — true if cls looks like a Millas Model
16
+ * resolveTable(cls) — table name for a Model class (convention or explicit)
17
+ * modelNameToTable(name) — PascalCase model name → snake_case plural table
18
+ * tableFromClass(cls) — walk prototype chain to find nearest static table
19
+ * isSnakeCase(str) — true if string is already snake_case
20
+ * fieldsEqual(a, b) — schema-key equality, ignores internal FK flags
21
+ */
22
+
23
+ // ─── File system ──────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Recursively collect all .js files under a directory.
27
+ * Skips dotfiles and index.js at any depth.
28
+ *
29
+ * @param {string} dir
30
+ * @returns {string[]} absolute paths
31
+ */
32
+ function walkJs(dir) {
33
+ const results = [];
34
+ if (!fs.existsSync(dir)) return results;
35
+
36
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
37
+ if (entry.name.startsWith('.')) continue;
38
+ const fullPath = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ results.push(...walkJs(fullPath));
41
+ } else if (entry.name.endsWith('.js') && entry.name !== 'index.js') {
42
+ results.push(fullPath);
43
+ }
44
+ }
45
+
46
+ return results;
47
+ }
48
+
49
+ // ─── Model class detection ────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Given a module export (class, object, or anything), return every
53
+ * function/class value that could be a Model subclass.
54
+ *
55
+ * Handles:
56
+ * module.exports = MyModel → [MyModel]
57
+ * module.exports = { User, Post } → [User, Post]
58
+ *
59
+ * @param {*} exported
60
+ * @returns {Function[]}
61
+ */
62
+ function extractClasses(exported) {
63
+ if (!exported) return [];
64
+ if (typeof exported === 'function') return [exported];
65
+ if (typeof exported === 'object') {
66
+ return Object.values(exported).filter(v => typeof v === 'function');
67
+ }
68
+ return [];
69
+ }
70
+
71
+ /**
72
+ * A class qualifies as a Millas Model if:
73
+ * - It is a function (class)
74
+ * - It has a static `fields` property that is a non-null object with at least one key
75
+ *
76
+ * Intentionally avoids instanceof checks so this works regardless of which
77
+ * resolution path Model was loaded from (e.g. during tests or monorepo setups).
78
+ *
79
+ * @param {*} cls
80
+ * @returns {boolean}
81
+ */
82
+ function isMillasModel(cls) {
83
+ if (typeof cls !== 'function') return false;
84
+ if (!cls.fields || typeof cls.fields !== 'object') return false;
85
+ return Object.keys(cls.fields).length > 0;
86
+ }
87
+
88
+ // ─── Table name resolution ────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Resolve the table name for a Model class.
92
+ *
93
+ * Rules (in priority order):
94
+ * 1. Abstract class (hasOwnProperty 'abstract' === true) → null (no table)
95
+ * 2. Explicitly declared via static set table(v) → stored in _table
96
+ * 3. Explicitly declared as static string property
97
+ * 4. Convention — auto-generated from class name via Model._defaultTable()
98
+ * (same as Laravel/Eloquent and Rails/ActiveRecord)
99
+ *
100
+ * @param {Function} cls — Model subclass
101
+ * @returns {string|null}
102
+ */
103
+ function resolveTable(cls) {
104
+ if (Object.prototype.hasOwnProperty.call(cls, 'abstract') && cls.abstract) return null;
105
+ if (Object.prototype.hasOwnProperty.call(cls, '_table') && cls._table) return cls._table;
106
+ if (Object.prototype.hasOwnProperty.call(cls, 'table') && typeof cls.table === 'string' && cls.table) return cls.table;
107
+ const generated = typeof cls.table === 'string' ? cls.table : null;
108
+ return generated || null;
109
+ }
110
+
111
+ /**
112
+ * Walk a Model class's prototype chain to find the nearest ancestor that
113
+ * explicitly declares a static table property via hasOwnProperty.
114
+ *
115
+ * Correctly resolves:
116
+ * Concrete model: User.table = 'users' → 'users'
117
+ * Same-table child: AdminUser extends User → 'users' (no own table)
118
+ * Multi-table child: Employee.table='employees'→ 'employees'
119
+ * Abstract base: AuthUser (abstract=true) → null
120
+ *
121
+ * @param {Function} cls
122
+ * @returns {string|null}
123
+ */
124
+ function tableFromClass(cls) {
125
+ let current = cls;
126
+ while (current && current !== Function.prototype) {
127
+ if (Object.prototype.hasOwnProperty.call(current, 'table') && current.table) {
128
+ return current.table;
129
+ }
130
+ current = Object.getPrototypeOf(current);
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Convert a PascalCase model name to a snake_case plural table name.
137
+ *
138
+ * Used as a last resort when _fkModel is a string and no class reference
139
+ * is available. Handles irregular pluralisation:
140
+ *
141
+ * 'User' → 'users'
142
+ * 'Category' → 'categories'
143
+ * 'TaggedPost' → 'tagged_posts'
144
+ * 'UnitCategory' → 'unit_categories'
145
+ *
146
+ * @param {string} name — PascalCase model name
147
+ * @returns {string} snake_case plural table name
148
+ */
149
+ function modelNameToTable(name) {
150
+ const snake = name
151
+ .replace(/([A-Z])/g, (m, c, i) => (i ? '_' : '') + c.toLowerCase())
152
+ .replace(/^_/, '');
153
+ if (snake.endsWith('y') && !/[aeiou]y$/.test(snake)) return snake.slice(0, -1) + 'ies';
154
+ if (/(?:s|sh|ch|x|z)$/.test(snake)) return snake + 'es';
155
+ return snake + 's';
156
+ }
157
+
158
+ /**
159
+ * Return true if the string is already a snake_case table name.
160
+ * Detected by: all lowercase (no uppercase letters).
161
+ *
162
+ * 'users' → true (table name — return as-is)
163
+ * 'unit_categories' → true (table name — return as-is)
164
+ * 'User' → false (PascalCase — needs conversion)
165
+ * 'UnitCategory' → false (PascalCase — needs conversion)
166
+ *
167
+ * @param {string} str
168
+ * @returns {boolean}
169
+ */
170
+ function isSnakeCase(str) {
171
+ return str === str.toLowerCase();
172
+ }
173
+
174
+ // ─── Field comparison ─────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Compare two normalised field definitions for schema equality.
178
+ *
179
+ * Only compares the 10 schema-relevant keys — ignores internal FK metadata
180
+ * flags (_isForeignKey, _isOneToOne, _fkOnDelete, _fkRelatedName) which are
181
+ * present on live-scanned fields but absent on replayed migration state.
182
+ * Comparing those would cause phantom AlterField diffs on every makemigrations run.
183
+ *
184
+ * @param {object} a — normalised field def
185
+ * @param {object} b — normalised field def
186
+ * @returns {boolean}
187
+ */
188
+ function fieldsEqual(a, b) {
189
+ const SCHEMA_KEYS = [
190
+ 'type', 'nullable', 'unique', 'default', 'max',
191
+ 'unsigned', 'enumValues', 'references', 'precision', 'scale',
192
+ ];
193
+ for (const k of SCHEMA_KEYS) {
194
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
195
+ }
196
+ return true;
197
+ }
198
+
199
+ module.exports = {
200
+ walkJs,
201
+ extractClasses,
202
+ isMillasModel,
203
+ resolveTable,
204
+ tableFromClass,
205
+ modelNameToTable,
206
+ isSnakeCase,
207
+ fieldsEqual,
208
+ };
@@ -128,9 +128,70 @@ class Model {
128
128
  static primaryKey = 'id';
129
129
  static timestamps = true;
130
130
  static softDeletes = false;
131
- static fields = {};
132
131
  static connection = null;
133
132
 
133
+ /**
134
+ * Own fields declared directly on this class only.
135
+ * Subclasses override this with just their own additions/overrides —
136
+ * no need to spread parent fields manually (like Django's AbstractUser).
137
+ *
138
+ * class User extends AuthUser {
139
+ * static ownFields = {
140
+ * phone: fields.string({ nullable: true }),
141
+ * role: fields.enum(['tenant', 'landlord'], { default: 'tenant' }),
142
+ * };
143
+ * }
144
+ *
145
+ * You can still use 'static fields = {...}' to completely replace the schema.
146
+ */
147
+ /**
148
+ * Merged field map — walks the prototype chain and merges fields from:
149
+ * - The class itself (always)
150
+ * - Any ancestor marked 'static abstract = true' (fields flow down)
151
+ * - Concrete ancestors with the same table (single-table inheritance)
152
+ *
153
+ * Child fields win on collision. Result is cached per class.
154
+ *
155
+ * Usage — just declare what's new or overridden, no spread needed:
156
+ *
157
+ * class AuthUser extends Model {
158
+ * static abstract = true;
159
+ * static fields = { id: fields.id(), email: fields.string() };
160
+ * }
161
+ * class User extends AuthUser {
162
+ * static table = 'users';
163
+ * static fields = { phone: fields.string(), role: fields.enum([...]) };
164
+ * // User.getFields() → id, email, phone, role (merged)
165
+ * }
166
+ */
167
+ static getFields() {
168
+ if (Object.prototype.hasOwnProperty.call(this, '_cachedFields')) return this._cachedFields;
169
+
170
+ const chain = [];
171
+ const myTable = this.table || this.name;
172
+ let cur = this;
173
+
174
+ while (cur && cur !== Function.prototype) {
175
+ if (Object.prototype.hasOwnProperty.call(cur, 'fields')) {
176
+ chain.unshift(cur.fields); // ancestor first → child wins in Object.assign
177
+ }
178
+ const curTable = cur.table || cur.name;
179
+ // Stop walking when we reach a non-abstract ancestor with a different table
180
+ // (that's a separate model with its own migration — don't merge its fields)
181
+ if (cur !== this && !cur.abstract && curTable !== myTable) break;
182
+ cur = Object.getPrototypeOf(cur);
183
+ }
184
+
185
+ const merged = Object.assign({}, ...chain);
186
+ Object.defineProperty(this, '_cachedFields', {
187
+ value: merged, writable: true, configurable: true, enumerable: false,
188
+ });
189
+ return merged;
190
+ }
191
+
192
+ /** Clear the fields cache — call if fields are modified at runtime. */
193
+ static _clearFieldCache() { delete this._cachedFields; }
194
+
134
195
  /** Define named scopes: static scopes = { published: qb => qb.where('published', true) } */
135
196
  static scopes = {};
136
197
 
@@ -340,7 +401,7 @@ class Model {
340
401
  * Post.defer('body', 'metadata').get()
341
402
  */
342
403
  static defer(...columns) {
343
- const all = Object.keys(this.fields);
404
+ const all = Object.keys(this.getFields());
344
405
  const exclude = new Set(columns);
345
406
  const keep = all.filter(c => !exclude.has(c));
346
407
  return new QueryBuilder(this._db(), this).select(...keep.map(c => `${this.table}.${c}`));
@@ -495,7 +556,7 @@ class Model {
495
556
  // Start with explicitly declared relations
496
557
  const merged = { ...(this.relations || {}) };
497
558
 
498
- for (const [fieldName, fieldDef] of Object.entries(this.fields || {})) {
559
+ for (const [fieldName, fieldDef] of Object.entries(this.getFields())) {
499
560
 
500
561
  // ── ForeignKey / OneToOne ────────────────────────────────────────────
501
562
  if (fieldDef._isForeignKey) {
@@ -519,11 +580,14 @@ class Model {
519
580
  return M;
520
581
  };
521
582
 
583
+ // Django convention: declared as 'landlord' → DB column 'landlord_id'.
584
+ // If already ends with _id (e.g. declared as 'landlord_id'), use as-is.
585
+ const colName = fieldName.endsWith('_id') ? fieldName : fieldName + '_id';
586
+
522
587
  if (fieldDef._isOneToOne) {
523
- // OneToOne: BelongsTo on the declaring side
524
- merged[accessorName] = new BelongsTo(resolveModel, fieldName.endsWith('_id') ? fieldName : fieldName + '_id', toField);
588
+ merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
525
589
  } else {
526
- merged[accessorName] = new BelongsTo(resolveModel, fieldName.endsWith('_id') ? fieldName : fieldName + '_id', toField);
590
+ merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
527
591
  }
528
592
  }
529
593
  }
@@ -694,7 +758,7 @@ class Model {
694
758
 
695
759
  static _applyDefaults(data) {
696
760
  const result = { ...data };
697
- for (const [key, field] of Object.entries(this.fields)) {
761
+ for (const [key, field] of Object.entries(this.getFields())) {
698
762
  if (!(key in result) && field.default !== undefined) {
699
763
  result[key] = typeof field.default === 'function'
700
764
  ? field.default()
@@ -705,11 +769,15 @@ class Model {
705
769
  }
706
770
 
707
771
  static _defaultTable() {
708
- const name = this.name.toLowerCase();
709
- if (name.endsWith('y') && !['ay','ey','iy','oy','uy'].some(s => name.endsWith(s)))
710
- return name.slice(0, -1) + 'ies';
711
- if (/(?:s|sh|ch|x|z)$/.test(name)) return name + 'es';
712
- return name + 's';
772
+ // Convert PascalCase class name to snake_case plural table name.
773
+ // BlogPost blog_posts, Category → categories, User → users.
774
+ const snake = this.name
775
+ .replace(/([A-Z])/g, (m, c, i) => (i ? '_' : '') + c.toLowerCase())
776
+ .replace(/^_/, '');
777
+ if (snake.endsWith('y') && !['ay','ey','iy','oy','uy'].some(s => snake.endsWith(s)))
778
+ return snake.slice(0, -1) + 'ies';
779
+ if (/(?:s|sh|ch|x|z)$/.test(snake)) return snake + 'es';
780
+ return snake + 's';
713
781
  }
714
782
 
715
783
  _getDirty() {
@@ -723,4 +791,4 @@ class Model {
723
791
  }
724
792
  }
725
793
 
726
- module.exports = Model;
794
+ module.exports = Model;