@buenojs/bueno 0.8.3 → 0.8.5

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 (218) hide show
  1. package/README.md +136 -16
  2. package/dist/cli/{index.js → bin.js} +3036 -1421
  3. package/dist/container/index.js +250 -0
  4. package/dist/context/index.js +219 -0
  5. package/dist/database/index.js +493 -0
  6. package/dist/frontend/index.js +7697 -0
  7. package/dist/health/index.js +364 -0
  8. package/dist/i18n/index.js +345 -0
  9. package/dist/index.js +11043 -6482
  10. package/dist/jobs/index.js +819 -0
  11. package/dist/lock/index.js +367 -0
  12. package/dist/logger/index.js +281 -0
  13. package/dist/metrics/index.js +289 -0
  14. package/dist/middleware/index.js +77 -0
  15. package/dist/migrations/index.js +571 -0
  16. package/dist/modules/index.js +3346 -0
  17. package/dist/notification/index.js +484 -0
  18. package/dist/observability/index.js +331 -0
  19. package/dist/openapi/index.js +776 -0
  20. package/dist/orm/index.js +1356 -0
  21. package/dist/router/index.js +886 -0
  22. package/dist/rpc/index.js +691 -0
  23. package/dist/schema/index.js +400 -0
  24. package/dist/telemetry/index.js +595 -0
  25. package/dist/template/index.js +640 -0
  26. package/dist/templates/index.js +640 -0
  27. package/dist/testing/index.js +1111 -0
  28. package/dist/types/index.js +60 -0
  29. package/package.json +121 -27
  30. package/src/cache/index.ts +2 -1
  31. package/src/cli/bin.ts +2 -2
  32. package/src/cli/commands/build.ts +183 -165
  33. package/src/cli/commands/dev.ts +96 -89
  34. package/src/cli/commands/generate.ts +142 -111
  35. package/src/cli/commands/help.ts +20 -16
  36. package/src/cli/commands/index.ts +3 -6
  37. package/src/cli/commands/migration.ts +124 -105
  38. package/src/cli/commands/new.ts +392 -438
  39. package/src/cli/commands/start.ts +81 -79
  40. package/src/cli/core/args.ts +68 -50
  41. package/src/cli/core/console.ts +89 -95
  42. package/src/cli/core/index.ts +4 -4
  43. package/src/cli/core/prompt.ts +65 -62
  44. package/src/cli/core/spinner.ts +23 -20
  45. package/src/cli/index.ts +46 -38
  46. package/src/cli/templates/database/index.ts +61 -0
  47. package/src/cli/templates/database/mysql.ts +14 -0
  48. package/src/cli/templates/database/none.ts +16 -0
  49. package/src/cli/templates/database/postgresql.ts +14 -0
  50. package/src/cli/templates/database/sqlite.ts +14 -0
  51. package/src/cli/templates/deploy.ts +29 -26
  52. package/src/cli/templates/docker.ts +41 -30
  53. package/src/cli/templates/frontend/index.ts +63 -0
  54. package/src/cli/templates/frontend/none.ts +17 -0
  55. package/src/cli/templates/frontend/react.ts +140 -0
  56. package/src/cli/templates/frontend/solid.ts +134 -0
  57. package/src/cli/templates/frontend/svelte.ts +131 -0
  58. package/src/cli/templates/frontend/vue.ts +130 -0
  59. package/src/cli/templates/generators/index.ts +339 -0
  60. package/src/cli/templates/generators/types.ts +56 -0
  61. package/src/cli/templates/index.ts +35 -2
  62. package/src/cli/templates/project/api.ts +81 -0
  63. package/src/cli/templates/project/default.ts +140 -0
  64. package/src/cli/templates/project/fullstack.ts +111 -0
  65. package/src/cli/templates/project/index.ts +95 -0
  66. package/src/cli/templates/project/minimal.ts +45 -0
  67. package/src/cli/templates/project/types.ts +94 -0
  68. package/src/cli/templates/project/website.ts +263 -0
  69. package/src/cli/utils/fs.ts +55 -41
  70. package/src/cli/utils/index.ts +3 -2
  71. package/src/cli/utils/strings.ts +47 -33
  72. package/src/cli/utils/version.ts +47 -0
  73. package/src/config/env-validation.ts +100 -0
  74. package/src/config/env.ts +169 -41
  75. package/src/config/index.ts +28 -20
  76. package/src/config/loader.ts +25 -16
  77. package/src/config/merge.ts +21 -10
  78. package/src/config/types.ts +545 -25
  79. package/src/config/validation.ts +215 -7
  80. package/src/container/forward-ref.ts +22 -22
  81. package/src/container/index.ts +34 -12
  82. package/src/context/index.ts +11 -1
  83. package/src/database/index.ts +7 -190
  84. package/src/database/orm/builder.ts +457 -0
  85. package/src/database/orm/casts/index.ts +130 -0
  86. package/src/database/orm/casts/types.ts +25 -0
  87. package/src/database/orm/compiler.ts +304 -0
  88. package/src/database/orm/hooks/index.ts +114 -0
  89. package/src/database/orm/index.ts +61 -0
  90. package/src/database/orm/model-registry.ts +59 -0
  91. package/src/database/orm/model.ts +821 -0
  92. package/src/database/orm/relationships/base.ts +146 -0
  93. package/src/database/orm/relationships/belongs-to-many.ts +179 -0
  94. package/src/database/orm/relationships/belongs-to.ts +56 -0
  95. package/src/database/orm/relationships/has-many.ts +45 -0
  96. package/src/database/orm/relationships/has-one.ts +41 -0
  97. package/src/database/orm/relationships/index.ts +11 -0
  98. package/src/database/orm/scopes/index.ts +55 -0
  99. package/src/events/__tests__/event-system.test.ts +235 -0
  100. package/src/events/config.ts +238 -0
  101. package/src/events/example-usage.ts +185 -0
  102. package/src/events/index.ts +278 -0
  103. package/src/events/manager.ts +385 -0
  104. package/src/events/registry.ts +182 -0
  105. package/src/events/types.ts +124 -0
  106. package/src/frontend/api-routes.ts +65 -23
  107. package/src/frontend/bundler.ts +76 -34
  108. package/src/frontend/console-client.ts +2 -2
  109. package/src/frontend/console-stream.ts +94 -38
  110. package/src/frontend/dev-server.ts +94 -46
  111. package/src/frontend/file-router.ts +61 -19
  112. package/src/frontend/frameworks/index.ts +37 -10
  113. package/src/frontend/frameworks/react.ts +10 -8
  114. package/src/frontend/frameworks/solid.ts +11 -9
  115. package/src/frontend/frameworks/svelte.ts +15 -9
  116. package/src/frontend/frameworks/vue.ts +13 -11
  117. package/src/frontend/hmr-client.ts +12 -10
  118. package/src/frontend/hmr.ts +146 -103
  119. package/src/frontend/index.ts +14 -5
  120. package/src/frontend/islands.ts +41 -22
  121. package/src/frontend/isr.ts +59 -37
  122. package/src/frontend/layout.ts +36 -21
  123. package/src/frontend/ssr/react.ts +74 -27
  124. package/src/frontend/ssr/solid.ts +54 -20
  125. package/src/frontend/ssr/svelte.ts +48 -14
  126. package/src/frontend/ssr/vue.ts +50 -18
  127. package/src/frontend/ssr.ts +83 -39
  128. package/src/frontend/types.ts +91 -56
  129. package/src/health/index.ts +21 -9
  130. package/src/i18n/engine.ts +305 -0
  131. package/src/i18n/index.ts +38 -0
  132. package/src/i18n/loader.ts +218 -0
  133. package/src/i18n/middleware.ts +164 -0
  134. package/src/i18n/negotiator.ts +162 -0
  135. package/src/i18n/types.ts +158 -0
  136. package/src/index.ts +179 -27
  137. package/src/jobs/drivers/memory.ts +315 -0
  138. package/src/jobs/drivers/redis.ts +459 -0
  139. package/src/jobs/index.ts +30 -0
  140. package/src/jobs/queue.ts +281 -0
  141. package/src/jobs/types.ts +295 -0
  142. package/src/jobs/worker.ts +380 -0
  143. package/src/logger/index.ts +1 -3
  144. package/src/logger/transports/index.ts +62 -22
  145. package/src/metrics/index.ts +25 -16
  146. package/src/migrations/index.ts +9 -0
  147. package/src/modules/filters.ts +13 -17
  148. package/src/modules/guards.ts +49 -26
  149. package/src/modules/index.ts +409 -298
  150. package/src/modules/interceptors.ts +58 -20
  151. package/src/modules/lazy.ts +11 -19
  152. package/src/modules/lifecycle.ts +15 -7
  153. package/src/modules/metadata.ts +15 -5
  154. package/src/modules/pipes.ts +94 -72
  155. package/src/notification/channels/base.ts +68 -0
  156. package/src/notification/channels/email.ts +105 -0
  157. package/src/notification/channels/push.ts +104 -0
  158. package/src/notification/channels/sms.ts +105 -0
  159. package/src/notification/channels/whatsapp.ts +104 -0
  160. package/src/notification/index.ts +48 -0
  161. package/src/notification/service.ts +354 -0
  162. package/src/notification/types.ts +344 -0
  163. package/src/observability/__tests__/observability.test.ts +483 -0
  164. package/src/observability/breadcrumbs.ts +114 -0
  165. package/src/observability/index.ts +136 -0
  166. package/src/observability/interceptor.ts +85 -0
  167. package/src/observability/service.ts +303 -0
  168. package/src/observability/trace.ts +37 -0
  169. package/src/observability/types.ts +196 -0
  170. package/src/openapi/__tests__/decorators.test.ts +335 -0
  171. package/src/openapi/__tests__/document-builder.test.ts +285 -0
  172. package/src/openapi/__tests__/route-scanner.test.ts +334 -0
  173. package/src/openapi/__tests__/schema-generator.test.ts +275 -0
  174. package/src/openapi/decorators.ts +328 -0
  175. package/src/openapi/document-builder.ts +274 -0
  176. package/src/openapi/index.ts +112 -0
  177. package/src/openapi/metadata.ts +112 -0
  178. package/src/openapi/route-scanner.ts +289 -0
  179. package/src/openapi/schema-generator.ts +256 -0
  180. package/src/openapi/swagger-module.ts +166 -0
  181. package/src/openapi/types.ts +398 -0
  182. package/src/orm/index.ts +10 -0
  183. package/src/rpc/index.ts +3 -1
  184. package/src/schema/index.ts +9 -0
  185. package/src/security/index.ts +15 -6
  186. package/src/ssg/index.ts +9 -8
  187. package/src/telemetry/index.ts +76 -22
  188. package/src/template/index.ts +7 -0
  189. package/src/templates/engine.ts +224 -0
  190. package/src/templates/index.ts +9 -0
  191. package/src/templates/loader.ts +331 -0
  192. package/src/templates/renderers/markdown.ts +212 -0
  193. package/src/templates/renderers/simple.ts +269 -0
  194. package/src/templates/types.ts +154 -0
  195. package/src/testing/index.ts +100 -27
  196. package/src/types/optional-deps.d.ts +347 -187
  197. package/src/validation/index.ts +92 -2
  198. package/src/validation/schemas.ts +536 -0
  199. package/tests/integration/fullstack.test.ts +4 -4
  200. package/tests/unit/database.test.ts +2 -72
  201. package/tests/unit/env-validation.test.ts +166 -0
  202. package/tests/unit/events.test.ts +910 -0
  203. package/tests/unit/i18n.test.ts +455 -0
  204. package/tests/unit/jobs.test.ts +493 -0
  205. package/tests/unit/notification.test.ts +988 -0
  206. package/tests/unit/observability.test.ts +453 -0
  207. package/tests/unit/orm/builder.test.ts +323 -0
  208. package/tests/unit/orm/casts.test.ts +179 -0
  209. package/tests/unit/orm/compiler.test.ts +220 -0
  210. package/tests/unit/orm/eager-loading.test.ts +285 -0
  211. package/tests/unit/orm/hooks.test.ts +191 -0
  212. package/tests/unit/orm/model.test.ts +373 -0
  213. package/tests/unit/orm/relationships.test.ts +303 -0
  214. package/tests/unit/orm/scopes.test.ts +74 -0
  215. package/tests/unit/templates-simple.test.ts +53 -0
  216. package/tests/unit/templates.test.ts +454 -0
  217. package/tests/unit/validation.test.ts +18 -24
  218. package/tsconfig.json +11 -3
