@nitronjs/framework 0.1.0

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 (87) hide show
  1. package/README.md +429 -0
  2. package/cli/create.js +260 -0
  3. package/cli/njs.js +164 -0
  4. package/lib/Auth/Manager.js +111 -0
  5. package/lib/Build/Manager.js +1232 -0
  6. package/lib/Console/Commands/BuildCommand.js +25 -0
  7. package/lib/Console/Commands/DevCommand.js +385 -0
  8. package/lib/Console/Commands/MakeCommand.js +110 -0
  9. package/lib/Console/Commands/MigrateCommand.js +98 -0
  10. package/lib/Console/Commands/MigrateFreshCommand.js +97 -0
  11. package/lib/Console/Commands/SeedCommand.js +92 -0
  12. package/lib/Console/Commands/StorageLinkCommand.js +31 -0
  13. package/lib/Console/Stubs/controller.js +19 -0
  14. package/lib/Console/Stubs/middleware.js +9 -0
  15. package/lib/Console/Stubs/migration.js +23 -0
  16. package/lib/Console/Stubs/model.js +7 -0
  17. package/lib/Console/Stubs/page-hydration.tsx +54 -0
  18. package/lib/Console/Stubs/seeder.js +9 -0
  19. package/lib/Console/Stubs/vendor.tsx +11 -0
  20. package/lib/Core/Config.js +86 -0
  21. package/lib/Core/Environment.js +21 -0
  22. package/lib/Core/Paths.js +188 -0
  23. package/lib/Database/Connection.js +61 -0
  24. package/lib/Database/DB.js +84 -0
  25. package/lib/Database/Drivers/MySQLDriver.js +234 -0
  26. package/lib/Database/Manager.js +162 -0
  27. package/lib/Database/Model.js +161 -0
  28. package/lib/Database/QueryBuilder.js +714 -0
  29. package/lib/Database/QueryValidation.js +62 -0
  30. package/lib/Database/Schema/Blueprint.js +126 -0
  31. package/lib/Database/Schema/Manager.js +116 -0
  32. package/lib/Date/DateTime.js +108 -0
  33. package/lib/Date/Locale.js +68 -0
  34. package/lib/Encryption/Manager.js +47 -0
  35. package/lib/Filesystem/Manager.js +49 -0
  36. package/lib/Hashing/Manager.js +25 -0
  37. package/lib/Http/Server.js +317 -0
  38. package/lib/Logging/Manager.js +153 -0
  39. package/lib/Mail/Manager.js +120 -0
  40. package/lib/Route/Loader.js +81 -0
  41. package/lib/Route/Manager.js +265 -0
  42. package/lib/Runtime/Entry.js +11 -0
  43. package/lib/Session/File.js +299 -0
  44. package/lib/Session/Manager.js +259 -0
  45. package/lib/Session/Memory.js +67 -0
  46. package/lib/Session/Session.js +196 -0
  47. package/lib/Support/Str.js +100 -0
  48. package/lib/Translation/Manager.js +49 -0
  49. package/lib/Validation/MimeTypes.js +39 -0
  50. package/lib/Validation/Validator.js +691 -0
  51. package/lib/View/Manager.js +544 -0
  52. package/lib/View/Templates/default/Home.tsx +262 -0
  53. package/lib/View/Templates/default/MainLayout.tsx +44 -0
  54. package/lib/View/Templates/errors/404.tsx +13 -0
  55. package/lib/View/Templates/errors/500.tsx +13 -0
  56. package/lib/View/Templates/errors/ErrorLayout.tsx +112 -0
  57. package/lib/View/Templates/messages/Maintenance.tsx +17 -0
  58. package/lib/View/Templates/messages/MessageLayout.tsx +136 -0
  59. package/lib/index.js +57 -0
  60. package/package.json +47 -0
  61. package/skeleton/.env.example +26 -0
  62. package/skeleton/app/Controllers/HomeController.js +9 -0
  63. package/skeleton/app/Kernel.js +11 -0
  64. package/skeleton/app/Middlewares/Authentication.js +9 -0
  65. package/skeleton/app/Middlewares/Guest.js +9 -0
  66. package/skeleton/app/Middlewares/VerifyCsrf.js +24 -0
  67. package/skeleton/app/Models/User.js +7 -0
  68. package/skeleton/config/app.js +4 -0
  69. package/skeleton/config/auth.js +16 -0
  70. package/skeleton/config/database.js +27 -0
  71. package/skeleton/config/hash.js +3 -0
  72. package/skeleton/config/server.js +28 -0
  73. package/skeleton/config/session.js +21 -0
  74. package/skeleton/database/migrations/2025_01_01_00_00_users.js +20 -0
  75. package/skeleton/database/seeders/UserSeeder.js +15 -0
  76. package/skeleton/globals.d.ts +1 -0
  77. package/skeleton/package.json +24 -0
  78. package/skeleton/public/.gitkeep +0 -0
  79. package/skeleton/resources/css/.gitkeep +0 -0
  80. package/skeleton/resources/langs/.gitkeep +0 -0
  81. package/skeleton/resources/views/Site/Home.tsx +66 -0
  82. package/skeleton/routes/web.js +4 -0
  83. package/skeleton/storage/app/private/.gitkeep +0 -0
  84. package/skeleton/storage/app/public/.gitkeep +0 -0
  85. package/skeleton/storage/framework/sessions/.gitkeep +0 -0
  86. package/skeleton/storage/logs/.gitkeep +0 -0
  87. package/skeleton/tsconfig.json +33 -0
