@zerotal/orm 1.0.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/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -0
  4. package/package.json +58 -0
  5. package/src/casts/Cast.ts +200 -0
  6. package/src/commands/DbSeedCommand.ts +71 -0
  7. package/src/commands/MakeFactoryCommand.ts +59 -0
  8. package/src/commands/MakeMigrationCommand.ts +109 -0
  9. package/src/commands/MakeModelCommand.ts +83 -0
  10. package/src/commands/MakeSeederCommand.ts +50 -0
  11. package/src/commands/MigrateCommand.ts +60 -0
  12. package/src/commands/MigrateFreshCommand.ts +41 -0
  13. package/src/commands/MigrateGenerateCommand.ts +110 -0
  14. package/src/commands/MigrateRollbackCommand.ts +43 -0
  15. package/src/commands/MigrateStatusCommand.ts +49 -0
  16. package/src/commands/_loadMigrations.ts +34 -0
  17. package/src/commands/index.ts +30 -0
  18. package/src/config.ts +182 -0
  19. package/src/conventions.ts +67 -0
  20. package/src/db/DB.ts +486 -0
  21. package/src/db/NPlusOneDetector.ts +176 -0
  22. package/src/db/QueryBuilder.ts +2458 -0
  23. package/src/db/ReadWriteRouter.ts +96 -0
  24. package/src/db/TransactionContext.ts +13 -0
  25. package/src/db/dialects/MysqlDialect.ts +57 -0
  26. package/src/db/dialects/PostgresDialect.ts +55 -0
  27. package/src/db/dialects/SqliteDialect.ts +54 -0
  28. package/src/db/dialects/index.ts +25 -0
  29. package/src/db/dialects/types.ts +67 -0
  30. package/src/db/resolver.ts +30 -0
  31. package/src/db/sql-types.ts +12 -0
  32. package/src/db/types.ts +296 -0
  33. package/src/errors/MassAssignmentError.ts +25 -0
  34. package/src/errors/MigrationError.ts +18 -0
  35. package/src/errors/ModelNotFoundError.ts +21 -0
  36. package/src/errors/NPlusOneError.ts +6 -0
  37. package/src/errors/RelationNotLoadedError.ts +19 -0
  38. package/src/errors/StateError.ts +18 -0
  39. package/src/errors/TransactionError.ts +13 -0
  40. package/src/errors/UnsupportedDialectError.ts +18 -0
  41. package/src/errors/index.ts +7 -0
  42. package/src/events.ts +112 -0
  43. package/src/global.d.ts +17 -0
  44. package/src/implicitBinding.ts +73 -0
  45. package/src/index.ts +255 -0
  46. package/src/model/BaseModel.ts +2499 -0
  47. package/src/model/ModelQueryBuilder.ts +1808 -0
  48. package/src/model/Observer.ts +73 -0
  49. package/src/model/OrmContext.ts +71 -0
  50. package/src/model/ReactiveProxy.ts +53 -0
  51. package/src/model/SoftDeletes.ts +108 -0
  52. package/src/model/State.ts +290 -0
  53. package/src/model/decorators/_metadata.ts +211 -0
  54. package/src/model/decorators/_registerRelation.ts +20 -0
  55. package/src/model/decorators/belongsTo.ts +38 -0
  56. package/src/model/decorators/column.ts +278 -0
  57. package/src/model/decorators/hasMany.ts +34 -0
  58. package/src/model/decorators/hasManyThrough.ts +50 -0
  59. package/src/model/decorators/hasOne.ts +34 -0
  60. package/src/model/decorators/hasOneThrough.ts +40 -0
  61. package/src/model/decorators/manyToMany.ts +55 -0
  62. package/src/model/decorators/morphMany.ts +38 -0
  63. package/src/model/decorators/morphOne.ts +38 -0
  64. package/src/model/decorators/morphTo.ts +51 -0
  65. package/src/model/decorators/morphToMany.ts +49 -0
  66. package/src/model/decorators/morphedByMany.ts +46 -0
  67. package/src/model/decorators/table.ts +124 -0
  68. package/src/model/hooks/HookRegistry.ts +110 -0
  69. package/src/model/mixins.ts +536 -0
  70. package/src/model/payload.ts +114 -0
  71. package/src/model/relations/RelationRegistry.ts +184 -0
  72. package/src/observability.ts +210 -0
  73. package/src/provider/DatabaseProvider.ts +266 -0
  74. package/src/schema/Blueprint.ts +900 -0
  75. package/src/schema/ColumnDefinition.ts +517 -0
  76. package/src/schema/Migration.ts +34 -0
  77. package/src/schema/MigrationCodegen.ts +108 -0
  78. package/src/schema/MigrationRunner.ts +351 -0
  79. package/src/schema/ModelInspector.ts +133 -0
  80. package/src/schema/Schema.ts +140 -0
  81. package/src/schema/SchemaDiffer.ts +137 -0
  82. package/src/schema/SchemaInspector.ts +164 -0
  83. package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
  84. package/src/schema/autoMigrate.ts +154 -0
  85. package/src/schema/index.ts +28 -0
  86. package/src/seeding/Seeder.ts +46 -0
  87. package/src/support/identifiers.ts +62 -0