@@ -0,0 +1,821 @@
1
+ // @ts-nocheck - Abstract class generic constraints cause false positives in TypeScript.
2
+ // The implementation is logically correct; see .idea/orm-implementation-status.md
3
+
4
+ /**
5
+ * Model Base Class
6
+ *
7
+ * Active Record pattern implementation inspired by Laravel Eloquent.
8
+ * Provides model lifecycle, attributes, persistence, and relationships.
9
+ */
10
+
11
+ import type { Database } from "../index";
12
+ import { OrmQueryBuilder } from "./builder";
13
+ import { type CastDefinition, CastRegistry } from "./casts";
14
+ import {
15
+ HookRunner,
16
+ type ModelHookCallback,
17
+ type ModelHookName,
18
+ } from "./hooks";
19
+ import { getModelDatabase, registerModelDatabase } from "./model-registry";
20
+ import { BelongsTo, BelongsToMany, HasMany, HasOne } from "./relationships";
21
+ import type { ScopeDefinition, ScopeRegistry } from "./scopes";
22
+
23
+ export class ModelNotFoundError extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "ModelNotFoundError";
27
+ }
28
+ }
29
+
30
+ export class ModelOperationAbortedError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "ModelOperationAbortedError";
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Base Model class
39
+ *
40
+ * Generic over TAttributes (the shape of model attributes)
41
+ */
42
+ export abstract class Model<
43
+ TAttributes extends Record<string, unknown> = Record<string, unknown>,
44
+ > {
45
+ // ============= Static Configuration =============
46
+
47
+ static readonly table: string;
48
+ static readonly primaryKey: string = "id";
49
+ static readonly timestamps: boolean = true;
50
+ static readonly createdAtColumn: string = "created_at";
51
+ static readonly updatedAtColumn: string = "updated_at";
52
+ static readonly softDeletes: boolean = false;
53
+ static readonly deletedAtColumn: string = "deleted_at";
54
+ static readonly fillable: string[] = [];
55
+ static readonly guarded: string[] = [];
56
+ static readonly casts: Record<string, CastDefinition> = {};
57
+
58
+ private static scopeRegistry = new Map<string, ScopeRegistry<any>>();
59
+ private static hookRegistry = new Map<
60
+ string,
61
+ Map<ModelHookName, ModelHookCallback<any>[]>
62
+ >();
63
+
64
+ // ============= Instance State =============
65
+
66
+ protected _attributes: TAttributes = {} as TAttributes;
67
+ protected _original: TAttributes = {} as TAttributes;
68
+ protected _relations: Map<string, unknown> = new Map();
69
+ protected _exists = false;
70
+ protected _isDirty = false;
71
+
72
+ // ============= Constructor / Initialization =============
73
+
74
+ constructor(attributes?: Partial<TAttributes>) {
75
+ if (attributes) {
76
+ this.fill(attributes);
77
+ }
78
+
79
+ // Return a Proxy to intercept property access
80
+ return new Proxy(this, {
81
+ get: (target, prop: string) => {
82
+ // Internal properties first
83
+ if (prop.startsWith("_")) {
84
+ return (target as any)[prop];
85
+ }
86
+ // Check if relation is loaded (from eager loading or lazy access)
87
+ // This must come before checking if it's a function, so eager-loaded
88
+ // relations take precedence over the relationship methods
89
+ if ((target as any)._relations.has(prop)) {
90
+ return (target as any)._relations.get(prop);
91
+ }
92
+ // Then check if it's a method
93
+ if (typeof (target as any)[prop] === "function") {
94
+ return (target as any)[prop];
95
+ }
96
+ // Finally try to get as attribute
97
+ return target.getAttribute(prop as keyof TAttributes);
98
+ },
99
+ set: (target, prop: string, value) => {
100
+ // Internal properties and methods
101
+ if (prop.startsWith("_") || prop in Object.getPrototypeOf(target)) {
102
+ (target as any)[prop] = value;
103
+ return true;
104
+ }
105
+ // Set as attribute
106
+ target.setAttribute(prop as keyof TAttributes, value as any);
107
+ return true;
108
+ },
109
+ has: (target, prop: string) => {
110
+ // Check internal properties
111
+ if (prop.startsWith("_") || prop in Object.getPrototypeOf(target)) {
112
+ return true;
113
+ }
114
+ // Check relations
115
+ if ((target as any)._relations.has(prop)) {
116
+ return true;
117
+ }
118
+ // Check attributes
119
+ return (target as any)._attributes.hasOwnProperty(prop);
120
+ },
121
+ });
122
+ }
123
+
124
+ // ============= Static Query API =============
125
+
126
+ /**
127
+ * Create a query builder for this model
128
+ */
129
+ static query<M extends Model>(
130
+ this: { new (): M } & typeof Model,
131
+ ): ModelQueryBuilder<M> {
132
+ return new ModelQueryBuilder<M>(this);
133
+ }
134
+
135
+ /**
136
+ * Find a model by ID
137
+ */
138
+ static async find<M extends Model>(
139
+ this: { new (): M } & typeof Model,
140
+ id: unknown,
141
+ ): Promise<M | null> {
142
+ return this.query().where("id", id).first();
143
+ }
144
+
145
+ /**
146
+ * Find a model by ID or throw
147
+ */
148
+ static async findOrFail<M extends Model>(
149
+ this: { new (): M } & typeof Model,
150
+ id: unknown,
151
+ ): Promise<M> {
152
+ const result = await this.find(id);
153
+ if (!result) {
154
+ throw new ModelNotFoundError(`${this.name} with id ${id} not found`);
155
+ }
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Create a where clause
161
+ */
162
+ static where<M extends Model>(
163
+ this: { new (): M } & typeof Model,
164
+ column: string,
165
+ operator: unknown,
166
+ value?: unknown,
167
+ ): ModelQueryBuilder<M> {
168
+ return this.query().where(column, operator, value);
169
+ }
170
+
171
+ /**
172
+ * Get all records
173
+ */
174
+ static async all<M extends Model>(
175
+ this: { new (): M } & typeof Model,
176
+ ): Promise<M[]> {
177
+ return this.query().get();
178
+ }
179
+
180
+ /**
181
+ * Create and persist a new model
182
+ */
183
+ static async create<M extends Model>(
184
+ this: { new (): M } & typeof Model,
185
+ data: Record<string, unknown>,
186
+ ): Promise<M> {
187
+ // @ts-expect-error - Abstract class instantiation is valid here; this is the concrete subclass
188
+ const instance = new this();
189
+ instance.fill(data);
190
+ await instance.save();
191
+ return instance;
192
+ }
193
+
194
+ /**
195
+ * First or create — get or create based on conditions
196
+ */
197
+ static async firstOrCreate<M extends Model>(
198
+ this: { new (): M } & typeof Model,
199
+ conditions: Partial<TAttributes>,
200
+ values?: Partial<TAttributes>,
201
+ ): Promise<M> {
202
+ let instance = await this.query();
203
+ for (const [key, value] of Object.entries(conditions)) {
204
+ instance = instance.where(key, value) as any;
205
+ }
206
+ const found = await instance.first();
207
+ if (found) return found;
208
+
209
+ const create_data = { ...conditions, ...values };
210
+ return this.create(create_data as Record<string, unknown>);
211
+ }
212
+
213
+ /**
214
+ * Update or create — update or create based on conditions
215
+ */
216
+ static async updateOrCreate<M extends Model>(
217
+ this: { new (): M } & typeof Model,
218
+ conditions: Partial<TAttributes>,
219
+ values: Partial<TAttributes>,
220
+ ): Promise<M> {
221
+ let query = this.query();
222
+ for (const [key, value] of Object.entries(conditions)) {
223
+ query = query.where(key, value) as any;
224
+ }
225
+ const found = await query.first();
226
+
227
+ if (found) {
228
+ await found.fill(values).save();
229
+ return found;
230
+ }
231
+
232
+ return this.create({ ...conditions, ...values } as Record<string, unknown>);
233
+ }
234
+
235
+ // ============= Attribute Access =============
236
+
237
+ /**
238
+ * Get an attribute value
239
+ */
240
+ getAttribute<K extends keyof TAttributes>(key: K): TAttributes[K] {
241
+ return this._attributes[key];
242
+ }
243
+
244
+ /**
245
+ * Set an attribute value
246
+ */
247
+ setAttribute<K extends keyof TAttributes>(
248
+ key: K,
249
+ value: TAttributes[K],
250
+ ): void {
251
+ this._attributes[key] = value;
252
+ this._isDirty = true;
253
+ }
254
+
255
+ /**
256
+ * Fill attributes from data (respects fillable/guarded)
257
+ */
258
+ fill(data: Partial<TAttributes>): this {
259
+ const guarded = (this.constructor as typeof Model).guarded;
260
+ const fillable = (this.constructor as typeof Model).fillable;
261
+
262
+ for (const [key, value] of Object.entries(data)) {
263
+ if (guarded.includes("*")) break;
264
+ if (guarded.includes(key)) continue;
265
+ if (fillable.length > 0 && !fillable.includes(key)) continue;
266
+
267
+ this.setAttribute(
268
+ key as keyof TAttributes,
269
+ value as TAttributes[keyof TAttributes],
270
+ );
271
+ }
272
+
273
+ return this;
274
+ }
275
+
276
+ /**
277
+ * Force fill (bypass fillable/guarded)
278
+ */
279
+ forceFill(data: Partial<TAttributes>): this {
280
+ for (const [key, value] of Object.entries(data)) {
281
+ this.setAttribute(
282
+ key as keyof TAttributes,
283
+ value as TAttributes[keyof TAttributes],
284
+ );
285
+ }
286
+ return this;
287
+ }
288
+
289
+ /**
290
+ * Convert to JSON
291
+ */
292
+ toJSON(): TAttributes {
293
+ return { ...this._attributes };
294
+ }
295
+
296
+ /**
297
+ * Convert to plain object
298
+ */
299
+ toObject(): TAttributes {
300
+ return { ...this._attributes };
301
+ }
302
+
303
+ // ============= Dirty Tracking =============
304
+
305
+ /**
306
+ * Check if model or specific attribute is dirty
307
+ */
308
+ isDirty(key?: string): boolean {
309
+ if (key) {
310
+ return (
311
+ this._attributes[key as keyof TAttributes] !==
312
+ this._original[key as keyof TAttributes]
313
+ );
314
+ }
315
+ return this._isDirty;
316
+ }
317
+
318
+ /**
319
+ * Check if model or specific attribute is clean
320
+ */
321
+ isClean(key?: string): boolean {
322
+ return !this.isDirty(key);
323
+ }
324
+
325
+ /**
326
+ * Get dirty attributes
327
+ */
328
+ getDirty(): Partial<TAttributes> {
329
+ const dirty: Partial<TAttributes> = {};
330
+ for (const [key, value] of Object.entries(this._attributes)) {
331
+ if (value !== this._original[key as keyof TAttributes]) {
332
+ dirty[key as keyof TAttributes] =
333
+ value as TAttributes[keyof TAttributes];
334
+ }
335
+ }
336
+ return dirty;
337
+ }
338
+
339
+ /**
340
+ * Get original attribute values
341
+ */
342
+ getOriginal<K extends keyof TAttributes>(
343
+ key?: K,
344
+ ): TAttributes[K] | Partial<TAttributes> {
345
+ if (key) {
346
+ return this._original[key];
347
+ }
348
+ return { ...this._original };
349
+ }
350
+
351
+ // ============= Persistence =============
352
+
353
+ /**
354
+ * Save the model (create or update)
355
+ */
356
+ async save(): Promise<void> {
357
+ const modelClass = this.constructor as typeof Model;
358
+ const db = getModelDatabase(modelClass.name);
359
+
360
+ if (this._exists) {
361
+ // UPDATE
362
+ if (!this.isDirty()) return;
363
+
364
+ const dirty = this.getDirty();
365
+ if (modelClass.timestamps) {
366
+ const now = new Date().toISOString();
367
+ dirty[modelClass.updatedAtColumn as keyof TAttributes] =
368
+ now as TAttributes[keyof TAttributes];
369
+ // Also update the in-memory attribute so model.updated_at reflects the new value
370
+ this._attributes[modelClass.updatedAtColumn as keyof TAttributes] =
371
+ now as TAttributes[keyof TAttributes];
372
+ }
373
+
374
+ const builder = new OrmQueryBuilder(db, modelClass.table);
375
+ builder.where(
376
+ modelClass.primaryKey,
377
+ this.getAttribute(modelClass.primaryKey as keyof TAttributes),
378
+ );
379
+ await builder.update(dirty);
380
+
381
+ this._original = { ...this._attributes };
382
+ this._isDirty = false;
383
+ } else {
384
+ // INSERT
385
+ const data = { ...this._attributes };
386
+
387
+ if (modelClass.timestamps) {
388
+ const now = new Date().toISOString();
389
+ data[modelClass.createdAtColumn as keyof TAttributes] =
390
+ now as TAttributes[keyof TAttributes];
391
+ data[modelClass.updatedAtColumn as keyof TAttributes] =
392
+ now as TAttributes[keyof TAttributes];
393
+ }
394
+
395
+ const builder = new OrmQueryBuilder<TAttributes>(db, modelClass.table);
396
+ const result = await builder.insert(data);
397
+
398
+ this._attributes = result;
399
+ this._original = { ...result };
400
+ this._exists = true;
401
+ this._isDirty = false;
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Delete the model (soft or hard)
407
+ */
408
+ async delete(): Promise<void> {
409
+ const modelClass = this.constructor as typeof Model;
410
+ const db = getModelDatabase(modelClass.name);
411
+
412
+ if (modelClass.softDeletes) {
413
+ // Soft delete: set deleted_at
414
+ this.setAttribute(
415
+ modelClass.deletedAtColumn as keyof TAttributes,
416
+ new Date().toISOString() as TAttributes[keyof TAttributes],
417
+ );
418
+ await this.save();
419
+ } else {
420
+ // Hard delete
421
+ const builder = new OrmQueryBuilder(db, modelClass.table);
422
+ builder.where(
423
+ modelClass.primaryKey,
424
+ this.getAttribute(modelClass.primaryKey as keyof TAttributes),
425
+ );
426
+ await builder.delete();
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Restore a soft-deleted model
432
+ */
433
+ async restore(): Promise<void> {
434
+ const modelClass = this.constructor as typeof Model;
435
+ if (!modelClass.softDeletes) {
436
+ throw new Error(`Model ${modelClass.name} does not use soft deletes`);
437
+ }
438
+
439
+ this.setAttribute(
440
+ modelClass.deletedAtColumn as keyof TAttributes,
441
+ null as TAttributes[keyof TAttributes],
442
+ );
443
+ await this.save();
444
+ }
445
+
446
+ /**
447
+ * Refresh the model from the database
448
+ */
449
+ async refresh(): Promise<void> {
450
+ const modelClass = this.constructor as typeof Model;
451
+ const id = this.getAttribute(modelClass.primaryKey as keyof TAttributes);
452
+ const fresh = await (modelClass.query() as any).find(id);
453
+
454
+ if (fresh) {
455
+ this._attributes = fresh._attributes;
456
+ this._original = { ...fresh._attributes };
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Get a fresh instance of this model
462
+ */
463
+ async fresh(): Promise<this> {
464
+ const modelClass = this.constructor as typeof Model;
465
+ const id = this.getAttribute(modelClass.primaryKey as keyof TAttributes);
466
+ return (modelClass.query() as any).find(id);
467
+ }
468
+
469
+ // ============= Relationships =============
470
+
471
+ /**
472
+ * Define a one-to-one relationship
473
+ */
474
+ hasOne<TRelated extends Model>(
475
+ relatedClass: { new (): TRelated } & typeof Model,
476
+ foreignKey: string,
477
+ localKey?: string,
478
+ ): HasOne<TRelated> {
479
+ return new HasOne(this as any, relatedClass, foreignKey, localKey ?? "id");
480
+ }
481
+
482
+ /**
483
+ * Define a one-to-many relationship
484
+ */
485
+ hasMany<TRelated extends Model>(
486
+ relatedClass: { new (): TRelated } & typeof Model,
487
+ foreignKey: string,
488
+ localKey?: string,
489
+ ): HasMany<TRelated> {
490
+ return new HasMany(this as any, relatedClass, foreignKey, localKey ?? "id");
491
+ }
492
+
493
+ /**
494
+ * Define the inverse of a one-to-one or one-to-many relationship
495
+ */
496
+ belongsTo<TRelated extends Model>(
497
+ relatedClass: { new (): TRelated } & typeof Model,
498
+ foreignKey: string,
499
+ ownerKey?: string,
500
+ ): BelongsTo<TRelated> {
501
+ return new BelongsTo(
502
+ this as any,
503
+ relatedClass,
504
+ foreignKey,
505
+ ownerKey ?? "id",
506
+ );
507
+ }
508
+
509
+ /**
510
+ * Define a many-to-many relationship
511
+ */
512
+ belongsToMany<TRelated extends Model>(
513
+ relatedClass: { new (): TRelated } & typeof Model,
514
+ pivotTable: string,
515
+ foreignPivotKey: string,
516
+ relatedPivotKey: string,
517
+ parentKey?: string,
518
+ relatedKey?: string,
519
+ ): BelongsToMany<TRelated> {
520
+ return new BelongsToMany(
521
+ this as any,
522
+ relatedClass,
523
+ pivotTable,
524
+ foreignPivotKey,
525
+ relatedPivotKey,
526
+ parentKey ?? "id",
527
+ relatedKey ?? "id",
528
+ );
529
+ }
530
+
531
+ // ============= Hydration =============
532
+
533
+ /**
534
+ * Create model instances from database rows
535
+ */
536
+ static hydrate<M extends Model>(
537
+ this: { new (): M } & typeof Model,
538
+ rows: Record<string, unknown>[],
539
+ ): M[] {
540
+ return rows.map((row) => {
541
+ const instance = new this();
542
+ instance._attributes = {} as any;
543
+
544
+ for (const [key, value] of Object.entries(row)) {
545
+ const castDef = this.casts[key];
546
+ instance._attributes[key as keyof any] = castDef
547
+ ? CastRegistry.deserialize(castDef, value)
548
+ : value;
549
+ }
550
+
551
+ instance._original = { ...instance._attributes };
552
+ instance._exists = true;
553
+ return instance;
554
+ });
555
+ }
556
+
557
+ // ============= Hook Management =============
558
+
559
+ /**
560
+ * Register a hook callback
561
+ */
562
+ static on<M extends Model>(
563
+ this: { new (): M } & typeof Model,
564
+ hookName: ModelHookName,
565
+ callback: ModelHookCallback<M>,
566
+ ): void {
567
+ if (!Model.hookRegistry.has(this.name)) {
568
+ Model.hookRegistry.set(this.name, new Map());
569
+ }
570
+ const registry = Model.hookRegistry.get(this.name)!;
571
+ if (!registry.has(hookName)) {
572
+ registry.set(hookName, []);
573
+ }
574
+ registry.get(hookName)!.push(callback as any);
575
+ }
576
+
577
+ /**
578
+ * Get all callbacks for a hook
579
+ */
580
+ static getHookCallbacks<M extends Model>(
581
+ this: { new (): M } & typeof Model,
582
+ hookName: ModelHookName,
583
+ ): ModelHookCallback<M>[] {
584
+ const registry = Model.hookRegistry.get(this.name);
585
+ return (registry?.get(hookName) ?? []) as any;
586
+ }
587
+
588
+ /**
589
+ * Called when model class is first used (for static initialization)
590
+ */
591
+ static booting(): void {
592
+ // Override in subclass
593
+ }
594
+ }
595
+
596
+ /**
597
+ * ModelQueryBuilder — extended OrmQueryBuilder with Model-specific features
598
+ */
599
+ export class ModelQueryBuilder<M extends Model> extends OrmQueryBuilder<any> {
600
+ private modelClass: { new (): M } & typeof Model;
601
+ private eagerLoads: Map<string, ((q: OrmQueryBuilder<any>) => void) | null> =
602
+ new Map();
603
+
604
+ constructor(modelClass: { new (): M } & typeof Model) {
605
+ const db = getModelDatabase(modelClass.name);
606
+ super(db, modelClass.table as string);
607
+ this.modelClass = modelClass;
608
+ }
609
+
610
+ /**
611
+ * Eager load a relationship
612
+ */
613
+ with(relation: string, callback?: (q: OrmQueryBuilder<any>) => void): this {
614
+ this.eagerLoads.set(relation, callback ?? null);
615
+ return this;
616
+ }
617
+
618
+ /**
619
+ * Override get() to hydrate Model instances
620
+ */
621
+ override async get(): Promise<M[]> {
622
+ const rows = await super.get();
623
+ const models = this.modelClass.hydrate(rows);
624
+
625
+ // Load eager relationships
626
+ for (const [relation, callback] of this.eagerLoads) {
627
+ await this.loadRelation(models, relation, callback);
628
+ }
629
+
630
+ return models;
631
+ }
632
+
633
+ /**
634
+ * Override first() to hydrate Model instance
635
+ * Note: We must fetch raw data directly to avoid double-hydration
636
+ * (since get() is overridden to already hydrate)
637
+ */
638
+ override async first(): Promise<M | null> {
639
+ // Get raw row from parent OrmQueryBuilder.get() bypassing our override
640
+ const rows = await OrmQueryBuilder.prototype.get.call(this);
641
+ if (rows.length === 0) return null;
642
+
643
+ const row = rows[0];
644
+ const models = this.modelClass.hydrate([row]);
645
+
646
+ // Load eager relationships
647
+ for (const [relation, callback] of this.eagerLoads) {
648
+ await this.loadRelation(models, relation, callback ?? undefined);
649
+ }
650
+
651
+ return models[0] ?? null;
652
+ }
653
+
654
+ /**
655
+ * Override paginate() to return hydrated Model instances
656
+ * Note: super.paginate() calls get() which already hydrates,
657
+ * so we just need to return the result as-is
658
+ */
659
+ override async paginate(page: number, limit: number) {
660
+ const offset = (page - 1) * limit;
661
+ const [data, total] = await Promise.all([
662
+ this.clone().offset(offset).limit(limit).get(),
663
+ this.clone().count(),
664
+ ]);
665
+
666
+ return {
667
+ data,
668
+ total,
669
+ page,
670
+ limit,
671
+ totalPages: Math.ceil(total / limit),
672
+ };
673
+ }
674
+
675
+ /**
676
+ * Find first or create — find by conditions or create with values
677
+ */
678
+ async firstOrCreate(
679
+ conditions: Record<string, unknown>,
680
+ values?: Record<string, unknown>,
681
+ ): Promise<M> {
682
+ // Build a fresh query with conditions
683
+ const query = new ModelQueryBuilder(this.modelClass);
684
+ for (const [key, value] of Object.entries(conditions)) {
685
+ query.where(key, value);
686
+ }
687
+
688
+ // Try to find existing
689
+ const found = await query.first();
690
+ if (found) return found;
691
+
692
+ // Create if not found
693
+ const createData = { ...conditions, ...values };
694
+ return this.modelClass.create(createData as Record<string, unknown>);
695
+ }
696
+
697
+ /**
698
+ * Update or create — update if exists or create if not
699
+ */
700
+ async updateOrCreate(
701
+ conditions: Record<string, unknown>,
702
+ values: Record<string, unknown>,
703
+ ): Promise<M> {
704
+ // Build a fresh query with conditions
705
+ const query = new ModelQueryBuilder(this.modelClass);
706
+ for (const [key, value] of Object.entries(conditions)) {
707
+ query.where(key, value);
708
+ }
709
+
710
+ // Try to find existing
711
+ const found = await query.first();
712
+ if (found) {
713
+ // Update if found
714
+ found.fill(values);
715
+ await found.save();
716
+ return found;
717
+ }
718
+
719
+ // Create if not found
720
+ const createData = { ...conditions, ...values };
721
+ return this.modelClass.create(createData as Record<string, unknown>);
722
+ }
723
+
724
+ /**
725
+ * Load eager relationships
726
+ * Supports nested dot-notation like "posts.comments"
727
+ */
728
+ private async loadRelation(
729
+ models: M[],
730
+ relation: string,
731
+ callback?: (q: OrmQueryBuilder<any>) => void,
732
+ ): Promise<void> {
733
+ // Return early if no models
734
+ if (models.length === 0) return;
735
+
736
+ // Handle nested dot-notation relations (e.g., "posts.comments")
737
+ const dotIndex = relation.indexOf(".");
738
+ if (dotIndex !== -1) {
739
+ const head = relation.substring(0, dotIndex);
740
+ const tail = relation.substring(dotIndex + 1);
741
+
742
+ // Load the first relation recursively without a callback
743
+ await this.loadRelation(models, head, undefined);
744
+
745
+ // Collect intermediate models from _relations
746
+ const intermediates = models.flatMap(
747
+ (m) => (m as any)._relations.get(head) ?? [],
748
+ );
749
+
750
+ if (intermediates.length === 0) return;
751
+
752
+ // Load nested relation on intermediate models
753
+ const intermediateModelClass = intermediates[0]
754
+ .constructor as typeof Model;
755
+ const intermediateBuilder = new ModelQueryBuilder(intermediateModelClass);
756
+ await intermediateBuilder.loadRelation(intermediates, tail, callback);
757
+ return;
758
+ }
759
+
760
+ // Get a representative model to call the relationship method
761
+ const representative = models[0];
762
+ const relationMethod = (representative as any)[relation];
763
+
764
+ if (!relationMethod || typeof relationMethod !== "function") {
765
+ throw new Error(
766
+ `Relation "${relation}" not found on ${this.modelClass.name}`,
767
+ );
768
+ }
769
+
770
+ // Call the relationship method to get a relationship instance
771
+ const relationship = relationMethod.call(representative);
772
+
773
+ // Reset the query from single-model WHERE to clean state
774
+ (relationship as any).resetQuery();
775
+
776
+ // Apply eager constraints to load all parents at once
777
+ (relationship as any).addEagerConstraints(models);
778
+
779
+ // Apply optional callback to constrain the query
780
+ if (callback) {
781
+ callback(relationship.query);
782
+ }
783
+
784
+ // Execute the query and get raw rows
785
+ const rawRows = await relationship.query.get();
786
+
787
+ // Hydrate raw rows into model instances
788
+ const related = (relationship.relatedClass as typeof Model).hydrate(
789
+ rawRows,
790
+ );
791
+
792
+ // Match relationship results back to parent models
793
+ relationship.match(models, related, relation);
794
+ }
795
+
796
+ /**
797
+ * Support local scopes via Proxy
798
+ */
799
+ static createScoped<M extends Model>(
800
+ modelClass: { new (): M } & typeof Model,
801
+ ): ModelQueryBuilder<M> {
802
+ const builder = new ModelQueryBuilder(modelClass);
803
+
804
+ return new Proxy(builder, {
805
+ get: (target, prop: string) => {
806
+ // Check for scope method
807
+ if (typeof prop === "string" && prop.startsWith("scope")) {
808
+ const methodName = `scope${prop.charAt(5).toUpperCase()}${prop.slice(6)}`;
809
+ if (typeof (modelClass as any)[methodName] === "function") {
810
+ return (...args: any[]) => {
811
+ return (modelClass as any)[methodName](target, ...args);
812
+ };
813
+ }
814
+ }
815
+
816
+ // Regular property access
817
+ return (target as any)[prop];
818
+ },
819
+ }) as ModelQueryBuilder<M>;
820
+ }
821
+ }