@@ -0,0 +1,61 @@
1
+ class Connection {
2
+ #driver = null;
3
+ #name = null;
4
+
5
+ constructor(name, driver) {
6
+ this.#name = name;
7
+ this.#driver = driver;
8
+ }
9
+
10
+ async query(sql, bindings = []) {
11
+ return await this.#driver.query(sql, bindings);
12
+ }
13
+
14
+ async raw(sql) {
15
+ return await this.#driver.raw(sql);
16
+ }
17
+
18
+ async withTransaction(callback, options = {}) {
19
+ const timeout = options.timeout || 30000; // 30 seconds default
20
+ const controller = new AbortController();
21
+ const timeoutId = setTimeout(() => {
22
+ controller.abort();
23
+ }, timeout);
24
+
25
+ try {
26
+ const result = await this.#driver.withTransaction(callback, {
27
+ signal: controller.signal
28
+ });
29
+
30
+ clearTimeout(timeoutId);
31
+ return result;
32
+ } catch (err) {
33
+ clearTimeout(timeoutId);
34
+ if (controller.signal.aborted) {
35
+ throw new Error(
36
+ `Transaction timeout after ${timeout}ms. ` +
37
+ 'This may indicate a forgotten await in the callback or a slow query. ' +
38
+ 'Consider: 1) Adding await to all async operations, 2) Increasing timeout, 3) Optimizing queries.'
39
+ );
40
+ }
41
+
42
+ throw err;
43
+ }
44
+ }
45
+
46
+ async healthCheck() {
47
+ return await this.#driver.healthCheck();
48
+ }
49
+
50
+ getName() {
51
+ return this.#name;
52
+ }
53
+
54
+ async close() {
55
+ if (this.#driver && typeof this.#driver.close === 'function') {
56
+ await this.#driver.close();
57
+ }
58
+ }
59
+ }
60
+
61
+ export default Connection;
@@ -0,0 +1,84 @@
1
+ import DatabaseManager from "./Manager.js";
2
+ import { query as createQueryBuilder, RawExpression } from "./QueryBuilder.js";
3
+
4
+ class DB {
5
+ static manager() {
6
+ return DatabaseManager.getInstance();
7
+ }
8
+
9
+ static connection(name = null) {
10
+ if (!this.#isEnabled()) return null;
11
+ return this.manager().connection(name);
12
+ }
13
+
14
+ static async query(sql, bindings = []) {
15
+ if (!this.#isEnabled()) return null;
16
+ return await this.manager().query(sql, bindings);
17
+ }
18
+
19
+ static rawExpr(expression) {
20
+ return new RawExpression(expression);
21
+ }
22
+
23
+ static async rawQuery(sql) {
24
+ if (!this.#isEnabled()) return null;
25
+ return await this.connection().raw(sql);
26
+ }
27
+
28
+ static async transaction(callback) {
29
+ if (!this.#isEnabled()) return null;
30
+ return await this.connection().withTransaction(callback);
31
+ }
32
+
33
+ /**
34
+ * Create a query builder for a table.
35
+ * Uses lazy connection loading - connection is fetched only when query is executed.
36
+ * @param {string} table - The table name
37
+ * @param {string|null} connectionName - Optional connection name
38
+ * @param {Function|null} modelClass - Optional model class for hydration
39
+ * @returns {QueryBuilder} A query builder instance
40
+ */
41
+ static table(table, connectionName = null, modelClass = null) {
42
+ // Pass connection directly now (synchronous)
43
+ const connection = this.connection(connectionName);
44
+ return createQueryBuilder(table, connection, modelClass);
45
+ }
46
+
47
+ static async setup() {
48
+ if (!this.#isEnabled()) return;
49
+
50
+ const manager = this.manager();
51
+ const defaultConnection = manager.getDefaultConnection();
52
+
53
+ try {
54
+ const connection = manager.connection(defaultConnection);
55
+ const isHealthy = await connection.healthCheck();
56
+
57
+ if (!isHealthy) {
58
+ throw new Error(`Database connection '${defaultConnection}' health check failed`);
59
+ }
60
+ }
61
+ catch (error) {
62
+ console.error('[Database] Setup failed:', error.message);
63
+ console.error('[Database] Check your .env credentials and ensure MySQL is running');
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ static async close() {
69
+ if (!this.#isEnabled()) return;
70
+
71
+ try {
72
+ await this.manager().closeAll();
73
+ }
74
+ catch (error) {
75
+ console.error('[Database] Error during shutdown:', error.message);
76
+ }
77
+ }
78
+
79
+ static #isEnabled() {
80
+ return !!DatabaseManager.getInstance().getDefaultConnection();
81
+ }
82
+ }
83
+
84
+ export default DB;
@@ -0,0 +1,234 @@
1
+ import mysql from 'mysql2/promise';
2
+
3
+ class MySQLDriver {
4
+ #pool = null;
5
+ #config = null;
6
+ #credentials = null;
7
+
8
+ constructor(config, credentials = null) {
9
+ this.#config = config;
10
+ this.#credentials = credentials;
11
+ this.#createPool();
12
+ }
13
+
14
+ #createPool() {
15
+ const creds = this.#credentials;
16
+
17
+ this.#pool = mysql.createPool({
18
+ host: creds?.host ?? process.env.DATABASE_HOST ?? '127.0.0.1',
19
+ port: parseInt(creds?.port ?? process.env.DATABASE_PORT ?? '3306'),
20
+ user: creds?.username ?? process.env.DATABASE_USERNAME ?? 'root',
21
+ password: creds?.password ?? process.env.DATABASE_PASSWORD ?? '',
22
+ database: creds?.database ?? process.env.DATABASE_NAME ?? 'test',
23
+ charset: this.#config.charset || 'utf8mb4',
24
+
25
+ waitForConnections: true,
26
+ connectionLimit: this.#config.pool?.max ?? 10,
27
+ queueLimit: this.#config.pool?.queueLimit ?? 100,
28
+ connectTimeout: 10000,
29
+
30
+ enableKeepAlive: true,
31
+ keepAliveInitialDelay: 10000,
32
+ namedPlaceholders: true,
33
+ decimalNumbers: true
34
+ });
35
+ }
36
+
37
+ async query(sql, bindings = []) {
38
+ const isProduction = process.env.APP_DEV === 'false';
39
+
40
+ try {
41
+ const connection = await this.#pool.getConnection();
42
+
43
+ try {
44
+ await connection.query('SET SESSION MAX_EXECUTION_TIME = 60000');
45
+ const [rows, fields] = await connection.execute(sql, bindings);
46
+
47
+ return [rows, fields];
48
+ }
49
+ finally {
50
+ connection.release();
51
+ }
52
+ }
53
+
54
+ catch (error) {
55
+ if (isProduction) {
56
+ console.error('[MySQLDriver] Query failed', {
57
+ code: error.code,
58
+ errno: error.errno,
59
+ sqlState: error.sqlState
60
+ });
61
+
62
+ const sanitized = new Error('Database query failed');
63
+ sanitized.code = error.code;
64
+ sanitized.driver = 'mysql';
65
+ throw sanitized;
66
+ }
67
+
68
+ error.sql = sql;
69
+ error.bindings = this.#maskSensitiveData(bindings);
70
+ error.driver = 'mysql';
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ async raw(sql) {
76
+ const isProduction = process.env.APP_DEV === 'false';
77
+
78
+ try {
79
+ const [rows, fields] = await this.#pool.query(sql);
80
+ return [rows, fields];
81
+ }
82
+
83
+ catch (error) {
84
+ if (isProduction) {
85
+ console.error('[MySQLDriver] Raw query failed', {
86
+ code: error.code,
87
+ errno: error.errno,
88
+ sqlState: error.sqlState
89
+ });
90
+
91
+ const sanitized = new Error('Database query failed');
92
+ sanitized.code = error.code;
93
+ sanitized.driver = 'mysql';
94
+ throw sanitized;
95
+ }
96
+
97
+ error.sql = sql;
98
+ error.driver = 'mysql';
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ #maskSensitiveData(bindings) {
104
+ if (process.env.APP_DEV === 'true') {
105
+ return bindings; // Show all in development
106
+ }
107
+
108
+ return bindings.map((value, index) => {
109
+ if (typeof value !== 'string') {
110
+ return value;
111
+ }
112
+
113
+ const sensitivePatterns = /password|token|secret|key|auth|bearer|jwt|otp|pin|credential/i;
114
+ const valuePreview = value.substring(0, 30).toLowerCase();
115
+ const looksLikeBase64 = /^[A-Za-z0-9+/]+=*$/.test(value) && value.length > 16;
116
+
117
+ if (sensitivePatterns.test(valuePreview) || value.length > 20 || looksLikeBase64) {
118
+ return '***MASKED***';
119
+ }
120
+
121
+ return value;
122
+ });
123
+ }
124
+
125
+ async beginTransaction() {
126
+ const connection = await this.#pool.getConnection();
127
+ await connection.query('SET SESSION MAX_EXECUTION_TIME = 60000');
128
+
129
+ await connection.beginTransaction();
130
+
131
+ return connection;
132
+ }
133
+
134
+ async commit(connection) {
135
+ try {
136
+ await connection.commit();
137
+ }
138
+
139
+ finally {
140
+ connection.release();
141
+ }
142
+ }
143
+
144
+ async rollback(connection) {
145
+ try {
146
+ await connection.rollback();
147
+ }
148
+
149
+ catch (error) {
150
+ }
151
+
152
+ finally {
153
+ connection.release();
154
+ }
155
+ }
156
+
157
+ async transactionQuery(connection, sql, bindings = []) {
158
+ try {
159
+ const [rows, fields] = await connection.execute(sql, bindings);
160
+ return [rows, fields];
161
+ }
162
+
163
+ catch (error) {
164
+ const isProduction = process.env.APP_DEV === 'false';
165
+
166
+ if (isProduction) {
167
+ console.error('[MySQLDriver] Transaction query failed', {
168
+ code: error.code,
169
+ errno: error.errno,
170
+ sqlState: error.sqlState,
171
+ inTransaction: true
172
+ });
173
+
174
+ const sanitized = new Error('Database query failed');
175
+ sanitized.code = error.code;
176
+ sanitized.driver = 'mysql';
177
+ sanitized.inTransaction = true;
178
+ throw sanitized;
179
+ }
180
+
181
+ error.sql = sql;
182
+ error.bindings = this.#maskSensitiveData(bindings);
183
+ error.driver = 'mysql';
184
+ error.inTransaction = true;
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ async withTransaction(callback, options = {}) {
190
+ const signal = options?.signal;
191
+ const connection = await this.beginTransaction();
192
+
193
+ try {
194
+ if (signal?.aborted) {
195
+ throw new Error('Transaction aborted');
196
+ }
197
+
198
+ const result = await callback(connection);
199
+ if (signal?.aborted) {
200
+ await this.rollback(connection);
201
+ throw new Error('Transaction aborted');
202
+ }
203
+
204
+ await this.commit(connection);
205
+
206
+ return result;
207
+ }
208
+
209
+ catch (error) {
210
+ await this.rollback(connection);
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ async healthCheck() {
216
+ try {
217
+ await this.#pool.query('SELECT 1');
218
+ return true;
219
+ }
220
+
221
+ catch (error) {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ async close() {
227
+ if (this.#pool) {
228
+ await this.#pool.end();
229
+ this.#pool = null;
230
+ }
231
+ }
232
+ }
233
+
234
+ export default MySQLDriver;
@@ -0,0 +1,162 @@
1
+ import Connection from './Connection.js';
2
+ import MySQLDriver from './Drivers/MySQLDriver.js';
3
+ import Config from '../Core/Config.js';
4
+
5
+ class DatabaseManager {
6
+
7
+ static instance = null;
8
+ static #isInternal = false;
9
+
10
+ #connections = new Map();
11
+ #defaultConnection = null;
12
+ #config = null;
13
+ #credentials = null;
14
+
15
+ constructor() {
16
+ if (!DatabaseManager.#isInternal) {
17
+ throw new Error('DatabaseManager must be initialized with await DatabaseManager.getInstance()');
18
+ }
19
+ }
20
+
21
+ static createInstance() {
22
+ DatabaseManager.#isInternal = true;
23
+ const instance = new DatabaseManager();
24
+ DatabaseManager.#isInternal = false;
25
+
26
+ instance.#config = Config.all('database');
27
+ instance.#initializeDefaultConnection();
28
+ return instance;
29
+ }
30
+
31
+ #initializeDefaultConnection() {
32
+ const envDriver = (process.env.DATABASE_DRIVER || 'mysql').toLowerCase();
33
+ const normalized = envDriver === 'postgresql' ? 'postgres' : envDriver;
34
+
35
+ this.#defaultConnection = normalized === 'none' ? null : normalized;
36
+ }
37
+
38
+ static getInstance() {
39
+ if (!this.instance) {
40
+ this.instance = DatabaseManager.createInstance();
41
+ }
42
+
43
+ return this.instance;
44
+ }
45
+
46
+ // ========================================
47
+ // Connection Management
48
+ // ========================================
49
+
50
+ connection(name = null) {
51
+ const connectionName = name || this.#defaultConnection;
52
+
53
+ if (!connectionName) {
54
+ throw new Error('Database is disabled (DATABASE_DRIVER=none)');
55
+ }
56
+
57
+ if (this.#connections.has(connectionName)) {
58
+ return this.#connections.get(connectionName);
59
+ }
60
+
61
+ const config = this.#config.connections[connectionName];
62
+
63
+ if (!config) {
64
+ throw new Error(`Database connection '${connectionName}' is not configured`);
65
+ }
66
+
67
+ return this.#addConnection(connectionName, config);
68
+ }
69
+
70
+ #addConnection(name, config) {
71
+ const driver = this.#createDriver(config);
72
+ const connection = new Connection(name, driver);
73
+
74
+ this.#connections.set(name, connection);
75
+
76
+ return connection;
77
+ }
78
+
79
+ #createDriver(config) {
80
+ switch (config.driver) {
81
+ case 'mysql':
82
+ return new MySQLDriver(config, this.#credentials);
83
+
84
+ case 'postgres':
85
+ throw new Error('Postgres driver not yet implemented');
86
+
87
+ case 'mongodb':
88
+ throw new Error('MongoDB driver not yet implemented');
89
+
90
+ default:
91
+ throw new Error(`Unknown database driver: ${config.driver}`);
92
+ }
93
+ }
94
+
95
+ // ========================================
96
+ // Reconfiguration (for Installer)
97
+ // ========================================
98
+
99
+ /**
100
+ * Reconfigure database with new credentials
101
+ * Closes existing connections and creates new pool with provided credentials
102
+ */
103
+ async reconfigure(credentials) {
104
+ await this.closeAll();
105
+
106
+ this.#credentials = {
107
+ host: credentials.host,
108
+ port: String(credentials.port),
109
+ database: credentials.database,
110
+ username: credentials.username,
111
+ password: credentials.password || ""
112
+ };
113
+
114
+ this.#defaultConnection = "mysql";
115
+ }
116
+
117
+ // ========================================
118
+ // Query & Health
119
+ // ========================================
120
+
121
+ async query(sql, bindings = []) {
122
+ return await this.connection().query(sql, bindings);
123
+ }
124
+
125
+ async healthCheck() {
126
+ const results = {};
127
+
128
+ for (const [name, connection] of this.#connections.entries()) {
129
+ try {
130
+ results[name] = await connection.healthCheck();
131
+ }
132
+ catch (error) {
133
+ results[name] = false;
134
+ }
135
+ }
136
+
137
+ return results;
138
+ }
139
+
140
+ // ========================================
141
+ // Lifecycle
142
+ // ========================================
143
+
144
+ async closeAll() {
145
+ for (const [name, connection] of this.#connections.entries()) {
146
+ try {
147
+ await connection.close();
148
+ }
149
+ catch (error) {
150
+ // Ignore close errors
151
+ }
152
+ }
153
+
154
+ this.#connections.clear();
155
+ }
156
+
157
+ getDefaultConnection() {
158
+ return this.#defaultConnection;
159
+ }
160
+ }
161
+
162
+ export default DatabaseManager;
@@ -0,0 +1,161 @@
1
+ import DB from './DB.js';
2
+
3
+ class Model {
4
+ static table = null;
5
+ static primaryKey = 'id';
6
+
7
+ constructor(attrs = {}) {
8
+ Object.defineProperty(this, '_attributes', { value: {}, writable: true });
9
+ Object.defineProperty(this, '_original', { value: {}, writable: true });
10
+ Object.defineProperty(this, '_exists', { value: false, writable: true });
11
+
12
+ Object.assign(this._attributes, attrs);
13
+ this._original = { ...this._attributes };
14
+
15
+ return new Proxy(this, {
16
+ get(target, prop) {
17
+ if (prop.startsWith('_') || typeof target[prop] === 'function' || prop === 'constructor') {
18
+ return target[prop];
19
+ }
20
+ return target._attributes[prop];
21
+ },
22
+ set(target, prop, value) {
23
+ if (prop.startsWith('_')) {
24
+ target[prop] = value;
25
+ return true;
26
+ }
27
+ if (prop in target) return false;
28
+
29
+ target._attributes[prop] = value;
30
+
31
+ return true;
32
+ }
33
+ });
34
+ }
35
+
36
+ static async get() {
37
+ if (!this.table) {
38
+ throw new Error(`Model ${this.name} must define a static 'table' property`);
39
+ }
40
+
41
+ const rows = await DB.table(this.table).get();
42
+
43
+ return rows.map(row => {
44
+ const instance = new this();
45
+
46
+ for (const [key, value] of Object.entries(row)) {
47
+ if (value instanceof Date) {
48
+ instance._attributes[key] = value.toISOString().slice(0, 19).replace('T', ' ');
49
+ } else {
50
+ instance._attributes[key] = value;
51
+ }
52
+ }
53
+
54
+ instance._exists = true;
55
+ instance._original = { ...instance._attributes };
56
+
57
+ return instance;
58
+ });
59
+ }
60
+
61
+ static async find(id) {
62
+ if (!this.table) {
63
+ throw new Error(`Model ${this.name} must define a static 'table' property`);
64
+ }
65
+
66
+ const row = await DB.table(this.table).where(this.primaryKey, id).first();
67
+
68
+ if (!row) return null;
69
+
70
+ const instance = new this();
71
+
72
+ for (const [key, value] of Object.entries(row)) {
73
+ if (value instanceof Date) {
74
+ instance._attributes[key] = value.toISOString().slice(0, 19).replace('T', ' ');
75
+ } else {
76
+ instance._attributes[key] = value;
77
+ }
78
+ }
79
+
80
+ instance._exists = true;
81
+ instance._original = { ...instance._attributes };
82
+
83
+ return instance;
84
+ }
85
+
86
+ static where(column, operator, value) {
87
+ if (!this.table) {
88
+ throw new Error(`Model ${this.name} must define a static 'table' property`);
89
+ }
90
+
91
+ return DB.table(this.table, null, this).where(column, operator, value);
92
+ }
93
+
94
+ static select(...columns) {
95
+ if (!this.table) {
96
+ throw new Error(`Model ${this.name} must define a static 'table' property`);
97
+ }
98
+
99
+ return DB.table(this.table, null, this).select(...columns);
100
+ }
101
+
102
+ static async first() {
103
+ if (!this.table) {
104
+ throw new Error(`Model ${this.name} must define a static 'table' property`);
105
+ }
106
+
107
+ return await DB.table(this.table, null, this).limit(1).first();
108
+ }
109
+
110
+ async save() {
111
+ const constructor = this.constructor;
112
+ const data = {};
113
+
114
+ for (const [key, value] of Object.entries(this._attributes)) {
115
+ if (value !== undefined && (key !== constructor.primaryKey || !this._exists)) {
116
+ data[key] = value;
117
+ }
118
+ }
119
+
120
+ if (this._exists) {
121
+ const primaryKeyValue = this._attributes[constructor.primaryKey];
122
+ await DB.table(constructor.table)
123
+ .where(constructor.primaryKey, primaryKeyValue)
124
+ .update(data);
125
+
126
+ Object.assign(this._attributes, data);
127
+ this._original = { ...this._attributes };
128
+ }
129
+ else {
130
+ const id = await DB.table(constructor.table).insert(data);
131
+ this._attributes[constructor.primaryKey] = id;
132
+
133
+ this._original = { ...this._attributes };
134
+ this._exists = true;
135
+ }
136
+
137
+ return this;
138
+ }
139
+
140
+ async delete() {
141
+ const constructor = this.constructor;
142
+ const primaryKeyValue = this._attributes[constructor.primaryKey];
143
+
144
+ if (!this._exists) {
145
+ throw new Error('Cannot delete a model that does not exist');
146
+ }
147
+
148
+ await DB.table(constructor.table)
149
+ .where(constructor.primaryKey, primaryKeyValue)
150
+ .delete();
151
+
152
+ this._exists = false;
153
+ return true;
154
+ }
155
+
156
+ toObject() {
157
+ return { ...this._attributes };
158
+ }
159
+ }
160
+
161
+ export default Model;