@@ -0,0 +1,2499 @@
1
+ import type { SQLInstance } from "../db/sql-types.ts";
2
+ import type { PaginateResult } from "../db/types.ts";
3
+ import { RequestContext, FrameworkEvents } from "@zerotal/core";
4
+ import { ModelChanged } from "../events.ts";
5
+ import { Carbon } from "@zerotal/core/carbon";
6
+ import { toCamelKey as toCamel, toSnakeColumn as toSnake } from "../support/identifiers.ts";
7
+ import { resolveContainerConnection } from "../db/resolver.ts";
8
+ import { currentOrmContext } from "./OrmContext.ts";
9
+ import {
10
+ ModelQueryBuilder,
11
+ _globalScopeRegistry,
12
+ aggregateAttribute,
13
+ countAttribute,
14
+ type GlobalScopeCallback,
15
+ } from "./ModelQueryBuilder.ts";
16
+ import {
17
+ QueryBuilder,
18
+ _setQueryBuilderDialect,
19
+ _runSegments,
20
+ _assertIdentifier,
21
+ dialectFor,
22
+ registerConnectionDialect,
23
+ type Dialect,
24
+ } from "../db/QueryBuilder.ts";
25
+ import { AsyncLocalStorage } from "node:async_hooks";
26
+ import { HookRegistry, type HookName } from "./hooks/HookRegistry.ts";
27
+ import { registerObserver, type ModelObserver } from "./Observer.ts";
28
+ import { makeReactive } from "./ReactiveProxy.ts";
29
+ import {
30
+ ModelNotFoundError,
31
+ RelationNotLoadedError,
32
+ MassAssignmentError,
33
+ } from "../errors/index.ts";
34
+ import { type ManyToMany } from "./relations/RelationRegistry.ts";
35
+ import { installReactiveAccessors, type ColumnOptions } from "./decorators/column.ts";
36
+ import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
37
+ import { TransactionContext } from "../db/TransactionContext.ts";
38
+ import type { InsertPayload, UpdatePayload } from "./payload.ts";
39
+ import type { WhereOperator, OrderDirection } from "../db/types.ts";
40
+
41
+ let _dialect: "sqlite" | "postgres" | "mysql" = "sqlite";
42
+
43
+ const _writeDialect = new AsyncLocalStorage<Dialect>();
44
+
45
+ // ── Model event dispatch (`dispatchesEvents` bridge) ──────────────────────────
46
+ //
47
+ // Maps internal hook names to the event keys used in `dispatchesEvents`.
48
+ const _HOOK_TO_EVENT: Partial<Record<HookName, string>> = {
49
+ beforeCreate: "creating",
50
+ afterCreate: "created",
51
+ beforeUpdate: "updating",
52
+ afterUpdate: "updated",
53
+ beforeSave: "saving",
54
+ afterSave: "saved",
55
+ beforeDelete: "deleting",
56
+ afterDelete: "deleted",
57
+ afterFind: "retrieved",
58
+ };
59
+
60
+ // App-level dispatcher, wired by DatabaseProvider to the container's event bus. No-op when
61
+ // unset (ORM used standalone / no emitter bound), keeping the ORM decoupled from core events.
62
+ let _eventDispatcher: ((event: object) => void) | undefined;
63
+
64
+ /** @internal Set the model-event dispatcher (called by DatabaseProvider). */
65
+ export function _setModelEventDispatcher(fn: ((event: object) => void) | undefined): void {
66
+ _eventDispatcher = fn;
67
+ }
68
+
69
+ // The persisting lifecycle hooks that correspond to a row change, for monitor telemetry.
70
+ const _HOOK_TO_OP: Partial<Record<HookName, ModelChanged["operation"]>> = {
71
+ afterCreate: "created",
72
+ afterUpdate: "updated",
73
+ afterDelete: "deleted",
74
+ };
75
+
76
+ // Dispatch the mapped event (if any) after a lifecycle hook runs. Wired via HookRegistry so
77
+ // it honours the hook-suppression context (factory seeding mutes events automatically).
78
+ HookRegistry.onAfterRun = (ModelClass, hook, model): void => {
79
+ // Per-model change telemetry (created/updated/deleted) for the monitor's models
80
+ // watcher. Fires for every model regardless of `dispatchesEvents`, but only when
81
+ // hooks aren't suppressed (so factory seeding doesn't flood it).
82
+ const op = _HOOK_TO_OP[hook];
83
+ if (op) {
84
+ const cls = ModelClass as typeof BaseModel;
85
+ FrameworkEvents.emit(new ModelChanged(cls.name, cls.table ?? "", op));
86
+ }
87
+
88
+ // `dispatchesEvents` bridge to the app event bus.
89
+ if (!_eventDispatcher) return;
90
+ const key = _HOOK_TO_EVENT[hook];
91
+ if (!key) return;
92
+ const map = (ModelClass as typeof BaseModel).dispatchesEvents;
93
+ const EventClass = map?.[key];
94
+ if (EventClass) _eventDispatcher(new EventClass(model));
95
+ };
96
+
97
+ /**
98
+ * Register a named connection that models can select via `static connection`.
99
+ * Stored on the current OrmContext (execution-scoped), not a global.
100
+ */
101
+ export function registerModelConnection(name: string, conn: SQLInstance, dialect?: Dialect): void {
102
+ currentOrmContext().namedConnections.set(name, conn);
103
+ registerConnectionDialect(conn as unknown as object, dialect ?? _dialect);
104
+ }
105
+
106
+ /** @internal — clear named connections (tests). Prefer resetOrmContext(). */
107
+ export function _clearModelConnections(): void {
108
+ currentOrmContext().namedConnections.clear();
109
+ }
110
+
111
+ /** @internal Set an execution-scoped override connection for all models (tests / withDatabase). */
112
+ export function _setBaseModelConnection(conn: SQLInstance | null): void {
113
+ currentOrmContext().overrideConnection = conn;
114
+ if (conn) registerConnectionDialect(conn as unknown as object, _dialect);
115
+ }
116
+
117
+ /** @internal Set the active SQL dialect used for identifier quoting and date serialization. */
118
+ export function _setBaseModelDialect(dialect: "sqlite" | "postgres" | "mysql"): void {
119
+ _dialect = dialect;
120
+ _setQueryBuilderDialect(dialect);
121
+ const ov = currentOrmContext().overrideConnection;
122
+ if (ov) {
123
+ registerConnectionDialect(ov as unknown as object, dialect);
124
+ }
125
+ }
126
+
127
+ /** @internal Return the active SQL dialect. */
128
+ export function _getDialect(): "sqlite" | "postgres" | "mysql" {
129
+ return _dialect;
130
+ }
131
+
132
+ /**
133
+ * Returns the active connection — override if set, otherwise via the injected resolver.
134
+ *
135
+ * @internal
136
+ */
137
+ export function _getModelConnection(): SQLInstance {
138
+ const override = currentOrmContext().overrideConnection;
139
+ if (override) return override;
140
+ const conn = resolveContainerConnection();
141
+ if (conn) return conn;
142
+ throw new Error("[Zerotal ORM] No database connection. Is DatabaseProvider registered?");
143
+ }
144
+
145
+ /**
146
+ * Context-aware connection resolver — an additive, AsyncLocalStorage-safe hook used
147
+ * by features that route queries to a connection chosen by the *current execution
148
+ * context* rather than a global override. The canonical user is `@zerotal/tenancy`'s
149
+ * multi-database strategy: it returns the active tenant's connection so every model
150
+ * query transparently hits the right database. Returns `null` to defer to the normal
151
+ * resolution. Defaults to a no-op, so it changes nothing until something registers it.
152
+ */
153
+ export type ContextConnectionResolver = (ModelClass?: typeof BaseModel) => SQLInstance | null;
154
+ let _contextConnectionResolver: ContextConnectionResolver | null = null;
155
+
156
+ /**
157
+ * Register a context-aware connection resolver (pass `null` to clear). Consulted by
158
+ * `_resolveConn` after explicit transactions and `static connection` named bindings,
159
+ * but before the default connection — so transactions and pinned connections still win.
160
+ */
161
+ export function registerConnectionResolver(fn: ContextConnectionResolver | null): void {
162
+ _contextConnectionResolver = fn;
163
+ }
164
+
165
+ /**
166
+ * Resolve the connection for a model operation.
167
+ * Priority: ALS transaction > RequestContext._transaction > `static connection` named
168
+ * binding > context resolver (e.g. tenancy multi-db) > configured/default connection.
169
+ * The configured connection itself may be an override set by withDatabase() in tests.
170
+ *
171
+ * @internal
172
+ */
173
+ export function _resolveConn(ModelClass?: typeof BaseModel): SQLInstance {
174
+ const tx =
175
+ TransactionContext.getStore() ??
176
+ (RequestContext.tryGet()?._transaction as SQLInstance | undefined);
177
+ if (tx) return tx;
178
+ const name = ModelClass?.connection;
179
+ if (name) {
180
+ const named = currentOrmContext().namedConnections.get(name);
181
+ if (named) return named;
182
+ }
183
+ if (_contextConnectionResolver) {
184
+ const resolved = _contextConnectionResolver(ModelClass);
185
+ if (resolved) return resolved;
186
+ }
187
+ return _getModelConnection();
188
+ }
189
+
190
+ // Column-name conversion lives in support/identifiers.ts — one memoized
191
+ // implementation shared with the query builders, whose lookups must produce
192
+ // exactly the property names hydration creates here.
193
+
194
+ /**
195
+ * Format a Date for storage, respecting the active dialect.
196
+ * MySQL DATETIME columns reject ISO 8601 ('T'/'Z') — they require 'YYYY-MM-DD HH:MM:SS'.
197
+ * SQLite and PostgreSQL both accept ISO 8601 as-is.
198
+ */
199
+ function _serializeDate(date: Date): string {
200
+ const dialect = _writeDialect.getStore() ?? _dialect;
201
+ if (dialect === "mysql") {
202
+ return date.toISOString().replace("T", " ").slice(0, 19);
203
+ }
204
+ return date.toISOString();
205
+ }
206
+
207
+ function serializeVal(v: unknown): unknown {
208
+ if (v instanceof Carbon) return _serializeDate(v.toDate());
209
+ if (v instanceof Date) return _serializeDate(v);
210
+ return v;
211
+ }
212
+
213
+ type StringCast =
214
+ | "datetime"
215
+ | "array"
216
+ | "json"
217
+ | "date"
218
+ | "boolean"
219
+ | "integer"
220
+ | "float"
221
+ | "enum"
222
+ | "immutable_datetime"
223
+ | `decimal:${number}`;
224
+ type CastOption = ColumnOptions["cast"];
225
+
226
+ function getCasts(ctor: Function): Record<string, CastOption> {
227
+ const merged: Record<string, CastOption> = {};
228
+ const chain: Function[] = [];
229
+ let current: Function | null = ctor;
230
+ while (current && current !== Function.prototype) {
231
+ chain.push(current);
232
+ current = Object.getPrototypeOf(current) as Function | null;
233
+ }
234
+ chain.reverse();
235
+ for (const entry of chain) {
236
+ const casts = (entry as { casts?: Record<string, CastOption> }).casts;
237
+ if (casts) Object.assign(merged, casts);
238
+ }
239
+ return merged;
240
+ }
241
+
242
+ function applyCastGet(value: unknown, cast: StringCast): unknown {
243
+ if (value === null || value === undefined) return value;
244
+ const cstr = cast as unknown as string;
245
+ if (cstr.startsWith("decimal:")) {
246
+ const n = parseInt(cstr.slice(8), 10) || 0;
247
+ return Number(value).toFixed(n);
248
+ }
249
+ if (cstr === "immutable_datetime") {
250
+ return value instanceof Carbon ? value : new Carbon(value as string | number | Date);
251
+ }
252
+ switch (cast) {
253
+ case "datetime":
254
+ if (value instanceof Carbon) return value;
255
+ return new Carbon(value as string | number | Date);
256
+ case "array":
257
+ case "json":
258
+ if (typeof value === "string") {
259
+ try {
260
+ return JSON.parse(value);
261
+ } catch {
262
+ return value;
263
+ }
264
+ }
265
+ return value;
266
+ case "date":
267
+ if (value instanceof Date) return value;
268
+ if (value instanceof Carbon) return value.toDate();
269
+ if (typeof value === "string" || typeof value === "number") return new Date(value);
270
+ return value;
271
+ case "boolean":
272
+ if (typeof value === "boolean") return value;
273
+ if (typeof value === "number") return value !== 0;
274
+ if (typeof value === "string") return value === "1" || value.toLowerCase() === "true";
275
+ return Boolean(value);
276
+ case "integer":
277
+ return parseInt(String(value), 10);
278
+ case "float":
279
+ return parseFloat(String(value));
280
+ case "enum":
281
+ return value;
282
+ default:
283
+ return value;
284
+ }
285
+ }
286
+
287
+ function tryParseJson(s: string): unknown {
288
+ try {
289
+ return JSON.parse(s);
290
+ } catch {
291
+ return s;
292
+ }
293
+ }
294
+
295
+ function applyCastSet(value: unknown, cast: StringCast): unknown {
296
+ if (value === null || value === undefined) return value;
297
+ const cstr = cast as unknown as string;
298
+ if (cstr.startsWith("decimal:")) {
299
+ const n = parseInt(cstr.slice(8), 10) || 0;
300
+ return Number(value).toFixed(n);
301
+ }
302
+ if (cstr === "immutable_datetime") {
303
+ if (value instanceof Carbon) return _serializeDate(value.toDate());
304
+ if (value instanceof Date) return _serializeDate(value);
305
+ return value;
306
+ }
307
+ switch (cast) {
308
+ case "datetime":
309
+ if (value instanceof Carbon) return _serializeDate(value.toDate());
310
+ if (value instanceof Date) return _serializeDate(value);
311
+ return value;
312
+ case "array":
313
+ case "json":
314
+ if (typeof value !== "string") return JSON.stringify(value);
315
+ return value;
316
+ case "date":
317
+ if (value instanceof Carbon) return _serializeDate(value.toDate());
318
+ if (value instanceof Date) return _serializeDate(value);
319
+ return value;
320
+ case "boolean":
321
+ return value ? 1 : 0;
322
+ case "integer":
323
+ return parseInt(String(value), 10);
324
+ case "float":
325
+ return parseFloat(String(value));
326
+ case "enum":
327
+ return value;
328
+ default:
329
+ return value;
330
+ }
331
+ }
332
+
333
+ function shouldProxyCast(
334
+ model: typeof BaseModel,
335
+ cast: CastOption | undefined,
336
+ colType: ColumnOptions["type"] | undefined,
337
+ ): boolean {
338
+ if (!model.reactiveCasts) return false;
339
+ return cast === "json" || cast === "array" || colType === "json";
340
+ }
341
+
342
+ /**
343
+ * Serialize one model property value for a database write.
344
+ *
345
+ * Applies, in priority order: a custom cast object's `set()`, a string
346
+ * shorthand cast ('boolean', 'json', …), then @column type coercions
347
+ * (boolean → 0/1, json → JSON string), and finally dialect-aware date
348
+ * formatting via serializeVal(). Must run inside a `_writeDialect.run()`
349
+ * scope so dates serialize for the right engine.
350
+ *
351
+ * Shared by bulkInsert(), upsert(), and both save() branches.
352
+ */
353
+ function _serializeForWrite(
354
+ key: string,
355
+ val: unknown,
356
+ casts: Record<string, CastOption>,
357
+ colReg: Map<string, ColumnOptions> | null,
358
+ ): unknown {
359
+ const colMeta = colReg?.get(key);
360
+ const castOpt = casts[key] ?? colMeta?.cast;
361
+ const colType = colMeta?.type;
362
+ let serializedVal: unknown;
363
+ if (castOpt && typeof castOpt === "object" && castOpt.set) {
364
+ serializedVal = castOpt.set(val);
365
+ } else if (typeof castOpt === "string") {
366
+ serializedVal = applyCastSet(val, castOpt);
367
+ } else if (colType === "boolean" && val !== null && val !== undefined) {
368
+ serializedVal = val ? 1 : 0;
369
+ } else if (colType === "json" && val !== null && typeof val !== "string") {
370
+ serializedVal = JSON.stringify(val);
371
+ } else {
372
+ serializedVal = val;
373
+ }
374
+ return serializeVal(serializedVal);
375
+ }
376
+
377
+ type Seg = string | { val: unknown };
378
+
379
+ // Both delegate to QueryBuilder._runSegments so model writes share the same
380
+ // interned-template cache AND emit the same QueryExecuted telemetry events
381
+ // as builder queries (previously model writes were invisible to the monitor).
382
+
383
+ async function runSegs(conn: SQLInstance, segs: Seg[]): Promise<void> {
384
+ await _runSegments(conn, segs);
385
+ }
386
+
387
+ function runQuery<T = Record<string, unknown>>(conn: SQLInstance, segs: Seg[]): Promise<T[]> {
388
+ return _runSegments<T>(conn, segs);
389
+ }
390
+
391
+ const SYSTEM_KEYS = new Set(["id", "createdAt", "updatedAt", "deletedAt"]);
392
+
393
+ type ModelCtor<T extends BaseModel> = typeof BaseModel & { new (): T };
394
+
395
+ /**
396
+ * Return camelCase relation names registered anywhere in a constructor's
397
+ * prototype chain. Walks the chain (like getCasts / _allColumnKeys) so a subclass
398
+ * inherits relations declared on ancestor classes — e.g. mixins applied in layers
399
+ * (`Roles(Permissions(Base))`), where each relation lives on a different
400
+ * class in the chain.
401
+ */
402
+ function relNames(ctor: Function): Set<string> {
403
+ return new Set(relationsFor(ctor).keys());
404
+ }
405
+
406
+ /**
407
+ * Enumerate own enumerable data properties, skipping:
408
+ * - _private fields
409
+ * - system columns (id, timestamps, softDeletes)
410
+ * - any relation property (getter-guard OR plain undefined from field initialiser)
411
+ */
412
+ function* ownDataEntries(
413
+ target: object,
414
+ skipKeys: Set<string>,
415
+ rels: Set<string>,
416
+ allowedKeys?: Set<string> | null,
417
+ ): Generator<[string, unknown]> {
418
+ for (const key of Object.keys(target)) {
419
+ if (key.startsWith("_")) continue;
420
+ if (skipKeys.has(key)) continue;
421
+ if (rels.has(key)) continue;
422
+ if (allowedKeys && !allowedKeys.has(key)) continue;
423
+ const desc = Object.getOwnPropertyDescriptor(target, key);
424
+ if (desc && typeof desc.get === "function") {
425
+ // Skip lazy-load relation guards, but INCLUDE reactive column accessors
426
+ // (json/array) — they carry a `_zerotal_<key>` backing data property.
427
+ if (!Object.prototype.hasOwnProperty.call(target, `_zerotal_${key}`)) continue;
428
+ }
429
+ yield [key, (target as Record<string, unknown>)[key]];
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Collect all @column-registered property names across the full prototype chain
435
+ * of a model class. Returns null when no column registry entries are found at all
436
+ * (rare edge case: a model with zero @column decorators) so callers can fall back
437
+ * to the old unrestricted behaviour.
438
+ */
439
+ function _allColumnKeys(cls: Function): Set<string> | null {
440
+ const cols = columnsFor(cls);
441
+ return cols ? new Set(cols.keys()) : null;
442
+ }
443
+
444
+ /**
445
+ * A reusable, applicable query constraint produced by {@link BaseModel.scope}.
446
+ * Its `apply` method mutates a {@link QueryBuilder} to add the scope's clauses.
447
+ */
448
+ export interface ScopeApplicator {
449
+ apply(query: QueryBuilder): void;
450
+ }
451
+
452
+ /**
453
+ * Union of all data-column property names on a model class.
454
+ *
455
+ * Excludes:
456
+ * - Methods / functions
457
+ * - Internal underscore-prefixed properties (`_exists`, `_original`, `_zerotal_*`)
458
+ *
459
+ * Use this to give `fillable`, `hidden`, and `hashable` compile-time safety
460
+ * — TypeScript will catch typos and non-existent column references.
461
+ *
462
+ * @example
463
+ * \@table("users").withTimestamps()
464
+ * export class User extends BaseModel {
465
+ * static fillable: Columns<User>[] = ["name", "email", "role"];
466
+ * static hidden: Columns<User>[] = ["password"];
467
+ * static hashable: Columns<User>[] = ["password"];
468
+ *
469
+ * @column() name!: string;
470
+ * @column() email!: string;
471
+ * @column() password!: string;
472
+ * @column() role?: string;
473
+ * }
474
+ */
475
+ export type Columns<T> = {
476
+ [K in keyof T & string]: K extends `_${string}`
477
+ ? never
478
+ : T[K] extends (...args: any[]) => any
479
+ ? never
480
+ : K;
481
+ }[keyof T & string];
482
+
483
+ /**
484
+ * Base class for every Zerotal Active Record model, backed by `Bun.sql`.
485
+ *
486
+ * Subclass it, declare columns with `@column`, and you get querying,
487
+ * persistence, dirty tracking, relationships, serialization, timestamps,
488
+ * lifecycle hooks, and (opt-in) soft deletes — Active Record-style, but
489
+ * fully typed against your model's own properties.
490
+ *
491
+ * @remarks
492
+ * An instance is a row: its enumerable data properties are the attributes.
493
+ * The model snapshots them on load, so {@link isDirty}, {@link $dirty}, and
494
+ * {@link save} write only changed columns (an UPDATE touches dirty columns
495
+ * only; a new instance INSERTs).
496
+ *
497
+ * **Mass assignment is guarded by default.** A model that declares neither
498
+ * {@link fillable} (allowlist) nor {@link guarded} (denylist) rejects every
499
+ * attribute passed to {@link fill} / {@link create} with a
500
+ * {@link MassAssignmentError}, so a stray key from a request body can never
501
+ * reach the database. Use {@link forceFill} / {@link forceCreate} for trusted,
502
+ * framework-internal writes only.
503
+ *
504
+ * **Timestamps** (`created_at` / `updated_at`) are maintained automatically
505
+ * when {@link timestamps} is `true` (the default). The **primary key** is
506
+ * `id` unless {@link primaryKey} is overridden. **Soft deletes** are opt-in
507
+ * via the `SoftDeletes` mixin (which sets {@link softDeletes}); once enabled,
508
+ * queries scope `WHERE deleted_at IS NULL` and {@link delete} sets
509
+ * `deleted_at` instead of removing the row.
510
+ *
511
+ * `@column({ cast })` / {@link casts} coerce values on read and write
512
+ * (booleans, JSON/array, dates via `Carbon`, decimals, enums). {@link hashable}
513
+ * fields are bcrypt-hashed transparently on save. {@link hidden} / {@link visible}
514
+ * / {@link appends} shape {@link toJSON} output.
515
+ *
516
+ * @example
517
+ * Defining a model with `@column`:
518
+ * ```ts
519
+ * @table("users").withTimestamps()
520
+ * export class User extends BaseModel {
521
+ * static fillable: Columns<User>[] = ["name", "email", "password"];
522
+ * static hidden: Columns<User>[] = ["password"];
523
+ * static hashable = ["password"];
524
+ *
525
+ * @column() name!: string;
526
+ * @column() email!: string;
527
+ * @column() password!: string;
528
+ * @column({ cast: "boolean" }) active?: boolean;
529
+ * }
530
+ * ```
531
+ *
532
+ * @example
533
+ * Querying, creating, and saving:
534
+ * ```ts
535
+ * // Create (mass-assignment respects `fillable`)
536
+ * const user = await User.create({ name: "Ada", email: "ada@example.com", password: "s3cret" });
537
+ *
538
+ * // Query
539
+ * const admins = await User.where("role", "admin").orderBy("name").get();
540
+ * const found = await User.findOrFail(user.id); // throws ModelNotFoundError if missing
541
+ *
542
+ * // Mutate + persist (only dirty columns are written)
543
+ * found.name = "Ada Lovelace";
544
+ * await found.save();
545
+ * ```
546
+ */
547
+ export class BaseModel {
548
+ /**
549
+ * Phantom nominal brand, used ONLY at the type level to detect relation
550
+ * properties (see `ColumnKeys` in payload.ts). Detecting relations by this one
551
+ * marker — rather than structurally via `extends BaseModel` — avoids forcing TS
552
+ * to re-resolve a related model's `fill(data: UpdatePayload<this>)` signature,
553
+ * which is itself defined in terms of `ColumnKeys`. That structural feedback loop
554
+ * is what makes mutually-referential models (A.b: B, B.a: A) trip TS2615
555
+ * ("circularly references itself in mapped type").
556
+ *
557
+ * `declare` => purely type-level, no runtime field is emitted and instances
558
+ * never carry it. The leading underscore keeps it out of column/serialization
559
+ * key sets automatically.
560
+ */
561
+ declare readonly __isZerotalModel: true;
562
+
563
+ /**
564
+ * Database table this model maps to. Usually set for you by the `@table("…")`
565
+ * decorator; assign directly to override.
566
+ *
567
+ * @category Attributes & mass assignment
568
+ */
569
+ static table: string;
570
+
571
+ /**
572
+ * Primary-key column name. Defaults to `id`.
573
+ *
574
+ * @category Attributes & mass assignment
575
+ */
576
+ static primaryKey = "id";
577
+
578
+ /**
579
+ * When `true` (default), `created_at` / `updated_at` are set automatically on
580
+ * insert and `updated_at` is bumped on every update. Toggle off, or wrap a
581
+ * write in {@link withoutTimestamps}, to suppress this.
582
+ *
583
+ * @category Timestamps
584
+ */
585
+ static timestamps = true;
586
+
587
+ /**
588
+ * Whether this model uses soft deletes. Flipped to `true` by the `SoftDeletes`
589
+ * mixin; when set, {@link delete} sets `deleted_at` and queries scope
590
+ * `WHERE deleted_at IS NULL`.
591
+ *
592
+ * @category Soft deletes
593
+ */
594
+ static softDeletes = false;
595
+
596
+ /**
597
+ * Per-attribute cast map applied on read/write — string shorthands
598
+ * (`"boolean"`, `"json"`, `"array"`, `"date"`, `"datetime"`, `"integer"`,
599
+ * `"float"`, `"decimal:2"`, `"enum"`, `"immutable_datetime"`) or a custom
600
+ * cast object with `get`/`set`. Merged across the prototype chain.
601
+ *
602
+ * @category Attributes & mass assignment
603
+ */
604
+ static casts?: Record<string, CastOption>;
605
+
606
+ /**
607
+ * Wrap `json`/`array` cast columns in a reactive proxy so mutating them in place
608
+ * (`user.meta.count = 99`) marks the column dirty. Defaults to `true`.
609
+ *
610
+ * Off, the failure is silent and looks like success: `_applyRow` stores the same object
611
+ * reference in the instance and in `_original`, and `$dirty()` compares with `!==`, so
612
+ * `user.meta.count = 99; await user.save()` issues no UPDATE and reports no error. The
613
+ * proxy is allocated only for columns actually cast to `json`/`array`.
614
+ *
615
+ * Set `false` for a model where that cost is measurable and every write to a JSON column
616
+ * replaces the whole value (`user.meta = { ...user.meta, count: 99 }`), which dirty
617
+ * tracking sees either way.
618
+ *
619
+ * @category Attributes & mass assignment
620
+ */
621
+ static reactiveCasts = true;
622
+
623
+ /**
624
+ * Whether this model participates in implicit route-model binding (default: true).
625
+ * When on, a route param matching the model name auto-resolves to a loaded instance
626
+ * (e.g. `:user` -> `User.findOrFail(value)`). Set to `false` to opt out.
627
+ *
628
+ * @category Route binding
629
+ */
630
+ static implicitBinding?: boolean;
631
+
632
+ /**
633
+ * Override which route param this model claims for implicit binding. By default a model
634
+ * named `User` binds the `:user` param; set this to claim a different key.
635
+ *
636
+ * @example
637
+ * static implicitBindingKey = "author"; // any :author param resolves via this model
638
+ *
639
+ * @category Route binding
640
+ */
641
+ static implicitBindingKey?: string;
642
+
643
+ /**
644
+ * Maps lifecycle events to event classes that are dispatched on the app
645
+ * event bus when they fire (via `$dispatchesEvents`). Keys: `creating`, `created`,
646
+ * `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`, `retrieved`. Each event
647
+ * class is constructed with the model instance and emitted (no-op if no bus is bound).
648
+ *
649
+ * @example
650
+ * static dispatchesEvents = { created: OrderPlaced, deleted: OrderCancelled };
651
+ *
652
+ * @category Lifecycle & hooks
653
+ */
654
+ static dispatchesEvents?: Record<string, new (model: unknown) => object>;
655
+
656
+ /**
657
+ * Allowlist of camelCase field names accepted by create() / fill().
658
+ * When set, any key not in this list is rejected with `MassAssignmentError`.
659
+ * Cannot be used together with `guarded`.
660
+ *
661
+ * @category Attributes & mass assignment
662
+ */
663
+ static fillable?: string[];
664
+
665
+ /**
666
+ * Denylist of camelCase field names blocked from create() / fill().
667
+ * When set, listed keys are rejected; all other keys are accepted.
668
+ * Cannot be used together with `fillable`.
669
+ *
670
+ * @category Attributes & mass assignment
671
+ */
672
+ static guarded?: string[];
673
+
674
+ /**
675
+ * Disable mass-assignment protection for this model — every attribute passed
676
+ * to `fill()` / `create()` is accepted.
677
+ *
678
+ * Models **guard by default**: when neither `fillable` nor `guarded` is
679
+ * declared, `fill()` rejects every attribute (throwing `MassAssignmentError`)
680
+ * so an unexpected key from a request body can never reach the database. Set
681
+ * this to `true` only for models whose writes never come from user input.
682
+ *
683
+ * @category Attributes & mass assignment
684
+ */
685
+ static unguarded = false;
686
+
687
+ /**
688
+ * Process-wide mass-assignment override. When true, models that declare
689
+ * **neither** `fillable` **nor** `guarded` accept every attribute (an explicit
690
+ * `fillable`/`guarded` list is always honoured regardless).
691
+ *
692
+ * Toggle it around trusted bulk work — seeders, migrations, test setup — with
693
+ * {@link unguard} / {@link reguard}; the default (`false`) keeps request input
694
+ * guarded. This is a global flag, so re-guard promptly in a `finally`.
695
+ *
696
+ * @internal
697
+ */
698
+ private static _unguardedGlobally = false;
699
+
700
+ /**
701
+ * Turn off mass-assignment guarding process-wide (trusted contexts only).
702
+ *
703
+ * @category Attributes & mass assignment
704
+ */
705
+ static unguard(): void {
706
+ BaseModel._unguardedGlobally = true;
707
+ }
708
+
709
+ /**
710
+ * Restore mass-assignment guarding process-wide.
711
+ *
712
+ * @category Attributes & mass assignment
713
+ */
714
+ static reguard(): void {
715
+ BaseModel._unguardedGlobally = false;
716
+ }
717
+
718
+ /**
719
+ * Run `callback` with mass-assignment guarding disabled process-wide,
720
+ * restoring the previous setting afterwards (even on throw).
721
+ *
722
+ * @category Attributes & mass assignment
723
+ */
724
+ static async withoutGuard<T>(callback: () => T | Promise<T>): Promise<T> {
725
+ BaseModel.unguard();
726
+ try {
727
+ return await callback();
728
+ } finally {
729
+ BaseModel.reguard();
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Fields to exclude from `toJSON()` and therefore from any
735
+ * `JSON.stringify()` output — API responses, cache serialisation, etc.
736
+ *
737
+ * List camelCase property names. Nested models serialise independently
738
+ * via their own `toJSON()`, so a parent's `hidden` list does not
739
+ * propagate to relations.
740
+ *
741
+ * @example
742
+ * static hidden = ['password', 'rememberToken'];
743
+ *
744
+ * @category Serialization
745
+ */
746
+ static hidden: string[] = [];
747
+
748
+ /**
749
+ * Allow-list for serialization. When set (non-empty), `toJSON()` includes
750
+ * ONLY these keys (plus `appends`). Takes precedence over `hidden`.
751
+ *
752
+ * @category Serialization
753
+ */
754
+ static visible: string[] = [];
755
+
756
+ /**
757
+ * Computed accessor names to include in `toJSON()` output. Each name should
758
+ * resolve to a getter (or plain property) on the instance.
759
+ *
760
+ * @example
761
+ * class User extends BaseModel {
762
+ * static appends = ['fullName'];
763
+ * get fullName() { return `${this.first} ${this.last}`; }
764
+ * }
765
+ *
766
+ * @category Serialization
767
+ */
768
+ static appends: string[] = [];
769
+
770
+ /**
771
+ * Optional named connection (registered via {@link registerConnection}). When
772
+ * set, queries for this model resolve to that connection instead of the default.
773
+ *
774
+ * @category Querying
775
+ */
776
+ static connection?: string;
777
+
778
+ /**
779
+ * Fields that are automatically hashed with `Bun.password.hash()` (bcrypt)
780
+ * before every INSERT and whenever the field changes on UPDATE.
781
+ *
782
+ * The hash is applied transparently in `save()` — the plaintext value is
783
+ * never written to the database. Use `Bun.password.verify()` to check
784
+ * a plaintext candidate against the stored hash.
785
+ *
786
+ * @example
787
+ * static hashable = ['password'];
788
+ *
789
+ * // Verify later:
790
+ * const ok = await Bun.password.verify(candidate, user.password);
791
+ *
792
+ * @category Persistence
793
+ */
794
+ static hashable?: string[];
795
+
796
+ /**
797
+ * Register an observer class for this model.
798
+ * The observer's lifecycle methods (creating, created, updating, …) are
799
+ * wired into the HookRegistry automatically.
800
+ *
801
+ * @example
802
+ * User.observe(UserObserver); // call once at boot in a ServiceProvider
803
+ *
804
+ * @category Lifecycle & hooks
805
+ */
806
+ static observe<T extends BaseModel>(
807
+ this: ModelCtor<T>,
808
+ ObserverClass: new () => ModelObserver<T>,
809
+ ): void {
810
+ registerObserver<T>(this, ObserverClass);
811
+ }
812
+
813
+ /**
814
+ * Primary-key value. Populated after {@link save} inserts a new row, or when
815
+ * the instance is hydrated from the database.
816
+ *
817
+ * @category Attributes & mass assignment
818
+ */
819
+ id!: number;
820
+
821
+ /**
822
+ * Creation timestamp, set on insert when {@link timestamps} is enabled.
823
+ *
824
+ * @category Timestamps
825
+ */
826
+ createdAt?: Date;
827
+
828
+ /**
829
+ * Last-update timestamp, bumped on every save when {@link timestamps} is enabled.
830
+ *
831
+ * @category Timestamps
832
+ */
833
+ updatedAt?: Date;
834
+
835
+ /** @internal Snapshot of attribute values at load/last-save, for dirty tracking. */
836
+ private _original: Record<string, unknown> = {};
837
+ /** @internal Keys force-marked dirty via {@link markDirty}. */
838
+ private _forcedDirty = new Set<string>();
839
+ /**
840
+ * True when this instance was loaded from (or last written to) the database.
841
+ *
842
+ * @internal
843
+ */
844
+ private _exists = false;
845
+
846
+ // ── Global query scopes ──────────────────────────────────────────────────
847
+
848
+ /**
849
+ * Register a named global scope applied to every query for this model.
850
+ * The callback receives the query builder and constrains it (e.g. a tenant
851
+ * filter). Remove it later with {@link removeGlobalScope}.
852
+ *
853
+ * @category Querying
854
+ */
855
+ static addGlobalScope<T extends BaseModel>(
856
+ this: ModelCtor<T>,
857
+ name: string,
858
+ callback: GlobalScopeCallback,
859
+ ): void {
860
+ const reg = _globalScopeRegistry();
861
+ let scopes = reg.get(this);
862
+ if (!scopes) {
863
+ scopes = new Map();
864
+ reg.set(this, scopes);
865
+ }
866
+ scopes.set(name, callback);
867
+ }
868
+
869
+ /**
870
+ * Remove a previously registered global scope by name.
871
+ *
872
+ * @category Querying
873
+ */
874
+ static removeGlobalScope<T extends BaseModel>(this: ModelCtor<T>, name: string): void {
875
+ _globalScopeRegistry().get(this)?.delete(name);
876
+ }
877
+
878
+ /**
879
+ * Register a named connection that models can select via `static connection`.
880
+ * Stored on the current OrmContext (execution-scoped).
881
+ *
882
+ * @category Querying
883
+ */
884
+ static registerConnection(name: string, conn: SQLInstance, dialect?: Dialect): void {
885
+ registerModelConnection(name, conn, dialect);
886
+ }
887
+
888
+ /**
889
+ * Run a callback with automatic timestamp updates disabled for this model.
890
+ * Restores the previous setting afterwards (even on throw).
891
+ *
892
+ * @example
893
+ * await User.withoutTimestamps(() => user.save());
894
+ *
895
+ * @category Timestamps
896
+ */
897
+ static async withoutTimestamps<R>(callback: () => Promise<R> | R): Promise<R> {
898
+ const prev = this.timestamps;
899
+ this.timestamps = false;
900
+ try {
901
+ return await callback();
902
+ } finally {
903
+ this.timestamps = prev;
904
+ }
905
+ }
906
+
907
+ /**
908
+ * When true, `prune()` permanently deletes rows (forceDelete) rather than
909
+ * soft-deleting them.
910
+ *
911
+ * @category Persistence
912
+ */
913
+ static massPrune = false;
914
+
915
+ /**
916
+ * Override to return the query selecting records eligible for pruning.
917
+ * Implement this to make a model "prunable" (used by `prune()` and a
918
+ * scheduled `model:prune` task).
919
+ *
920
+ * @example
921
+ * static prunable() { return this.query().where('created_at', '<', cutoff); }
922
+ *
923
+ * @category Persistence
924
+ */
925
+ static prunable?<T extends BaseModel>(this: ModelCtor<T>): ModelQueryBuilder<T>;
926
+
927
+ /**
928
+ * Delete prunable records in chunks. Returns the number of records pruned.
929
+ * Honours `massPrune` (permanent delete) vs. soft delete.
930
+ *
931
+ * @throws {Error} when the model does not define a static `prunable()` method.
932
+ * @category Persistence
933
+ */
934
+ static async prune<T extends BaseModel>(this: ModelCtor<T>, chunkSize = 1000): Promise<number> {
935
+ if (typeof this.prunable !== "function") {
936
+ throw new Error(`[Zerotal ORM] ${this.name}.prune() requires a static prunable() method.`);
937
+ }
938
+ let total = 0;
939
+ for (;;) {
940
+ const query = (this.prunable as () => ModelQueryBuilder<T>).call(this);
941
+ const rows = await query.limit(chunkSize).get<T>();
942
+ if (rows.length === 0) break;
943
+ for (const row of rows) {
944
+ // massPrune means permanent removal. For soft-delete models that's forceDelete()
945
+ // (from the SoftDeletes mixin); for hard-delete models delete() is already permanent.
946
+ if (this.massPrune && this.softDeletes) {
947
+ await (row as unknown as { forceDelete(): Promise<void> }).forceDelete();
948
+ } else {
949
+ await row.delete();
950
+ }
951
+ total++;
952
+ }
953
+ if (rows.length < chunkSize) break;
954
+ }
955
+ return total;
956
+ }
957
+
958
+ // ── Static query entry points ────────────────────────────────────────────
959
+
960
+ /**
961
+ * New model query builder with the soft-delete scope
962
+ * (`deleted_at IS NULL`) applied when the model uses soft deletes.
963
+ * Single source of the scope for every static entry point.
964
+ *
965
+ * @internal
966
+ */
967
+ private static _newScopedQuery<T extends BaseModel>(this: ModelCtor<T>): ModelQueryBuilder<T> {
968
+ const qb = new ModelQueryBuilder<T>(this.table, _resolveConn(this), this);
969
+ if (this.softDeletes) qb.whereNull("deleted_at");
970
+ // Everything the caller adds from here on is "theirs", and must be grouped as a unit so an
971
+ // orWhere() cannot split the soft-delete predicate (or a global scope) off its chain.
972
+ qb._markUserWhereStart();
973
+ return qb;
974
+ }
975
+
976
+ /**
977
+ * Start a new query builder for this model — the entry point for building
978
+ * `where`/`orderBy`/`with`/etc. chains. Applies the soft-delete scope
979
+ * (`deleted_at IS NULL`) when the model uses soft deletes.
980
+ *
981
+ * @example
982
+ * const posts = await Post.query().where("published", true).orderBy("createdAt", "desc").get();
983
+ *
984
+ * @category Querying
985
+ */
986
+ static query<T extends BaseModel>(this: ModelCtor<T>): ModelQueryBuilder<T> {
987
+ return (this as ModelCtor<T>)._newScopedQuery();
988
+ }
989
+
990
+ /**
991
+ * @internal A raw, unscoped model query — no soft-delete (`deleted_at IS NULL`)
992
+ * filter applied. Used by the relation loader to build base related queries
993
+ * (which add their own scoping). The public, soft-delete-aware `withTrashed()` /
994
+ * `onlyTrashed()` live on the `SoftDeletes` mixin.
995
+ */
996
+ static _unscopedQuery<T extends BaseModel>(this: ModelCtor<T>): ModelQueryBuilder<T> {
997
+ return new ModelQueryBuilder<T>(this.table, _resolveConn(this), this);
998
+ }
999
+
1000
+ // ── Static query shortcuts ─────────────────────────────────────────────────
1001
+ // Fluent static entry points so callers can write Post.where(...) or
1002
+ // User.count() without spelling out .query() first. Each delegates to query(),
1003
+ // so the soft-delete scope and connection resolution are applied consistently.
1004
+
1005
+ /**
1006
+ * Start a query constrained by a `WHERE` clause. Pass `(column, value)` for
1007
+ * equality or `(column, operator, value)` for any other comparison.
1008
+ *
1009
+ * @example
1010
+ * const recent = await Post.where("views", ">=", 100).get();
1011
+ *
1012
+ * @category Querying
1013
+ */
1014
+ static where<T extends BaseModel>(
1015
+ this: ModelCtor<T>,
1016
+ column: string,
1017
+ value: unknown,
1018
+ ): ModelQueryBuilder<T>;
1019
+ static where<T extends BaseModel>(
1020
+ this: ModelCtor<T>,
1021
+ column: string,
1022
+ operator: WhereOperator,
1023
+ value: unknown,
1024
+ ): ModelQueryBuilder<T>;
1025
+ static where<T extends BaseModel>(
1026
+ this: ModelCtor<T>,
1027
+ column: string,
1028
+ operatorOrValue: unknown,
1029
+ value?: unknown,
1030
+ ): ModelQueryBuilder<T> {
1031
+ const qb = (this as ModelCtor<T>).query();
1032
+ return value === undefined
1033
+ ? qb.where(column, operatorOrValue)
1034
+ : qb.where(column, operatorOrValue as WhereOperator, value);
1035
+ }
1036
+
1037
+ /**
1038
+ * Start a query constrained by `WHERE column IN (values)`.
1039
+ *
1040
+ * @category Querying
1041
+ */
1042
+ static whereIn<T extends BaseModel>(
1043
+ this: ModelCtor<T>,
1044
+ column: string,
1045
+ values: unknown[],
1046
+ ): ModelQueryBuilder<T> {
1047
+ return (this as ModelCtor<T>).query().whereIn(column, values);
1048
+ }
1049
+
1050
+ /**
1051
+ * Start a query ordered by `column` (default direction `"asc"`).
1052
+ *
1053
+ * @category Querying
1054
+ */
1055
+ static orderBy<T extends BaseModel>(
1056
+ this: ModelCtor<T>,
1057
+ column: string,
1058
+ direction: OrderDirection = "asc",
1059
+ ): ModelQueryBuilder<T> {
1060
+ return (this as ModelCtor<T>).query().orderBy(column, direction);
1061
+ }
1062
+
1063
+ /**
1064
+ * Start a query ordered newest-first by `column` (default `"created_at"`).
1065
+ *
1066
+ * @category Querying
1067
+ */
1068
+ static latest<T extends BaseModel>(
1069
+ this: ModelCtor<T>,
1070
+ column = "created_at",
1071
+ ): ModelQueryBuilder<T> {
1072
+ return (this as ModelCtor<T>).query().latest(column);
1073
+ }
1074
+
1075
+ /**
1076
+ * Start a query ordered oldest-first by `column` (default `"created_at"`).
1077
+ *
1078
+ * @category Querying
1079
+ */
1080
+ static oldest<T extends BaseModel>(
1081
+ this: ModelCtor<T>,
1082
+ column = "created_at",
1083
+ ): ModelQueryBuilder<T> {
1084
+ return (this as ModelCtor<T>).query().oldest(column);
1085
+ }
1086
+
1087
+ /**
1088
+ * Fetch the first row, or `null` when none match.
1089
+ *
1090
+ * @category Querying
1091
+ */
1092
+ static first<T extends BaseModel>(this: ModelCtor<T>): Promise<T | null> {
1093
+ return (this as ModelCtor<T>).query().first<T>();
1094
+ }
1095
+
1096
+ /**
1097
+ * Fetch the first row, or throw when none match.
1098
+ *
1099
+ * @throws {ModelNotFoundError} when no row matches.
1100
+ * @category Querying
1101
+ */
1102
+ static firstOrFail<T extends BaseModel>(this: ModelCtor<T>): Promise<T> {
1103
+ return (this as ModelCtor<T>).query().firstOrFail() as Promise<T>;
1104
+ }
1105
+
1106
+ /**
1107
+ * Count all rows (subject to any global/soft-delete scopes).
1108
+ *
1109
+ * @category Querying
1110
+ */
1111
+ static count<T extends BaseModel>(this: ModelCtor<T>): Promise<number> {
1112
+ return (this as ModelCtor<T>).query().count();
1113
+ }
1114
+
1115
+ /**
1116
+ * Find a single row by primary key, or `null` when not found.
1117
+ *
1118
+ * @category Querying
1119
+ */
1120
+ static async find<T extends BaseModel>(
1121
+ this: ModelCtor<T>,
1122
+ id: number | string,
1123
+ ): Promise<T | null> {
1124
+ return (this as ModelCtor<T>)._newScopedQuery().where(this.primaryKey, id).first<T>();
1125
+ }
1126
+
1127
+ /**
1128
+ * Find a single row by primary key, or throw when not found.
1129
+ *
1130
+ * @throws {ModelNotFoundError} when no row has the given primary key.
1131
+ *
1132
+ * @example
1133
+ * const user = await User.findOrFail(ctx.integer("id"));
1134
+ *
1135
+ * @category Querying
1136
+ */
1137
+ static async findOrFail<T extends BaseModel>(
1138
+ this: ModelCtor<T>,
1139
+ id: number | string,
1140
+ ): Promise<T> {
1141
+ const inst = await (this as ModelCtor<T>).find(id);
1142
+ if (inst === null) throw new ModelNotFoundError(this.name, id);
1143
+ return inst;
1144
+ }
1145
+
1146
+ /**
1147
+ * Find the first row where `column` equals `value` (the column name is
1148
+ * converted to snake_case), or `null` when none match.
1149
+ *
1150
+ * @category Querying
1151
+ */
1152
+ static async findBy<T extends BaseModel>(
1153
+ this: ModelCtor<T>,
1154
+ column: string,
1155
+ value: unknown,
1156
+ ): Promise<T | null> {
1157
+ return (this as ModelCtor<T>)._newScopedQuery().where(toSnake(column), value).first<T>();
1158
+ }
1159
+
1160
+ /**
1161
+ * Fetch every row for this model (subject to global/soft-delete scopes).
1162
+ *
1163
+ * @category Querying
1164
+ */
1165
+ static async all<T extends BaseModel>(this: ModelCtor<T>): Promise<T[]> {
1166
+ return (this as ModelCtor<T>)._newScopedQuery().get<T>();
1167
+ }
1168
+
1169
+ /**
1170
+ * Fetch one page of rows (subject to global/soft-delete scopes).
1171
+ *
1172
+ * The page comes from the request in flight — the `?page=` query string, or whatever a
1173
+ * server-driven view registered instead — so a controller or a Flow page reads
1174
+ * `Post.paginate(10)` and gets the page the user is actually on. Pass `page` to override.
1175
+ *
1176
+ * @param perPage - Rows per page. Defaults to 15.
1177
+ * @param page - 1-based page. Omit to use the request's current page.
1178
+ * @param pageName - Which paginator to read, so one page can drive several. Defaults to `"page"`.
1179
+ *
1180
+ * @example
1181
+ * ```ts
1182
+ * const posts = await Post.paginate(10); // ?page= (or the view's page)
1183
+ * const invoices = await Invoice.paginate(10, undefined, "invoices"); // a second paginator
1184
+ * ```
1185
+ *
1186
+ * @category Querying
1187
+ */
1188
+ static async paginate<T extends BaseModel>(
1189
+ this: ModelCtor<T>,
1190
+ perPage = 15,
1191
+ page?: number,
1192
+ pageName = "page",
1193
+ ): Promise<PaginateResult<T>> {
1194
+ return (this as ModelCtor<T>)._newScopedQuery().paginate<T>(perPage, page, pageName);
1195
+ }
1196
+
1197
+ /**
1198
+ * Mass-assign `data` (respecting {@link fillable} / {@link guarded}) onto a new
1199
+ * instance and {@link save} it, returning the persisted model.
1200
+ *
1201
+ * @throws {MassAssignmentError} when `data` contains a non-fillable key.
1202
+ *
1203
+ * @example
1204
+ * const user = await User.create({ name: "Ada", email: "ada@example.com" });
1205
+ *
1206
+ * @category Persistence
1207
+ */
1208
+ static async create<T extends BaseModel>(this: ModelCtor<T>, data: InsertPayload<T>): Promise<T> {
1209
+ const inst = new this();
1210
+ inst.fill(data as UpdatePayload<T>);
1211
+ return inst.save() as Promise<T>;
1212
+ }
1213
+
1214
+ /**
1215
+ * Mass-assign fields, respecting `fillable` / `guarded` protection.
1216
+ * Call this instead of `Object.assign` when data comes from user input.
1217
+ *
1218
+ * **Guarded by default:** a model that declares neither `fillable` nor
1219
+ * `guarded` (and is not `unguarded`) rejects every attribute with a
1220
+ * `MassAssignmentError`, so a stray key from a request body can never reach
1221
+ * the database. Declare `fillable` to allow specific columns, or use
1222
+ * {@link forceFill} for trusted, framework-internal writes.
1223
+ *
1224
+ * Accepts `UpdatePayload<this>` — a partial of the model's writable,
1225
+ * non-relation, non-auto-managed columns. Passing `id`, `createdAt`,
1226
+ * relations, or methods is a compile-time error.
1227
+ *
1228
+ * @example
1229
+ * post.fill(ctx.body<UpdatePayload<Post>>());
1230
+ *
1231
+ * @throws {MassAssignmentError} when `data` contains a key not permitted by
1232
+ * this model's `fillable` / `guarded` configuration.
1233
+ * @category Attributes & mass assignment
1234
+ */
1235
+ fill(data: UpdatePayload<this>): this {
1236
+ const ModelClass = this.constructor as typeof BaseModel;
1237
+ // Reactive (json/array) columns are registered at decoration time; install their
1238
+ // per-instance accessors now so assignments below go through the reactive setter.
1239
+ installReactiveAccessors(this);
1240
+
1241
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
1242
+ if (!ModelClass._isFillable(key)) {
1243
+ throw new MassAssignmentError(ModelClass.name, key);
1244
+ }
1245
+ (this as unknown as Record<string, unknown>)[key] = value;
1246
+ }
1247
+ return this;
1248
+ }
1249
+
1250
+ /**
1251
+ * True when `key` may be mass-assigned given this model's `fillable` /
1252
+ * `guarded` / `unguarded` configuration. Precedence: `unguarded` (all) →
1253
+ * `fillable` (allowlist) → `guarded` (denylist) → guarded-by-default (none).
1254
+ *
1255
+ * @internal
1256
+ */
1257
+ static _isFillable(key: string): boolean {
1258
+ // An explicit allow/deny list is always honoured, even under global unguard.
1259
+ if (this.fillable !== undefined) return this.fillable.includes(key);
1260
+ if (this.guarded !== undefined) return !this.guarded.includes(key);
1261
+ if (this.unguarded || BaseModel._unguardedGlobally) return true;
1262
+ // Neither list declared and not unguarded → guard everything.
1263
+ return false;
1264
+ }
1265
+
1266
+ /**
1267
+ * Mass-assign fields **bypassing** `fillable` / `guarded` protection.
1268
+ * Use only for trusted data you construct yourself (framework-internal
1269
+ * writes, factories, seeders) — never for request input.
1270
+ *
1271
+ * @example
1272
+ * // Trusted, non-user data:
1273
+ * role.forceFill({ name, guard });
1274
+ *
1275
+ * @category Attributes & mass assignment
1276
+ */
1277
+ forceFill(data: Record<string, unknown>): this {
1278
+ installReactiveAccessors(this);
1279
+ for (const [key, value] of Object.entries(data)) {
1280
+ (this as unknown as Record<string, unknown>)[key] = value;
1281
+ }
1282
+ return this;
1283
+ }
1284
+
1285
+ /**
1286
+ * Like {@link create}, but bypasses mass-assignment protection. Use only for
1287
+ * trusted data (framework-internal writes, seeders), never for request input.
1288
+ *
1289
+ * @category Persistence
1290
+ */
1291
+ static async forceCreate<T extends BaseModel>(
1292
+ this: ModelCtor<T>,
1293
+ data: Record<string, unknown>,
1294
+ ): Promise<T> {
1295
+ const inst = new this();
1296
+ inst.forceFill(data);
1297
+ return inst.save() as Promise<T>;
1298
+ }
1299
+
1300
+ /**
1301
+ * Return the first row matching `search`, or {@link create} one from
1302
+ * `search` merged with `create`.
1303
+ *
1304
+ * @throws {MassAssignmentError} when a created key is not fillable.
1305
+ * @category Persistence
1306
+ */
1307
+ static async firstOrCreate<T extends BaseModel>(
1308
+ this: ModelCtor<T>,
1309
+ search: UpdatePayload<T>,
1310
+ create?: UpdatePayload<T>,
1311
+ ): Promise<T> {
1312
+ const qb = (this as ModelCtor<T>)._newScopedQuery();
1313
+ for (const [k, v] of Object.entries(search as Record<string, unknown>)) {
1314
+ qb.where(toSnake(k), v);
1315
+ }
1316
+ const existing = await qb.first<T>();
1317
+ if (existing) return existing;
1318
+ return (this as ModelCtor<T>).create<T>({
1319
+ ...search,
1320
+ ...(create ?? {}),
1321
+ } as InsertPayload<T>);
1322
+ }
1323
+
1324
+ /**
1325
+ * Update the first row matching `search`, or create it. Returns the model.
1326
+ *
1327
+ * @example
1328
+ * await User.updateOrCreate({ email }, { name, lastSeenAt: new Date() });
1329
+ *
1330
+ * @throws {MassAssignmentError} when an updated/created key is not fillable.
1331
+ * @category Persistence
1332
+ */
1333
+ static async updateOrCreate<T extends BaseModel>(
1334
+ this: ModelCtor<T>,
1335
+ search: UpdatePayload<T>,
1336
+ values?: UpdatePayload<T>,
1337
+ ): Promise<T> {
1338
+ const qb = (this as ModelCtor<T>)._newScopedQuery();
1339
+ for (const [k, v] of Object.entries(search as Record<string, unknown>)) {
1340
+ qb.where(toSnake(k), v);
1341
+ }
1342
+ const existing = await qb.first<T>();
1343
+ if (existing) {
1344
+ existing.fill((values ?? {}) as UpdatePayload<T>);
1345
+ return existing.save() as Promise<T>;
1346
+ }
1347
+ return (this as ModelCtor<T>).create<T>({
1348
+ ...search,
1349
+ ...(values ?? {}),
1350
+ } as InsertPayload<T>);
1351
+ }
1352
+
1353
+ /**
1354
+ * Return the first row matching `search`, or a new **unsaved** instance
1355
+ * filled with `search` + `values`.
1356
+ *
1357
+ * @throws {MassAssignmentError} when a filled key is not fillable.
1358
+ * @category Persistence
1359
+ */
1360
+ static async firstOrNew<T extends BaseModel>(
1361
+ this: ModelCtor<T>,
1362
+ search: UpdatePayload<T>,
1363
+ values?: UpdatePayload<T>,
1364
+ ): Promise<T> {
1365
+ const qb = (this as ModelCtor<T>)._newScopedQuery();
1366
+ for (const [k, v] of Object.entries(search as Record<string, unknown>)) {
1367
+ qb.where(toSnake(k), v);
1368
+ }
1369
+ const existing = await qb.first<T>();
1370
+ if (existing) return existing;
1371
+ const inst = new this();
1372
+ inst.fill({ ...search, ...(values ?? {}) } as UpdatePayload<T>);
1373
+ return inst;
1374
+ }
1375
+
1376
+ /**
1377
+ * Find by primary key, or return a new **unsaved** instance if not found.
1378
+ *
1379
+ * @category Persistence
1380
+ */
1381
+ static async findOrNew<T extends BaseModel>(this: ModelCtor<T>, id: number | string): Promise<T> {
1382
+ const found = await (this as ModelCtor<T>).find(id);
1383
+ if (found) return found;
1384
+ return new this();
1385
+ }
1386
+
1387
+ /**
1388
+ * Create multiple rows one at a time, returning the saved model instances.
1389
+ * Each row goes through the full `save()` path — casts, hashing, timestamps,
1390
+ * and observer/hook events all fire per row.
1391
+ *
1392
+ * For large bulk loads where per-row events are not needed, prefer
1393
+ * {@link bulkInsert} which issues a single multi-row INSERT.
1394
+ *
1395
+ * @throws {MassAssignmentError} when any record contains a non-fillable key.
1396
+ * @category Persistence
1397
+ */
1398
+ static async createMany<T extends BaseModel>(
1399
+ this: ModelCtor<T>,
1400
+ records: InsertPayload<T>[],
1401
+ ): Promise<T[]> {
1402
+ const out: T[] = [];
1403
+ for (const r of records) out.push(await (this as ModelCtor<T>).create<T>(r));
1404
+ return out;
1405
+ }
1406
+
1407
+ /**
1408
+ * Insert many rows in a single multi-row `INSERT`, returning the number of
1409
+ * rows written. Applies casts and timestamps but bypasses per-row `save()`
1410
+ * lifecycle hooks — the fast path for bulk loads. Use {@link createMany} when
1411
+ * you need hooks/observers per row.
1412
+ *
1413
+ * @returns the number of rows inserted (`0` for an empty input).
1414
+ * @category Persistence
1415
+ */
1416
+ static async bulkInsert<T extends BaseModel>(
1417
+ this: ModelCtor<T>,
1418
+ records: InsertPayload<T>[],
1419
+ ): Promise<number> {
1420
+ if (records.length === 0) return 0;
1421
+
1422
+ const ModelClass = this as unknown as typeof BaseModel;
1423
+ const conn = _resolveConn(ModelClass);
1424
+ const dialect = dialectFor(conn as unknown as object);
1425
+ const casts = getCasts(ModelClass as unknown as Function);
1426
+ const colReg = columnsFor(ModelClass as unknown as Function);
1427
+ const useTs = ModelClass.timestamps;
1428
+
1429
+ const rows: Record<string, unknown>[] = _writeDialect.run(dialect, () => {
1430
+ const now = _serializeDate(new Date());
1431
+ return records.map((rec) => {
1432
+ const row: Record<string, unknown> = {};
1433
+ for (const [key, val] of Object.entries(rec as Record<string, unknown>)) {
1434
+ if (key.startsWith("_")) continue;
1435
+ row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1436
+ }
1437
+ if (useTs) {
1438
+ row["created_at"] = now;
1439
+ row["updated_at"] = now;
1440
+ }
1441
+ return row;
1442
+ });
1443
+ });
1444
+
1445
+ const colSet = new Set<string>();
1446
+ for (const r of rows) for (const k of Object.keys(r)) colSet.add(k);
1447
+ const cols = [...colSet];
1448
+ for (const c of cols) _assertIdentifier(c, "bulkInsert()");
1449
+
1450
+ const segs: Seg[] = [`INSERT INTO ${ModelClass.table} (${cols.join(", ")}) VALUES `];
1451
+ rows.forEach((r, ri) => {
1452
+ if (ri > 0) segs.push(", ");
1453
+ segs.push("(");
1454
+ cols.forEach((c, ci) => {
1455
+ if (ci > 0) segs.push(", ");
1456
+ segs.push({ val: c in r ? r[c] : null });
1457
+ });
1458
+ segs.push(")");
1459
+ });
1460
+
1461
+ await runSegs(conn, segs);
1462
+ return rows.length;
1463
+ }
1464
+
1465
+ /**
1466
+ * Persist multiple already-built instances (each via {@link save}).
1467
+ *
1468
+ * @category Persistence
1469
+ */
1470
+ static async saveMany<T extends BaseModel>(this: ModelCtor<T>, models: T[]): Promise<T[]> {
1471
+ for (const m of models) await m.save();
1472
+ return models;
1473
+ }
1474
+
1475
+ /**
1476
+ * Fetch many rows by an array of primary keys.
1477
+ *
1478
+ * @category Querying
1479
+ */
1480
+ static async findMany<T extends BaseModel>(
1481
+ this: ModelCtor<T>,
1482
+ ids: (number | string)[],
1483
+ ): Promise<T[]> {
1484
+ if (ids.length === 0) return [];
1485
+ return (this as ModelCtor<T>)._newScopedQuery().whereIn(this.primaryKey, ids).get<T>();
1486
+ }
1487
+
1488
+ /**
1489
+ * INSERT a row; update specified columns when the unique constraint fires.
1490
+ *
1491
+ * - PostgreSQL / SQLite: `ON CONFLICT (conflictKeys) DO UPDATE SET …`
1492
+ * - MySQL: `ON DUPLICATE KEY UPDATE … = VALUES(…)`
1493
+ *
1494
+ * @param data Row data (camelCase keys are converted to snake_case)
1495
+ * @param conflictKeys Columns that define the conflict constraint (ignored on MySQL)
1496
+ * @param updateCols Columns to overwrite on conflict (defaults to all non-conflict cols)
1497
+ *
1498
+ * @example
1499
+ * await User.upsert({ email: 'a@b.com', name: 'Alice' }, ['email'], ['name']);
1500
+ *
1501
+ * @category Persistence
1502
+ */
1503
+ static async upsert<T extends BaseModel>(
1504
+ this: ModelCtor<T>,
1505
+ data: InsertPayload<T>,
1506
+ conflictKeys: (keyof T & string)[],
1507
+ updateCols?: (keyof T & string)[],
1508
+ ): Promise<void> {
1509
+ const conn = _resolveConn(this);
1510
+ const dialect = dialectFor(conn as unknown as object);
1511
+ const casts = getCasts(this as unknown as Function);
1512
+ const colReg = columnsFor(this as unknown as Function);
1513
+
1514
+ const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1515
+ const r: Record<string, unknown> = {};
1516
+ for (const [key, val] of Object.entries(data as Record<string, unknown>)) {
1517
+ r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1518
+ }
1519
+ return r;
1520
+ });
1521
+
1522
+ const cols = Object.keys(row);
1523
+ const vals = Object.values(row);
1524
+ if (cols.length === 0) return;
1525
+
1526
+ const conflictSnake = conflictKeys.map(toSnake);
1527
+ const targetCols = updateCols
1528
+ ? updateCols.map(toSnake)
1529
+ : cols.filter((c) => !conflictSnake.includes(c));
1530
+
1531
+ if (targetCols.length === 0) return;
1532
+
1533
+ for (const c of cols) _assertIdentifier(c, "upsert()");
1534
+ for (const c of conflictSnake) _assertIdentifier(c, "upsert()");
1535
+ for (const c of targetCols) _assertIdentifier(c, "upsert()");
1536
+
1537
+ const segs: Seg[] = [`INSERT INTO ${this.table} (${cols.join(", ")}) VALUES (`];
1538
+ vals.forEach((v, i) => {
1539
+ if (i > 0) segs.push(", ");
1540
+ segs.push({ val: v });
1541
+ });
1542
+ segs.push(")");
1543
+
1544
+ if (dialect === "mysql") {
1545
+ segs.push(" ON DUPLICATE KEY UPDATE ");
1546
+ targetCols.forEach((col, i) => {
1547
+ if (i > 0) segs.push(", ");
1548
+ segs.push(`${col} = VALUES(${col})`);
1549
+ });
1550
+ } else {
1551
+ // PostgreSQL + SQLite
1552
+ segs.push(` ON CONFLICT (${conflictSnake.join(", ")}) DO UPDATE SET `);
1553
+ targetCols.forEach((col, i) => {
1554
+ if (i > 0) segs.push(", ");
1555
+ segs.push(`${col} = EXCLUDED.${col}`);
1556
+ });
1557
+ }
1558
+
1559
+ await runSegs(conn, segs);
1560
+ }
1561
+
1562
+ /**
1563
+ * Define a named query scope with typed arguments.
1564
+ * Assign to a static property; apply via .withScopes(s => s.active()).
1565
+ *
1566
+ * @example
1567
+ * static active = BaseModel.scope((q) => q.where('active', 1));
1568
+ * static byScore = BaseModel.scope((q, min: number) => q.where('score', '>=', min));
1569
+ *
1570
+ * @category Querying
1571
+ */
1572
+ static scope<Args extends unknown[]>(
1573
+ fn: (query: QueryBuilder, ...args: Args) => void,
1574
+ ): (...args: Args) => ScopeApplicator {
1575
+ return (...args: Args) => ({ apply: (q: QueryBuilder) => fn(q, ...args) });
1576
+ }
1577
+
1578
+ // ── Instance methods ─────────────────────────────────────────────────────
1579
+
1580
+ /**
1581
+ * Persist this instance: `INSERT` when new, or `UPDATE` of only the dirty
1582
+ * columns when it already exists. Applies casts, hashes {@link hashable}
1583
+ * fields, maintains timestamps, and runs the save/create/update lifecycle
1584
+ * hooks. On insert the new primary key (and full row) are read back onto the
1585
+ * instance. Returns `this`.
1586
+ *
1587
+ * @example
1588
+ * const user = new User();
1589
+ * user.fill({ name: "Ada", email: "ada@example.com" });
1590
+ * await user.save();
1591
+ *
1592
+ * @category Persistence
1593
+ */
1594
+ async save(): Promise<this> {
1595
+ const ModelClass = this.constructor as typeof BaseModel;
1596
+ const conn = _resolveConn(ModelClass);
1597
+ const dialect = dialectFor(conn as unknown as object);
1598
+ const rels = relNames(ModelClass as unknown as Function);
1599
+ const casts = getCasts(ModelClass as unknown as Function);
1600
+
1601
+ await HookRegistry.run(ModelClass, "beforeSave", this);
1602
+
1603
+ // ── Auto-hash hashable fields ─────────────────────────────────────────
1604
+ // On INSERT: hash every hashable field that holds a non-empty string.
1605
+ // On UPDATE: only hash hashable fields whose value changed since the last
1606
+ // save (avoids re-hashing an already-stored bcrypt hash).
1607
+ const hashable = ModelClass.hashable;
1608
+ if (hashable && hashable.length > 0) {
1609
+ const self = this as unknown as Record<string, unknown>;
1610
+ if (!this._exists) {
1611
+ for (const key of hashable) {
1612
+ const val = self[key];
1613
+ if (typeof val === "string" && val.length > 0) {
1614
+ self[key] = await Bun.password.hash(val);
1615
+ }
1616
+ }
1617
+ } else {
1618
+ const orig = this._original as Record<string, unknown>;
1619
+ for (const key of hashable) {
1620
+ const current = self[key];
1621
+ if (typeof current === "string" && current.length > 0 && current !== orig[key]) {
1622
+ self[key] = await Bun.password.hash(current);
1623
+ }
1624
+ }
1625
+ }
1626
+ }
1627
+
1628
+ const colReg = columnsFor(ModelClass as unknown as Function);
1629
+ const colKeys = _allColumnKeys(ModelClass as unknown as Function);
1630
+
1631
+ if (!this._exists) {
1632
+ // ── INSERT ──
1633
+ await HookRegistry.run(ModelClass, "beforeCreate", this);
1634
+
1635
+ const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
1636
+ const r: Record<string, unknown> = {};
1637
+ for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
1638
+ r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
1639
+ }
1640
+ if (ModelClass.timestamps) {
1641
+ const now = _serializeDate(new Date());
1642
+ r["created_at"] = now;
1643
+ r["updated_at"] = now;
1644
+ }
1645
+ return r;
1646
+ });
1647
+
1648
+ const cols = Object.keys(row);
1649
+ const vals = Object.values(row);
1650
+ for (const c of cols) _assertIdentifier(c, "save()");
1651
+ const segs: Seg[] = [`INSERT INTO ${ModelClass.table} (${cols.join(", ")}) VALUES (`];
1652
+ vals.forEach((v, i) => {
1653
+ if (i > 0) segs.push(", ");
1654
+ segs.push({ val: v });
1655
+ });
1656
+ segs.push(")");
1657
+
1658
+ let newId: number;
1659
+ if (dialect === "postgres") {
1660
+ segs.push(` RETURNING ${ModelClass.primaryKey}`);
1661
+ const [returning] = await runQuery<Record<string, number>>(conn, segs);
1662
+ newId = returning![ModelClass.primaryKey as string] as number;
1663
+ } else if (dialect === "mysql") {
1664
+ // LAST_INSERT_ID() is scoped per connection, so the INSERT and the
1665
+ // SELECT must be pinned to the SAME connection — under concurrency a
1666
+ // pool may otherwise hand the SELECT to a different connection and
1667
+ // return another insert's id. Inside a transaction the connection is
1668
+ // already pinned; otherwise reserve a dedicated connection from the
1669
+ // pool (falling back to a short transaction when reserve() is
1670
+ // unavailable).
1671
+ // _serializeDate() guarantees MySQL DATETIME format ('YYYY-MM-DD HH:MM:SS').
1672
+ const insertAndReadId = async (c: SQLInstance): Promise<number> => {
1673
+ await runSegs(c, segs);
1674
+ const [lastRow] = await runQuery<{ id: number }>(c, ["SELECT LAST_INSERT_ID() as id"]);
1675
+ return lastRow!.id;
1676
+ };
1677
+ const inTx =
1678
+ TransactionContext.getStore() !== undefined ||
1679
+ RequestContext.tryGet()?._transaction !== undefined;
1680
+ const reservable = conn as unknown as {
1681
+ reserve?: () => Promise<SQLInstance & { release(): void }>;
1682
+ };
1683
+ if (inTx) {
1684
+ newId = await insertAndReadId(conn);
1685
+ } else if (typeof reservable.reserve === "function") {
1686
+ const pinned = await reservable.reserve();
1687
+ try {
1688
+ newId = await insertAndReadId(pinned);
1689
+ } finally {
1690
+ pinned.release();
1691
+ }
1692
+ } else {
1693
+ newId = await conn.begin((tx) => insertAndReadId(tx));
1694
+ }
1695
+ } else {
1696
+ await runSegs(conn, segs);
1697
+ const [lastRow] = await runQuery<{ id: number }>(conn, [
1698
+ "SELECT last_insert_rowid() as id",
1699
+ ]);
1700
+ newId = lastRow!.id;
1701
+ }
1702
+
1703
+ const rows = await runQuery<Record<string, unknown>>(conn, [
1704
+ `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1705
+ { val: newId },
1706
+ ]);
1707
+ if (rows[0]) _applyRow(this, rows[0]);
1708
+
1709
+ await HookRegistry.run(ModelClass, "afterCreate", this);
1710
+ } else {
1711
+ // ── UPDATE — only dirty columns ──
1712
+ await HookRegistry.run(ModelClass, "beforeUpdate", this);
1713
+
1714
+ const dirty = this.$dirty();
1715
+ if (ModelClass.timestamps) dirty["updatedAt"] = new Date();
1716
+
1717
+ if (Object.keys(dirty).length > 0) {
1718
+ const entries = _writeDialect.run(dialect, () =>
1719
+ Object.entries(dirty).map(
1720
+ ([k, v]) => [toSnake(k), _serializeForWrite(k, v, casts, colReg)] as [string, unknown],
1721
+ ),
1722
+ );
1723
+ const segs: Seg[] = [`UPDATE ${ModelClass.table} SET `];
1724
+ entries.forEach(([col, val], i) => {
1725
+ _assertIdentifier(col, "save()");
1726
+ if (i > 0) segs.push(", ");
1727
+ segs.push(`${col} = `);
1728
+ segs.push({ val });
1729
+ });
1730
+ segs.push(` WHERE ${ModelClass.primaryKey} = `);
1731
+ segs.push({ val: this.id });
1732
+ await runSegs(conn, segs);
1733
+
1734
+ const self = this as unknown as Record<string, unknown>;
1735
+ for (const [k, v] of Object.entries(dirty)) self[k] = v;
1736
+ Object.assign(this._original, dirty);
1737
+ }
1738
+
1739
+ await HookRegistry.run(ModelClass, "afterUpdate", this);
1740
+ }
1741
+
1742
+ this._forcedDirty.clear();
1743
+ await HookRegistry.run(ModelClass, "afterSave", this);
1744
+ return this;
1745
+ }
1746
+
1747
+ /**
1748
+ * Delete this record. For soft-delete models this sets `deleted_at` (the row
1749
+ * stays in the table but is hidden from default queries); otherwise it issues
1750
+ * a hard `DELETE`. Runs the before/after delete lifecycle hooks.
1751
+ *
1752
+ * @category Persistence
1753
+ */
1754
+ async delete(): Promise<void> {
1755
+ const ModelClass = this.constructor as typeof BaseModel;
1756
+ const conn = _resolveConn(ModelClass);
1757
+ const dialect = dialectFor(conn as unknown as object);
1758
+
1759
+ await HookRegistry.run(ModelClass, "beforeDelete", this);
1760
+
1761
+ if (ModelClass.softDeletes) {
1762
+ const now = new Date();
1763
+ await runSegs(conn, [
1764
+ `UPDATE ${ModelClass.table} SET deleted_at = `,
1765
+ { val: _writeDialect.run(dialect, () => _serializeDate(now)) },
1766
+ ` WHERE ${ModelClass.primaryKey} = `,
1767
+ { val: this.id },
1768
+ ]);
1769
+ // `deletedAt` lives on the SoftDeletes mixin; this branch only runs for models
1770
+ // that compose it (softDeletes === true).
1771
+ (this as { deletedAt?: Date | null }).deletedAt = now;
1772
+ } else {
1773
+ await runSegs(conn, [
1774
+ `DELETE FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1775
+ { val: this.id },
1776
+ ]);
1777
+ }
1778
+
1779
+ await HookRegistry.run(ModelClass, "afterDelete", this);
1780
+ }
1781
+
1782
+ /**
1783
+ * Eager-load the given relations onto this already-fetched model instance.
1784
+ * If a relation is already loaded, it is reloaded.
1785
+ *
1786
+ * @example
1787
+ * const post = await Post.find(1);
1788
+ * await post.load(['comments', 'tags']);
1789
+ * // post.comments is now populated
1790
+ *
1791
+ * @category Relationships
1792
+ */
1793
+ async load(relations: string[]): Promise<this> {
1794
+ const ModelClass = this.constructor as typeof BaseModel;
1795
+ const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
1796
+ for (const rel of relations) qb.with(rel as never);
1797
+ // _eagerLoadRelations is private on ModelQueryBuilder; use the public approach
1798
+ // by fetching from the QB's internal method via a single-item array
1799
+ await (
1800
+ qb as unknown as {
1801
+ _eagerLoadRelations(instances: BaseModel[]): Promise<void>;
1802
+ }
1803
+ )._eagerLoadRelations([this]);
1804
+ return this;
1805
+ }
1806
+
1807
+ /**
1808
+ * Like load(), but skips relations that are already loaded on this instance.
1809
+ *
1810
+ * @example
1811
+ * await post.loadMissing(['comments']); // no-op if comments already loaded
1812
+ *
1813
+ * @category Relationships
1814
+ */
1815
+ async loadMissing(relations: string[]): Promise<this> {
1816
+ // A loaded relation is a plain data property (no getter).
1817
+ // An unloaded relation has a lazy-load getter that throws RelationNotLoadedError.
1818
+ const missing = relations.filter((rel) => {
1819
+ const desc = Object.getOwnPropertyDescriptor(this, rel);
1820
+ // If descriptor has a getter, the relation is not yet loaded
1821
+ return !desc || typeof desc.get === "function";
1822
+ });
1823
+ if (missing.length > 0) await this.load(missing);
1824
+ return this;
1825
+ }
1826
+
1827
+ /**
1828
+ * Re-read this record from the database and return it as a **new** instance,
1829
+ * leaving the current one untouched. Use {@link refresh} to mutate in place.
1830
+ *
1831
+ * @throws {ModelNotFoundError} when the row no longer exists.
1832
+ * @category Persistence
1833
+ */
1834
+ async fresh(): Promise<this> {
1835
+ const ModelClass = this.constructor as typeof BaseModel;
1836
+ const conn = _resolveConn(ModelClass);
1837
+ const rows = await runQuery<Record<string, unknown>>(conn, [
1838
+ `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1839
+ { val: this.id },
1840
+ ]);
1841
+ if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, this.id);
1842
+ return ModelClass.fromRow(rows[0]) as this;
1843
+ }
1844
+
1845
+ /**
1846
+ * Reload this instance's attributes from the database, mutating it in place.
1847
+ * Unlike `fresh()` (which returns a new instance) this updates `this`.
1848
+ *
1849
+ * @throws {ModelNotFoundError} when the row no longer exists.
1850
+ * @category Persistence
1851
+ */
1852
+ async refresh(): Promise<this> {
1853
+ const ModelClass = this.constructor as typeof BaseModel;
1854
+ const conn = _resolveConn(ModelClass);
1855
+ const rows = await runQuery<Record<string, unknown>>(conn, [
1856
+ `SELECT * FROM ${ModelClass.table} WHERE ${ModelClass.primaryKey} = `,
1857
+ { val: this.id },
1858
+ ]);
1859
+ if (!rows[0]) throw new ModelNotFoundError(ModelClass.name, this.id);
1860
+ _applyRow(this, rows[0]);
1861
+ return this;
1862
+ }
1863
+
1864
+ /**
1865
+ * Copy this model into a new **unsaved** instance. The primary key and
1866
+ * timestamps are not copied; pass `except` to omit additional columns.
1867
+ *
1868
+ * @category Persistence
1869
+ */
1870
+ replicate(except?: string[]): this {
1871
+ const ModelClass = this.constructor as typeof BaseModel;
1872
+ const rels = relNames(ModelClass as unknown as Function);
1873
+ const colKeys = _allColumnKeys(ModelClass as unknown as Function);
1874
+ const skip = new Set<string>([...SYSTEM_KEYS, ...(except ?? [])]);
1875
+ const inst = new (this.constructor as new () => this)();
1876
+ for (const [k, v] of ownDataEntries(this, skip, rels, colKeys)) {
1877
+ (inst as unknown as Record<string, unknown>)[k] = v;
1878
+ }
1879
+ return inst;
1880
+ }
1881
+
1882
+ /**
1883
+ * Bump `updated_at` to now and persist. No-op when timestamps are disabled.
1884
+ *
1885
+ * @category Timestamps
1886
+ */
1887
+ async touch(): Promise<this> {
1888
+ const ModelClass = this.constructor as typeof BaseModel;
1889
+ if (!ModelClass.timestamps) return this;
1890
+ this.updatedAt = new Date();
1891
+ this.markDirty("updatedAt" as keyof this);
1892
+ return this.save();
1893
+ }
1894
+
1895
+ /**
1896
+ * True when `other` is the same model class with the same primary key.
1897
+ *
1898
+ * @category Comparison
1899
+ */
1900
+ is(other: BaseModel | null | undefined): boolean {
1901
+ if (!other) return false;
1902
+ if (other.constructor !== this.constructor) return false;
1903
+ const pk = toCamel((this.constructor as typeof BaseModel).primaryKey);
1904
+ return (
1905
+ (other as unknown as Record<string, unknown>)[pk] ===
1906
+ (this as unknown as Record<string, unknown>)[pk]
1907
+ );
1908
+ }
1909
+
1910
+ /**
1911
+ * Inverse of {@link is}.
1912
+ *
1913
+ * @category Comparison
1914
+ */
1915
+ isNot(other: BaseModel | null | undefined): boolean {
1916
+ return !this.is(other);
1917
+ }
1918
+
1919
+ /**
1920
+ * Atomically increment a column in the DB and on this instance.
1921
+ *
1922
+ * @category Persistence
1923
+ */
1924
+ async increment(column: keyof this & string, amount = 1): Promise<this> {
1925
+ return this._stepColumn(column, amount);
1926
+ }
1927
+
1928
+ /**
1929
+ * Atomically decrement a column in the DB and on this instance.
1930
+ *
1931
+ * @category Persistence
1932
+ */
1933
+ async decrement(column: keyof this & string, amount = 1): Promise<this> {
1934
+ return this._stepColumn(column, -amount);
1935
+ }
1936
+
1937
+ /** @internal Shared implementation of {@link increment} / {@link decrement}. */
1938
+ private async _stepColumn(column: string, delta: number): Promise<this> {
1939
+ const ModelClass = this.constructor as typeof BaseModel;
1940
+ const conn = _resolveConn(ModelClass);
1941
+ const col = toSnake(column);
1942
+ _assertIdentifier(col, "increment()/decrement()");
1943
+ const op = delta >= 0 ? "+" : "-";
1944
+ await runSegs(conn, [
1945
+ `UPDATE ${ModelClass.table} SET ${col} = ${col} ${op} `,
1946
+ { val: Math.abs(delta) },
1947
+ ` WHERE ${ModelClass.primaryKey} = `,
1948
+ { val: this.id },
1949
+ ]);
1950
+ const self = this as unknown as Record<string, unknown>;
1951
+ const current = Number(self[column] ?? 0);
1952
+ self[column] = current + delta;
1953
+ (this._original as Record<string, unknown>)[column] = self[column];
1954
+ return this;
1955
+ }
1956
+
1957
+ /**
1958
+ * Load relation COUNT(s) onto this instance (sets `<rel>Count`).
1959
+ *
1960
+ * @category Relationships
1961
+ */
1962
+ async loadCount(relations: string | string[]): Promise<this> {
1963
+ const rels = Array.isArray(relations) ? relations : [relations];
1964
+ const ModelClass = this.constructor as typeof BaseModel;
1965
+ const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
1966
+ qb.where(ModelClass.primaryKey, this.id);
1967
+ for (const r of rels) qb.withCount(r);
1968
+ const fresh = (await qb.limit(1).get<BaseModel>())[0] ?? null;
1969
+ const src = fresh as unknown as Record<string, unknown> | null;
1970
+ const self = this as unknown as Record<string, unknown>;
1971
+ for (const r of rels) {
1972
+ const key = `${toCamel(r)}Count`;
1973
+ self[key] = src?.[key] ?? 0;
1974
+ }
1975
+ return this;
1976
+ }
1977
+
1978
+ /**
1979
+ * Load relation COUNT(s) for an array of already-fetched models in a SINGLE
1980
+ * query (no N+1), setting `<rel>Count` on each — prefer this over calling the
1981
+ * instance `loadCount()` in a loop.
1982
+ *
1983
+ * @example
1984
+ * const posts = await Post.all();
1985
+ * await Post.loadCount(posts, "comments");
1986
+ * posts[0]!.commentsCount;
1987
+ *
1988
+ * @category Relationships
1989
+ */
1990
+ static async loadCount<T extends BaseModel>(
1991
+ this: typeof BaseModel,
1992
+ models: T[],
1993
+ relations: string | string[],
1994
+ ): Promise<T[]> {
1995
+ if (models.length === 0) return models;
1996
+ const rels = Array.isArray(relations) ? relations : [relations];
1997
+ const idOf = (m: T): unknown => (m as unknown as { id: unknown }).id;
1998
+
1999
+ const qb = new ModelQueryBuilder(this.table, _resolveConn(this), this);
2000
+ qb.whereIn(this.primaryKey, models.map(idOf));
2001
+ for (const r of rels) qb.withCount(r);
2002
+ const fresh = await qb.get<BaseModel>();
2003
+
2004
+ const byId = new Map<unknown, Record<string, unknown>>();
2005
+ for (const f of fresh) byId.set((f as unknown as { id: unknown }).id, f as never);
2006
+
2007
+ for (const m of models) {
2008
+ const src = byId.get(idOf(m));
2009
+ const rec = m as unknown as Record<string, unknown>;
2010
+ for (const r of rels) {
2011
+ const key = countAttribute(r);
2012
+ rec[key] = src?.[key] ?? 0;
2013
+ }
2014
+ }
2015
+ return models;
2016
+ }
2017
+
2018
+ /** @internal Shared implementation of {@link loadSum} / {@link loadAvg} / {@link loadMin} / {@link loadMax}. */
2019
+ private async _loadAgg(
2020
+ fn: "Sum" | "Avg" | "Min" | "Max",
2021
+ relation: string,
2022
+ column: string,
2023
+ ): Promise<this> {
2024
+ const ModelClass = this.constructor as typeof BaseModel;
2025
+ const qb = new ModelQueryBuilder(ModelClass.table, _resolveConn(ModelClass), ModelClass);
2026
+ qb.where(ModelClass.primaryKey, this.id);
2027
+ (qb as unknown as Record<string, (r: string, c: string) => void>)[`with${fn}`]?.(
2028
+ relation,
2029
+ column,
2030
+ );
2031
+ const fresh = (await qb.limit(1).get<BaseModel>())[0] ?? null;
2032
+ // Attribute name matches the eager path, e.g. commentsSumVotes.
2033
+ const key = aggregateAttribute(relation, fn, column);
2034
+ (this as unknown as Record<string, unknown>)[key] =
2035
+ (fresh as unknown as Record<string, unknown> | null)?.[key] ?? 0;
2036
+ return this;
2037
+ }
2038
+
2039
+ /**
2040
+ * Load `SUM(column)` over a relation onto this instance (sets `<rel>Sum<Column>`).
2041
+ *
2042
+ * @category Relationships
2043
+ */
2044
+ loadSum(relation: string, column: string): Promise<this> {
2045
+ return this._loadAgg("Sum", relation, column);
2046
+ }
2047
+ /**
2048
+ * Load `AVG(column)` over a relation onto this instance (sets `<rel>Avg<Column>`).
2049
+ *
2050
+ * @category Relationships
2051
+ */
2052
+ loadAvg(relation: string, column: string): Promise<this> {
2053
+ return this._loadAgg("Avg", relation, column);
2054
+ }
2055
+ /**
2056
+ * Load `MIN(column)` over a relation onto this instance (sets `<rel>Min<Column>`).
2057
+ *
2058
+ * @category Relationships
2059
+ */
2060
+ loadMin(relation: string, column: string): Promise<this> {
2061
+ return this._loadAgg("Min", relation, column);
2062
+ }
2063
+ /**
2064
+ * Load `MAX(column)` over a relation onto this instance (sets `<rel>Max<Column>`).
2065
+ *
2066
+ * @category Relationships
2067
+ */
2068
+ loadMax(relation: string, column: string): Promise<this> {
2069
+ return this._loadAgg("Max", relation, column);
2070
+ }
2071
+
2072
+ /**
2073
+ * Hide additional keys from `toJSON()` for this instance only.
2074
+ *
2075
+ * @category Serialization
2076
+ */
2077
+ makeHidden(...keys: string[]): this {
2078
+ const self = this as unknown as { _instanceHidden?: Set<string> };
2079
+ self._instanceHidden = new Set([...(self._instanceHidden ?? []), ...keys]);
2080
+ return this;
2081
+ }
2082
+
2083
+ /**
2084
+ * Reveal keys that are hidden by the class `hidden` list, for this instance only.
2085
+ *
2086
+ * @category Serialization
2087
+ */
2088
+ makeVisible(...keys: string[]): this {
2089
+ const self = this as unknown as { _instanceVisible?: Set<string> };
2090
+ self._instanceVisible = new Set([...(self._instanceVisible ?? []), ...keys]);
2091
+ return this;
2092
+ }
2093
+
2094
+ /**
2095
+ * Add computed accessor name(s) to this instance's `toJSON()` output.
2096
+ *
2097
+ * @category Serialization
2098
+ */
2099
+ append(...keys: string[]): this {
2100
+ const self = this as unknown as { _instanceAppends?: string[] };
2101
+ self._instanceAppends = [...(self._instanceAppends ?? []), ...keys];
2102
+ return this;
2103
+ }
2104
+
2105
+ /**
2106
+ * Set this model's belongsTo foreign key to `model` and cache the relation.
2107
+ * Does not persist — call `save()` afterwards.
2108
+ *
2109
+ * @example
2110
+ * comment.associate('post', post);
2111
+ * await comment.save();
2112
+ *
2113
+ * @throws {Error} when `relation` is not a `belongsTo` relation on this model.
2114
+ * @category Relationships
2115
+ */
2116
+ associate(relation: string, model: BaseModel): this {
2117
+ const meta = relationsFor(this.constructor).get(relation);
2118
+ if (!meta || meta.type !== "belongsTo") {
2119
+ throw new Error(
2120
+ `associate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
2121
+ );
2122
+ }
2123
+ const fkProp = toCamel(meta.foreignKey);
2124
+ const ownerKeyProp = toCamel(meta.localKey);
2125
+ (this as unknown as Record<string, unknown>)[fkProp] = (
2126
+ model as unknown as Record<string, unknown>
2127
+ )[ownerKeyProp];
2128
+ Object.defineProperty(this, relation, {
2129
+ value: model,
2130
+ enumerable: true,
2131
+ configurable: true,
2132
+ writable: true,
2133
+ });
2134
+ return this;
2135
+ }
2136
+
2137
+ /**
2138
+ * Clear this model's belongsTo foreign key and cached relation.
2139
+ *
2140
+ * @throws {Error} when `relation` is not a `belongsTo` relation on this model.
2141
+ * @category Relationships
2142
+ */
2143
+ dissociate(relation: string): this {
2144
+ const meta = relationsFor(this.constructor).get(relation);
2145
+ if (!meta || meta.type !== "belongsTo") {
2146
+ throw new Error(
2147
+ `dissociate(): "${relation}" is not a belongsTo relation on ${this.constructor.name}`,
2148
+ );
2149
+ }
2150
+ const fkProp = toCamel(meta.foreignKey);
2151
+ (this as unknown as Record<string, unknown>)[fkProp] = null;
2152
+ Object.defineProperty(this, relation, {
2153
+ value: null,
2154
+ enumerable: true,
2155
+ configurable: true,
2156
+ writable: true,
2157
+ });
2158
+ return this;
2159
+ }
2160
+
2161
+ /**
2162
+ * True when the model (or a specific `column`) has unsaved changes since it
2163
+ * was loaded or last saved.
2164
+ *
2165
+ * @category Attributes & mass assignment
2166
+ */
2167
+ isDirty(column?: string): boolean {
2168
+ if (column) {
2169
+ const current = (this as unknown as Record<string, unknown>)[column];
2170
+ const original = this._original[column];
2171
+ return current !== original || this._forcedDirty.has(column);
2172
+ }
2173
+ return Object.keys(this.$dirty()).length > 0;
2174
+ }
2175
+
2176
+ /**
2177
+ * Return a map of changed columns to their current values — the set that a
2178
+ * subsequent {@link save} would write.
2179
+ *
2180
+ * @category Attributes & mass assignment
2181
+ */
2182
+ $dirty(): Record<string, unknown> {
2183
+ const ModelClass = this.constructor as typeof BaseModel;
2184
+ const rels = relNames(ModelClass as unknown as Function);
2185
+ const colKeys = _allColumnKeys(ModelClass as unknown as Function);
2186
+ const out: Record<string, unknown> = {};
2187
+ for (const [key, val] of ownDataEntries(this, SYSTEM_KEYS, rels, colKeys)) {
2188
+ if (val !== this._original[key] || this._forcedDirty.has(key)) {
2189
+ out[key] = val;
2190
+ }
2191
+ }
2192
+ return out;
2193
+ }
2194
+
2195
+ /**
2196
+ * Force a property to be treated as dirty so it is included in the next
2197
+ * {@link save}, even if its value is reference-equal to the loaded snapshot
2198
+ * (e.g. an in-place mutation of a JSON column).
2199
+ *
2200
+ * @category Attributes & mass assignment
2201
+ */
2202
+ markDirty(property: keyof this): this {
2203
+ this._forcedDirty.add(String(property));
2204
+ return this;
2205
+ }
2206
+
2207
+ /**
2208
+ * Called automatically by JSON.stringify — returns a plain object containing
2209
+ * only user-facing data:
2210
+ *
2211
+ * • All column values and loaded relation instances (enumerable own props)
2212
+ * • Excludes internal ORM state: _original, _exists, _forcedDirty, _zerotal_*
2213
+ * • Excludes any remaining getter guards (unloaded relation lazy-load traps)
2214
+ *
2215
+ * Nested model instances (e.g. post.author) also have toJSON(), so
2216
+ * JSON.stringify recurses correctly through the full object graph.
2217
+ *
2218
+ * @category Serialization
2219
+ */
2220
+ toJSON(): Record<string, unknown> {
2221
+ const ModelClass = this.constructor as typeof BaseModel;
2222
+ const self = this as unknown as {
2223
+ _instanceHidden?: Set<string>;
2224
+ _instanceVisible?: Set<string>;
2225
+ _instanceAppends?: string[];
2226
+ };
2227
+
2228
+ const instVisible = self._instanceVisible ?? new Set<string>();
2229
+ // Effective hidden = class hidden + per-instance hidden − per-instance visible.
2230
+ const hidden = new Set<string>([...(ModelClass.hidden ?? []), ...(self._instanceHidden ?? [])]);
2231
+ for (const k of instVisible) hidden.delete(k);
2232
+
2233
+ // The allow-list is a *class-level* opt-in. `makeVisible()` un-hides a key; it does not
2234
+ // create an allow-list where the class had none — otherwise, with `static visible` empty
2235
+ // (the normal case), one `makeVisible("password")` would reduce the whole payload to
2236
+ // exactly that field. The per-instance set only extends an allow-list the class already
2237
+ // declared.
2238
+ const classVisible = ModelClass.visible ?? [];
2239
+ const visible =
2240
+ classVisible.length > 0 ? new Set<string>([...classVisible, ...instVisible]) : null;
2241
+
2242
+ const include = (key: string): boolean => {
2243
+ if (hidden.has(key)) return false;
2244
+ if (visible && !visible.has(key)) return false;
2245
+ return true;
2246
+ };
2247
+
2248
+ const out: Record<string, unknown> = {};
2249
+ for (const key of Object.keys(this)) {
2250
+ if (key.startsWith("_")) continue;
2251
+ if (!include(key)) continue;
2252
+ const desc = Object.getOwnPropertyDescriptor(this, key);
2253
+ if (desc && typeof desc.get === "function") {
2254
+ // Skip lazy-load relation guards — reading one throws RelationNotLoadedError — but
2255
+ // INCLUDE reactive column accessors. `installReactiveAccessors` defines a getter for
2256
+ // every `cast: "json" | "array"` column, so the blanket skip silently dropped every
2257
+ // such column from toJSON(): persistence still worked, but the field vanished from API
2258
+ // responses, cache writes and queue payloads with no error. A `_zerotal_<key>` backing
2259
+ // data property is what distinguishes the two. `ownDataEntries` (see above) already
2260
+ // draws exactly this distinction; toJSON did not.
2261
+ if (!Object.prototype.hasOwnProperty.call(this, `_zerotal_${key}`)) continue;
2262
+ }
2263
+ out[key] = (this as Record<string, unknown>)[key];
2264
+ }
2265
+
2266
+ // Computed accessors declared via `static appends` / instance `append()`.
2267
+ const appends = [...(ModelClass.appends ?? []), ...(self._instanceAppends ?? [])];
2268
+ for (const name of appends) {
2269
+ if (!include(name)) continue;
2270
+ out[name] = (this as unknown as Record<string, unknown>)[name];
2271
+ }
2272
+
2273
+ return out;
2274
+ }
2275
+
2276
+ // ── Internal ──────────────────────────────────────────────────────────────
2277
+
2278
+ /**
2279
+ * Build a model instance from a raw database row, applying casts and marking
2280
+ * it as DB-resident. Primarily used internally by the query builder.
2281
+ *
2282
+ * @internal
2283
+ */
2284
+ static fromRow(row: Record<string, unknown>): BaseModel {
2285
+ const inst = new this();
2286
+ _applyRow(inst, row);
2287
+ return inst;
2288
+ }
2289
+ }
2290
+
2291
+ // ── Module-private helpers ───────────────────────────────────────────────────
2292
+
2293
+ /**
2294
+ * Returns a ManyToMany<never> pivot proxy for an *unloaded* manyToMany relation.
2295
+ *
2296
+ * The pivot methods (attach / detach / sync / toggle) work immediately using
2297
+ * the parent model's primary key — no need to eager-load the relation first.
2298
+ * Any array access (length, iteration, indexing) throws RelationNotLoadedError,
2299
+ * preserving the same contract as other unloaded relations.
2300
+ */
2301
+ function _createLazyPivotProxy(
2302
+ relName: string,
2303
+ modelName: string,
2304
+ pivotTable: string,
2305
+ pivotForeignKey: string,
2306
+ pivotRelatedKey: string,
2307
+ parentId: unknown,
2308
+ ): ManyToMany<never> {
2309
+ return new Proxy([] as unknown as ManyToMany<never>, {
2310
+ get(_: ManyToMany<never>, prop: string | symbol): unknown {
2311
+ switch (prop) {
2312
+ case "attach":
2313
+ return async (id: number | number[]): Promise<void> => {
2314
+ const ids = Array.isArray(id) ? id : [id];
2315
+ const conn = _resolveConn();
2316
+ for (const relId of ids) {
2317
+ await new QueryBuilder(pivotTable, conn).insert({
2318
+ [pivotForeignKey]: parentId,
2319
+ [pivotRelatedKey]: relId,
2320
+ });
2321
+ }
2322
+ };
2323
+ case "detach":
2324
+ return async (id?: number | number[]): Promise<void> => {
2325
+ const conn = _resolveConn();
2326
+ const qb = new QueryBuilder(pivotTable, conn).where(pivotForeignKey, parentId);
2327
+ if (id !== undefined) {
2328
+ const ids = Array.isArray(id) ? id : [id];
2329
+ qb.whereIn(pivotRelatedKey, ids as unknown[]);
2330
+ }
2331
+ await qb.delete();
2332
+ };
2333
+ case "sync":
2334
+ return async (ids: number[]): Promise<void> => {
2335
+ const conn = _resolveConn();
2336
+ await new QueryBuilder(pivotTable, conn).where(pivotForeignKey, parentId).delete();
2337
+ for (const relId of ids) {
2338
+ await new QueryBuilder(pivotTable, conn).insert({
2339
+ [pivotForeignKey]: parentId,
2340
+ [pivotRelatedKey]: relId,
2341
+ });
2342
+ }
2343
+ };
2344
+ case "toggle":
2345
+ return async (id: number | number[]): Promise<void> => {
2346
+ const conn = _resolveConn();
2347
+ const ids = Array.isArray(id) ? id : [id];
2348
+ for (const relId of ids) {
2349
+ const existing = await new QueryBuilder(pivotTable, conn)
2350
+ .where(pivotForeignKey, parentId)
2351
+ .where(pivotRelatedKey, relId)
2352
+ .first<Record<string, unknown>>();
2353
+ if (existing) {
2354
+ await new QueryBuilder(pivotTable, conn)
2355
+ .where(pivotForeignKey, parentId)
2356
+ .where(pivotRelatedKey, relId)
2357
+ .delete();
2358
+ } else {
2359
+ await new QueryBuilder(pivotTable, conn).insert({
2360
+ [pivotForeignKey]: parentId,
2361
+ [pivotRelatedKey]: relId,
2362
+ });
2363
+ }
2364
+ }
2365
+ };
2366
+ default:
2367
+ throw new RelationNotLoadedError(relName, modelName);
2368
+ }
2369
+ },
2370
+ });
2371
+ }
2372
+
2373
+ function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
2374
+ const self = inst as unknown as Record<string, unknown>;
2375
+ const orig: Record<string, unknown> = {};
2376
+ const ctor = inst.constructor;
2377
+ const ModelClass = ctor as typeof BaseModel;
2378
+ const colReg = columnsFor(ctor);
2379
+ installReactiveAccessors(inst); // json/array reactiveCasts accessors (registered at decoration)
2380
+ const casts = getCasts(ctor);
2381
+
2382
+ for (const [snakeKey, rawVal] of Object.entries(row)) {
2383
+ const camelKey = toCamel(snakeKey);
2384
+ // Prefer the property name as registered in the column registry. Models that
2385
+ // define snake_case properties (e.g. `is_active`) register under the snake_case
2386
+ // key, so we fall back to snakeKey when camelKey has no entry.
2387
+ const colMeta = colReg?.get(camelKey) ?? colReg?.get(snakeKey);
2388
+ const propKey = colReg?.has(camelKey) ? camelKey : colReg?.has(snakeKey) ? snakeKey : camelKey;
2389
+ const cast = casts[propKey] ?? casts[camelKey] ?? colMeta?.cast;
2390
+ const colType = colMeta?.type;
2391
+ const castObj =
2392
+ cast && typeof cast === "object" && typeof (cast as { get?: unknown }).get === "function"
2393
+ ? (cast as { get(v: unknown): unknown })
2394
+ : undefined;
2395
+
2396
+ let finalVal: unknown;
2397
+ if (castObj) {
2398
+ // User-defined cast (object or Cast class) — method call preserves `this`.
2399
+ finalVal = castObj.get(rawVal);
2400
+ } else if (typeof cast === "string") {
2401
+ // Explicit shorthand cast ('boolean', 'json', 'date', etc.)
2402
+ finalVal = applyCastGet(rawVal, cast);
2403
+ } else if (colType === "boolean" && rawVal !== null && rawVal !== undefined) {
2404
+ // Auto-cast based on @column({ type: 'boolean' }) — SQLite stores 0/1
2405
+ finalVal = rawVal === 1 || rawVal === "1" || rawVal === true;
2406
+ } else if (colType === "datetime" && rawVal !== null && rawVal !== undefined) {
2407
+ finalVal = new Carbon(rawVal as string | number);
2408
+ } else if (colType === "json" && rawVal !== null && rawVal !== undefined) {
2409
+ // Auto-cast based on @column({ type: 'json' }) — SQLite stores JSON strings
2410
+ finalVal = typeof rawVal === "string" ? tryParseJson(rawVal) : rawVal;
2411
+ } else if (camelKey === "createdAt" || camelKey === "updatedAt") {
2412
+ finalVal = rawVal != null ? new Carbon(rawVal as string | number) : undefined;
2413
+ } else if (camelKey === "deletedAt") {
2414
+ finalVal = rawVal != null ? new Carbon(rawVal as string | number) : null;
2415
+ } else {
2416
+ finalVal = rawVal;
2417
+ }
2418
+
2419
+ if (shouldProxyCast(ModelClass, cast, colType)) {
2420
+ finalVal = makeReactive(inst, propKey, finalVal);
2421
+ }
2422
+
2423
+ const desc = Object.getOwnPropertyDescriptor(inst, propKey);
2424
+ if (desc && typeof desc.set === "function") {
2425
+ const privateKey = `_zerotal_${propKey}`;
2426
+ (self as Record<string, unknown>)[privateKey] = finalVal;
2427
+ } else {
2428
+ self[propKey] = finalVal;
2429
+ }
2430
+ orig[propKey] = finalVal;
2431
+ }
2432
+
2433
+ // Mark as DB-resident so save() chooses UPDATE over INSERT.
2434
+ (inst as unknown as { _exists: boolean })._exists = true;
2435
+
2436
+ // Install lazy-load guard getters for every declared relation.
2437
+ // For manyToMany relations a pivot proxy is returned instead of throwing so
2438
+ // that attach/detach/sync/toggle can be called without eager-loading.
2439
+ // The setter in every guard rewrites the property to a plain data property
2440
+ // so _attach() can overwrite without triggering the getter recursively.
2441
+ // Walk the prototype chain so relations declared on ancestor classes (e.g.
2442
+ // layered mixins) are guarded on the concrete subclass too; child wins.
2443
+ const relations = relationsFor(ctor);
2444
+ if (relations.size) {
2445
+ for (const [relName, relMeta] of relations.entries()) {
2446
+ if (relMeta.type === "manyToMany") {
2447
+ const { pivotTable, pivotForeignKey, pivotRelatedKey, localKey } = relMeta;
2448
+ const localKeyProp = toCamel(localKey);
2449
+ Object.defineProperty(inst, relName, {
2450
+ get(this: BaseModel) {
2451
+ const parentId = (this as unknown as Record<string, unknown>)[localKeyProp];
2452
+ return _createLazyPivotProxy(
2453
+ relName,
2454
+ ctor.name,
2455
+ pivotTable!,
2456
+ pivotForeignKey!,
2457
+ pivotRelatedKey!,
2458
+ parentId,
2459
+ );
2460
+ },
2461
+ set(this: BaseModel, v: unknown) {
2462
+ Object.defineProperty(this, relName, {
2463
+ value: v,
2464
+ enumerable: true,
2465
+ configurable: true,
2466
+ writable: true,
2467
+ });
2468
+ },
2469
+ configurable: true,
2470
+ enumerable: false,
2471
+ });
2472
+ } else {
2473
+ Object.defineProperty(inst, relName, {
2474
+ get(this: BaseModel) {
2475
+ throw new RelationNotLoadedError(relName, ctor.name);
2476
+ },
2477
+ set(this: BaseModel, v: unknown) {
2478
+ Object.defineProperty(this, relName, {
2479
+ value: v,
2480
+ enumerable: true,
2481
+ configurable: true,
2482
+ writable: true,
2483
+ });
2484
+ },
2485
+ configurable: true,
2486
+ // false so JSON.stringify / cache drivers skip unloaded relations.
2487
+ // Once loaded the set() handler above redefines the property with
2488
+ // enumerable:true so it IS included in subsequent serialisation.
2489
+ enumerable: false,
2490
+ });
2491
+ }
2492
+ }
2493
+ }
2494
+
2495
+ (inst as unknown as { _original: Record<string, unknown> })._original = orig;
2496
+ }
2497
+
2498
+ // Backward-compat alias (index.ts exports `Model`)
2499
+ export { BaseModel as Model };