@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,184 @@
1
+ /** Discriminator for every relation kind the ORM supports. */
2
+ export type RelationType =
3
+ | "hasMany"
4
+ | "belongsTo"
5
+ | "hasOne"
6
+ | "manyToMany"
7
+ | "morphTo"
8
+ | "morphMany"
9
+ | "morphOne"
10
+ | "hasManyThrough"
11
+ | "hasOneThrough"
12
+ | "morphToMany"
13
+ | "morphedByMany";
14
+
15
+ /**
16
+ * Normalized descriptor for a single relation, produced by a relation decorator
17
+ * and stored in the {@link relationRegistry}. The {@link ModelQueryBuilder} reads
18
+ * it to build eager-load, existence and aggregate queries. Which fields are
19
+ * meaningful depends on {@link RelationMetadata.type | type} (see the per-field
20
+ * notes below).
21
+ */
22
+ export interface RelationMetadata {
23
+ type: RelationType;
24
+ related: () => unknown;
25
+ foreignKey: string;
26
+ localKey: string;
27
+ /** manyToMany only */
28
+ pivotTable?: string;
29
+ /** manyToMany only */
30
+ pivotForeignKey?: string;
31
+ /** manyToMany only */
32
+ pivotRelatedKey?: string;
33
+ /** morphTo only: maps type discriminator string → model factory */
34
+ morphMap?: Record<string, () => unknown>;
35
+ /** morphMany / morphOne / morphTo: the _type column name (e.g. "commentable_type") */
36
+ morphTypeColumn?: string;
37
+
38
+ // ── has*Through ─────────────────────────────────────────────────────────────
39
+ /** Intermediate ("through") model factory. */
40
+ through?: () => unknown;
41
+ /** FK on the through table pointing back to the parent (e.g. country_id on users). */
42
+ firstKey?: string;
43
+ /** FK on the related table pointing to the through model (e.g. user_id on posts). */
44
+ secondKey?: string;
45
+ /** Local key on the through model the related FK references. Defaults to 'id'. */
46
+ throughLocalKey?: string;
47
+
48
+ // ── manyToMany / morphToMany pivot enrichment ───────────────────────────────
49
+ /** Extra pivot columns to hydrate onto each related model's `pivot` bag. */
50
+ pivotColumns?: string[];
51
+ /** Maintain created_at / updated_at on the pivot table during attach/sync. */
52
+ pivotTimestamps?: boolean;
53
+ /** Constant pivot constraints applied to loads and writes: [column, value][]. */
54
+ pivotWheres?: Array<[string, unknown]>;
55
+ /** morphToMany / morphedByMany: the pivot _type column name. */
56
+ pivotMorphType?: string;
57
+ /** morphToMany / morphedByMany: the _type value stored for this side. */
58
+ pivotMorphValue?: string;
59
+
60
+ // ── belongsTo ───────────────────────────────────────────────────────────────
61
+ /** belongsTo withDefault: true, an attributes object, or a builder callback. */
62
+ withDefault?: boolean | Record<string, unknown> | ((model: unknown) => void);
63
+ }
64
+
65
+ /** Alias for RelationMetadata — preferred name going forward. */
66
+ export type RelationDefinition = RelationMetadata;
67
+
68
+ /**
69
+ * Global registry of relation metadata, keyed by model constructor then by
70
+ * relation (property) name. Populated by the relation decorators at class-definition
71
+ * time and consulted by {@link ModelQueryBuilder} when resolving a relation.
72
+ */
73
+ export const relationRegistry = new Map<Function, Map<string, RelationMetadata>>();
74
+
75
+ // ── Pivot collection ─────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Return type for manyToMany relation properties. Extends Array<T> so every
79
+ * array operation (map, filter, for-of, spread, destructuring) works without
80
+ * casting, while adding fully-typed pivot manipulation methods.
81
+ *
82
+ * On an unloaded model the pivot methods (attach / detach / sync / toggle)
83
+ * still function — they hit the DB directly using the parent's primary key.
84
+ * Array access on an unloaded relation throws RelationNotLoadedError.
85
+ */
86
+ export interface ManyToMany<T> extends Array<T> {
87
+ /** Insert pivot rows linking the parent to the given related id(s). Ignores duplicates. */
88
+ attach(id: number | number[], pivotData?: Record<string, unknown>): Promise<void>;
89
+ /** Remove pivot rows for the given related id(s), or all rows if omitted. */
90
+ detach(id?: number | number[]): Promise<void>;
91
+ /** Replace all pivot rows with exactly the given related ids. */
92
+ sync(ids: number[]): Promise<void>;
93
+ /** Attach ids that are missing; detach ids that already exist. */
94
+ toggle(id: number | number[]): Promise<void>;
95
+ /** Attach ids without detaching existing ones (no duplicates created). */
96
+ syncWithoutDetaching(ids: number[]): Promise<void>;
97
+ /** Update extra pivot columns on an existing pivot row. */
98
+ updateExistingPivot(id: number, pivotData: Record<string, unknown>): Promise<void>;
99
+ }
100
+
101
+ // ── Relation marker types (used as property annotations: `posts!: HasMany<Post>`) ─
102
+
103
+ export declare const __relation__: unique symbol;
104
+
105
+ /**
106
+ * Property annotation for a {@link hasMany} relation. A phantom marker: once the
107
+ * relation is eager-loaded (via `.with()`), {@link WithLoaded} resolves the
108
+ * property to a concrete `T[]`.
109
+ */
110
+ export interface HasMany<T> {
111
+ [__relation__]: "hasMany";
112
+ __type__: T;
113
+ }
114
+
115
+ /**
116
+ * Property annotation for a {@link belongsTo} relation. A phantom marker resolved
117
+ * by {@link WithLoaded} to `T | null` once eager-loaded.
118
+ */
119
+ export interface BelongsTo<T> {
120
+ [__relation__]: "belongsTo";
121
+ __type__: T;
122
+ }
123
+
124
+ /**
125
+ * Property annotation for a {@link hasOne} relation. A phantom marker resolved by
126
+ * {@link WithLoaded} to `T | null` once eager-loaded.
127
+ */
128
+ export interface HasOne<T> {
129
+ [__relation__]: "hasOne";
130
+ __type__: T;
131
+ }
132
+
133
+ /**
134
+ * Property annotation for a {@link morphTo} (inverse polymorphic) relation. A
135
+ * phantom marker resolved by {@link WithLoaded} to `T | null` once eager-loaded.
136
+ */
137
+ export interface MorphTo<T> {
138
+ [__relation__]: "morphTo";
139
+ __type__: T;
140
+ }
141
+
142
+ /**
143
+ * Property annotation for a {@link morphMany} (polymorphic one-to-many) relation.
144
+ * A phantom marker resolved by {@link WithLoaded} to `T[]` once eager-loaded.
145
+ */
146
+ export interface MorphMany<T> {
147
+ [__relation__]: "morphMany";
148
+ __type__: T;
149
+ }
150
+
151
+ /**
152
+ * Property annotation for a {@link morphOne} (polymorphic one-to-one) relation. A
153
+ * phantom marker resolved by {@link WithLoaded} to `T | null` once eager-loaded.
154
+ */
155
+ export interface MorphOne<T> {
156
+ [__relation__]: "morphOne";
157
+ __type__: T;
158
+ }
159
+
160
+ // ── WithLoaded ───────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Narrows the model type M after calling .with(relation).
164
+ * ManyToMany<T> is preserved as-is (keeps pivot methods).
165
+ * Phantom types HasMany/BelongsTo/HasOne are resolved to their concrete forms.
166
+ * Concrete types (e.g. User, Comment[]) are passed through unchanged.
167
+ */
168
+ export type WithLoaded<M, R extends keyof M> = Omit<M, R> & {
169
+ [K in R]: M[K] extends ManyToMany<infer T>
170
+ ? ManyToMany<T>
171
+ : M[K] extends HasMany<infer T>
172
+ ? T[]
173
+ : M[K] extends BelongsTo<infer T>
174
+ ? T | null
175
+ : M[K] extends HasOne<infer T>
176
+ ? T | null
177
+ : M[K] extends MorphTo<infer T>
178
+ ? T | null
179
+ : M[K] extends MorphMany<infer T>
180
+ ? T[]
181
+ : M[K] extends MorphOne<infer T>
182
+ ? T | null
183
+ : M[K];
184
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * ORM → observer bridges. The ORM emits its own framework events (query, N+1,
3
+ * transaction, migration, model-change) on the core `FrameworkEvents` bus; this
4
+ * module forwards them to whichever observer packages are installed.
5
+ *
6
+ * Each observer's write surface is resolved from the container by binding key and
7
+ * typed through a local structural interface, so the ORM depends on none of the
8
+ * observer packages — installing or removing an observer requires no change here.
9
+ * When an observer is not installed its binding is absent and its wiring is skipped.
10
+ */
11
+ import { FrameworkEvents } from "@zerotal/core";
12
+ import type { Application } from "@zerotal/core";
13
+ import {
14
+ QueryExecuted,
15
+ NPlusOneDetected,
16
+ ModelChanged,
17
+ TransactionCommitted,
18
+ TransactionRolledBack,
19
+ MigrationRan,
20
+ } from "./events.ts";
21
+
22
+ /** The subset of the telemetry tracer this bridge calls (bound as `telemetry`). */
23
+ interface TelemetrySink {
24
+ recordCompleted(
25
+ name: string,
26
+ durationMs: number,
27
+ options?: {
28
+ kind?: "internal" | "server" | "client" | "producer" | "consumer";
29
+ attributes?: Record<string, string | number | boolean>;
30
+ status?: "ok" | "error";
31
+ errorMessage?: string;
32
+ },
33
+ ): unknown;
34
+ }
35
+
36
+ /** The subset of the monitor store this bridge calls (bound as `monitor.store`). */
37
+ interface MonitorSink {
38
+ recordQuery(q: { sql: string; ms: number }): void;
39
+ recordEvent(e: {
40
+ kind: string;
41
+ label: string;
42
+ status?: "ok" | "warn" | "bad" | "info";
43
+ route?: string | null;
44
+ data?: Record<string, unknown>;
45
+ }): void;
46
+ bufferQuery(ctx: object, q: { ms: number; sql: string }): void;
47
+ markNPlus(ctx: object): void;
48
+ }
49
+
50
+ /** The subset of the devtools trace sink this bridge calls (bound as `devtools.trace`). */
51
+ interface DevtoolsSink {
52
+ bufferQuery(
53
+ ctx: object,
54
+ q: { sql: string; bindings: unknown[]; startMs: number; durationMs: number; rowCount: number },
55
+ ): void;
56
+ bufferWarning(ctx: object, w: { sql: string; count: number }): void;
57
+ }
58
+
59
+ /** The subset of the logger this bridge calls (bound as `log`). */
60
+ interface LogSink {
61
+ info(message: string, context?: Record<string, unknown>): void;
62
+ warn(message: string, context?: Record<string, unknown>): void;
63
+ error(message: string, context?: Record<string, unknown>, error?: unknown): void;
64
+ }
65
+
66
+ /** Matched-route template (else raw path) from an HttpContext, for event routes. */
67
+ function _ctxPath(ctx: object): string {
68
+ const c = ctx as { _routeDef?: { pattern?: string }; url?: { pathname?: string } };
69
+ return c._routeDef?.pattern ?? c.url?.pathname ?? "/";
70
+ }
71
+
72
+ /**
73
+ * Subscribe the ORM's events to every installed observer. Returns a disposer that
74
+ * removes every subscription; call it from the ORM provider's `onStopping()`.
75
+ */
76
+ export function installOrmObservability(app: Application): () => void {
77
+ const unsubs: Array<() => void> = [];
78
+
79
+ const tracer = app.container.tryMake("telemetry" as never) as TelemetrySink | undefined;
80
+ if (tracer) {
81
+ unsubs.push(
82
+ FrameworkEvents.on(QueryExecuted, (e) => {
83
+ void tracer.recordCompleted("db.query", e.durationMs, {
84
+ kind: "client",
85
+ attributes: { "db.statement": e.sql, "db.rows": e.rowCount },
86
+ });
87
+ }),
88
+ );
89
+ }
90
+
91
+ const store = app.container.tryMake("monitor.store" as never) as MonitorSink | undefined;
92
+ if (store) {
93
+ unsubs.push(
94
+ // Slow-query aggregates + the per-request query buffer, correlated by ctx.
95
+ FrameworkEvents.on(QueryExecuted, (e) => {
96
+ store.recordQuery({ sql: e.sql, ms: e.durationMs });
97
+ if (e.ctx) store.bufferQuery(e.ctx, { ms: Math.round(e.durationMs), sql: e.sql });
98
+ }),
99
+ FrameworkEvents.on(NPlusOneDetected, (e) => {
100
+ if (e.ctx) store.markNPlus(e.ctx);
101
+ store.recordEvent({
102
+ kind: "nplus",
103
+ label: e.fingerprint.replace(/\x00/g, "?"),
104
+ status: "warn",
105
+ route: e.ctx ? _ctxPath(e.ctx) : null,
106
+ data: { count: e.count },
107
+ });
108
+ }),
109
+ FrameworkEvents.on(ModelChanged, (e) =>
110
+ store.recordEvent({
111
+ kind: "model",
112
+ label: e.model,
113
+ status: "info",
114
+ route: e.operation,
115
+ data: { table: e.table, op: e.operation },
116
+ }),
117
+ ),
118
+ FrameworkEvents.on(TransactionCommitted, (e) =>
119
+ store.recordEvent({
120
+ kind: "tx",
121
+ label: "committed",
122
+ status: "ok",
123
+ route: null,
124
+ data: { ms: e.durationMs },
125
+ }),
126
+ ),
127
+ FrameworkEvents.on(TransactionRolledBack, (e) =>
128
+ store.recordEvent({
129
+ kind: "tx",
130
+ label: "rolledback",
131
+ status: "warn",
132
+ route: null,
133
+ data: { ms: e.durationMs, detail: e.reason ?? "" },
134
+ }),
135
+ ),
136
+ FrameworkEvents.on(MigrationRan, (e) =>
137
+ store.recordEvent({
138
+ kind: "migration",
139
+ label: e.name,
140
+ status: e.ok ? "ok" : "bad",
141
+ route: e.direction,
142
+ data: { direction: e.direction, ms: e.durationMs, detail: e.error ?? "" },
143
+ }),
144
+ ),
145
+ );
146
+ }
147
+
148
+ const trace = app.container.tryMake("devtools.trace" as never) as DevtoolsSink | undefined;
149
+ if (trace) {
150
+ unsubs.push(
151
+ // Buffer each query/warning against its request context for the request trace.
152
+ FrameworkEvents.on(QueryExecuted, (e) => {
153
+ if (!e.ctx) return;
154
+ trace.bufferQuery(e.ctx, {
155
+ sql: e.sql,
156
+ bindings: e.bindings,
157
+ startMs: e.startMs,
158
+ durationMs: e.durationMs,
159
+ rowCount: e.rowCount,
160
+ });
161
+ }),
162
+ FrameworkEvents.on(NPlusOneDetected, (e) => {
163
+ if (!e.ctx) return;
164
+ trace.bufferWarning(e.ctx, { sql: e.fingerprint.replace(/\x00/g, "?"), count: e.count });
165
+ }),
166
+ );
167
+ }
168
+
169
+ const log = app.container.tryMake("log" as never) as LogSink | undefined;
170
+ if (log) {
171
+ const cfg = app.container.tryMake("config");
172
+ const slowMs = cfg?.get<number>("logging.slowQueryMs") ?? 1000;
173
+ unsubs.push(
174
+ FrameworkEvents.on(QueryExecuted, (e) => {
175
+ if (e.durationMs >= slowMs) {
176
+ log.warn("Slow query", { sql: e.sql, durationMs: e.durationMs, rowCount: e.rowCount });
177
+ }
178
+ }),
179
+ FrameworkEvents.on(NPlusOneDetected, (e) =>
180
+ log.warn("N+1 query detected", { fingerprint: e.fingerprint, count: e.count }),
181
+ ),
182
+ FrameworkEvents.on(TransactionRolledBack, (e) =>
183
+ log.warn("Transaction rolled back", {
184
+ txId: e.txId,
185
+ durationMs: e.durationMs,
186
+ reason: e.reason,
187
+ }),
188
+ ),
189
+ FrameworkEvents.on(MigrationRan, (e) => {
190
+ if (e.ok) {
191
+ log.info("Migration ran", {
192
+ name: e.name,
193
+ direction: e.direction,
194
+ durationMs: e.durationMs,
195
+ });
196
+ } else {
197
+ log.error(
198
+ "Migration failed",
199
+ { name: e.name, direction: e.direction, durationMs: e.durationMs },
200
+ new Error(e.error),
201
+ );
202
+ }
203
+ }),
204
+ );
205
+ }
206
+
207
+ return () => {
208
+ for (const unsub of unsubs) unsub();
209
+ };
210
+ }
@@ -0,0 +1,266 @@
1
+ import type { SQLInstance } from "../db/sql-types.ts";
2
+ import { ServiceProvider } from "@zerotal/core";
3
+ import type { AppEnvironment } from "@zerotal/core";
4
+ import type { ConfigManager } from "@zerotal/core/config";
5
+ import { SQL } from "bun";
6
+ import { DB, _getConnection } from "../db/DB.ts";
7
+ import { preventNPlusOne } from "../db/NPlusOneDetector.ts";
8
+ import {
9
+ _setBaseModelConnection,
10
+ _setBaseModelDialect,
11
+ _setModelEventDispatcher,
12
+ } from "../model/BaseModel.ts";
13
+ import { setConnectionResolver } from "../db/resolver.ts";
14
+ import { createReadWriteRouter } from "../db/ReadWriteRouter.ts";
15
+ import { ormConcerns } from "../conventions.ts";
16
+ import { validateDatabaseConfig } from "../config.ts";
17
+ import { autoMigrateConcern } from "../schema/autoMigrate.ts";
18
+ import { registerImplicitBinding } from "../implicitBinding.ts";
19
+ import { installOrmObservability } from "../observability.ts";
20
+
21
+ // Extend the core container registry so 'db' is a typed binding.
22
+ declare module "@zerotal/core" {
23
+ interface ContainerBindings {
24
+ db: SQLInstance;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Service provider that wires the ORM into a Zerotal application.
30
+ *
31
+ * @remarks
32
+ * Register this provider to get: the `db` container binding (a `Bun.sql`
33
+ * connection, optionally wrapped in a read/write router when `database.replicas`
34
+ * is set), convention auto-discovery of `app/models` and `app/observers`,
35
+ * auto-migration, implicit route-model binding, N+1 detection outside production,
36
+ * validator `unique`/`exists` rule wiring, and the `migrate` / `make:*` / `db:seed`
37
+ * CLI commands. The connection URL, pool, replicas, and dialect are read from the
38
+ * `database` config namespace (see {@link DatabaseConfig}).
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * // config/app.ts
43
+ * providers: [DatabaseProvider]
44
+ * ```
45
+ */
46
+ export class DatabaseProvider extends ServiceProvider {
47
+ static override provides = ["db"] as const;
48
+ // Use an explicit mutable array type to satisfy ServiceProvider's static property constraint.
49
+ static override environments: AppEnvironment[] = ["web", "console", "test", "repl"];
50
+
51
+ private _disposeObservability: (() => void) | undefined = undefined;
52
+
53
+ override onRegister(): void {
54
+ // Convention-based auto-discovery: app/models, app/observers, and auto-migrate.
55
+ // Optional-chained so bare-container unit tests with a minimal app stub still pass.
56
+ for (const concern of ormConcerns) this.app.registerConcern?.(concern);
57
+ this.app.registerConcern?.(autoMigrateConcern);
58
+
59
+ // Refuse a production boot on a driver/URL mismatch; warn on in-memory
60
+ // stores and no-op synchronize flags. Runs in the boot-time config pass.
61
+ this.app.registerConfigValidator?.("database", validateDatabaseConfig);
62
+
63
+ // Implicit route-model binding: `:user` -> User.findOrFail(value), for every registered
64
+ // model (opt out per model with `static implicitBinding = false`). Resolves lazily at
65
+ // route-compile time, so model registration order doesn't matter.
66
+ registerImplicitBinding();
67
+
68
+ setConnectionResolver(() => {
69
+ try {
70
+ return this.app.container.makeSync("db") as SQLInstance;
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ });
75
+
76
+ this.app.container.singleton("db", async () => {
77
+ const config = (await this.app.container.make("config")) as ConfigManager;
78
+ const rawUrl = config.get<string>("database.url", ":memory:");
79
+ const pool = config.get<{ max?: number; idleTimeout?: number } | undefined>("database.pool");
80
+ const replicaUrls = config.get<string[]>("database.replicas", []);
81
+
82
+ const url = _normaliseSqliteUrl(rawUrl);
83
+ const sqlArg =
84
+ pool?.max !== undefined || pool?.idleTimeout !== undefined ? { url, ...pool } : url;
85
+ const primary = new SQL(sqlArg as unknown as string);
86
+
87
+ if (replicaUrls.length === 0) return primary;
88
+
89
+ // Build read replicas and wrap in a transparent read/write router.
90
+ // SELECT / WITH / EXPLAIN → round-robin replica pool.
91
+ // INSERT / UPDATE / DELETE / DDL / transactions → primary.
92
+ const replicas = replicaUrls.map((ru) => {
93
+ const replicaUrl = _normaliseSqliteUrl(ru);
94
+ const replicaSqlArg =
95
+ pool?.max !== undefined || pool?.idleTimeout !== undefined
96
+ ? { url: replicaUrl, ...pool }
97
+ : replicaUrl;
98
+ return new SQL(replicaSqlArg as unknown as string);
99
+ });
100
+
101
+ return createReadWriteRouter(primary, replicas);
102
+ });
103
+ }
104
+
105
+ override async onBooting(): Promise<void> {
106
+ const config = (await this.app.container.make("config")) as ConfigManager;
107
+ const rawUrl = config.get<string>("database.url", ":memory:");
108
+ _setBaseModelDialect(_detectDialect(rawUrl));
109
+
110
+ const sql = (await this.app.container.make("db")) as SQLInstance;
111
+ _setBaseModelConnection(sql);
112
+ await sql`SELECT 1`;
113
+
114
+ // Bridge model `dispatchesEvents` to the app event bus (no-op if no emitter).
115
+ _setModelEventDispatcher((event) => {
116
+ void this.app.container.tryMake("events")?.emit(event as object);
117
+ });
118
+
119
+ // Wire unique()/exists() validation rules to query through this connection.
120
+ // Lazy-import so @zerotal/validator is an optional peer — apps that don't use
121
+ // the validator still boot without errors.
122
+ try {
123
+ const { registerDbRuleRunner } = await import("@zerotal/validator");
124
+ registerDbRuleRunner(async (rule, table, column, value, options) => {
125
+ const conn = _getConnection();
126
+
127
+ if (rule === "unique") {
128
+ const ignoreId = options.ignoreId;
129
+ let rows: unknown[];
130
+ if (ignoreId !== undefined) {
131
+ const strs = [`SELECT 1 FROM ${table} WHERE ${column} = `, ` AND id != `, ` LIMIT 1`];
132
+ rows = await conn(
133
+ Object.assign(strs, { raw: strs }) as unknown as TemplateStringsArray,
134
+ value,
135
+ ignoreId,
136
+ );
137
+ } else {
138
+ const strs = [`SELECT 1 FROM ${table} WHERE ${column} = `, ` LIMIT 1`];
139
+ rows = await conn(
140
+ Object.assign(strs, { raw: strs }) as unknown as TemplateStringsArray,
141
+ value,
142
+ );
143
+ }
144
+ return rows.length === 0; // true = unique (no duplicate found)
145
+ }
146
+
147
+ // exists
148
+ const strs = [`SELECT 1 FROM ${table} WHERE ${column} = `, ` LIMIT 1`];
149
+ const rows = await conn(
150
+ Object.assign(strs, { raw: strs }) as unknown as TemplateStringsArray,
151
+ value,
152
+ );
153
+ return rows.length > 0; // true = exists (row found)
154
+ });
155
+ } catch {
156
+ // @zerotal/validator not installed — unique()/exists() rules won't work
157
+ }
158
+ }
159
+
160
+ override replContext(): Record<string, unknown> {
161
+ return { DB };
162
+ }
163
+
164
+ override async onStopping(): Promise<void> {
165
+ this._disposeObservability?.();
166
+ this._disposeObservability = undefined;
167
+ try {
168
+ const sql = this.app.container.makeSync("db") as SQLInstance;
169
+ _setBaseModelConnection(null);
170
+ setConnectionResolver(null);
171
+ await sql.end();
172
+ } catch {
173
+ // DB was never initialised — nothing to close
174
+ }
175
+ }
176
+
177
+ override async onBooted(): Promise<void> {
178
+ // Forward the ORM's framework events to whatever observers are installed.
179
+ this._disposeObservability = installOrmObservability(this.app);
180
+
181
+ // N+1 query detection — enabled outside production. Previously activated by
182
+ // the devtools provider; owned here so devtools needs no ORM import.
183
+ const env = Bun.env.APP_ENV ?? "";
184
+ if (env !== "production" && env !== "prod") {
185
+ preventNPlusOne({ threshold: 5, mode: "warn" });
186
+ }
187
+
188
+ const runner = this.app.container.tryMake("commands");
189
+ if (!runner) return;
190
+
191
+ runner.registerLazy(
192
+ "migrate",
193
+ () => import("../commands/MigrateCommand.ts").then((m) => m.MigrateCommand),
194
+ ["db:migrate"],
195
+ );
196
+ runner.registerLazy("migrate:rollback", () =>
197
+ import("../commands/MigrateRollbackCommand.ts").then((m) => m.MigrateRollbackCommand),
198
+ );
199
+ runner.registerLazy("migrate:fresh", () =>
200
+ import("../commands/MigrateFreshCommand.ts").then((m) => m.MigrateFreshCommand),
201
+ );
202
+ runner.registerLazy("migrate:status", () =>
203
+ import("../commands/MigrateStatusCommand.ts").then((m) => m.MigrateStatusCommand),
204
+ );
205
+ runner.registerLazy("make:migration", () =>
206
+ import("../commands/MakeMigrationCommand.ts").then((m) => m.MakeMigrationCommand),
207
+ );
208
+ runner.registerLazy(
209
+ "make:model",
210
+ () => import("../commands/MakeModelCommand.ts").then((m) => m.MakeModelCommand),
211
+ ["make:m"],
212
+ );
213
+ runner.registerLazy("db:seed", () =>
214
+ import("../commands/DbSeedCommand.ts").then((m) => m.DbSeedCommand),
215
+ );
216
+ runner.registerLazy("make:seeder", () =>
217
+ import("../commands/MakeSeederCommand.ts").then((m) => m.MakeSeederCommand),
218
+ );
219
+ runner.registerLazy("make:factory", () =>
220
+ import("../commands/MakeFactoryCommand.ts").then((m) => m.MakeFactoryCommand),
221
+ );
222
+ runner.registerLazy("migrate:generate", () =>
223
+ import("../commands/MigrateGenerateCommand.ts").then((m) => m.MigrateGenerateCommand),
224
+ );
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Normalise a database URL for Bun.sql.
230
+ *
231
+ * Bun v1.3.x on Windows only recognises SQLite when the URL begins with
232
+ * 'sqlite:' or is exactly ':memory:'. Bare file paths are silently treated
233
+ * as PostgreSQL connection strings, so we add the scheme explicitly.
234
+ *
235
+ * Mapping:
236
+ * :memory: → :memory: (Bun accepts it directly)
237
+ * sqlite://… → sqlite:… (collapse double-slash)
238
+ * sqlite:… → sqlite:… (keep)
239
+ * file:… → sqlite:… (rewrite scheme)
240
+ * postgres(ql)://… → unchanged (PostgreSQL pass-through)
241
+ * mysql(2)://… → unchanged (MySQL pass-through)
242
+ * ./path or path → sqlite:./path (add scheme)
243
+ *
244
+ * @internal
245
+ */
246
+ export function _normaliseSqliteUrl(raw: string): string {
247
+ if (!raw || raw === ":memory:") return raw;
248
+ if (raw.startsWith("postgres://")) return raw;
249
+ if (raw.startsWith("postgresql://")) return raw;
250
+ if (raw.startsWith("mysql2://")) return raw;
251
+ if (raw.startsWith("mysql://")) return raw;
252
+ if (raw.startsWith("sqlite://")) return raw.replace("sqlite://", "sqlite:");
253
+ if (raw.startsWith("sqlite:")) return raw;
254
+ if (raw.startsWith("file:")) return "sqlite:" + raw.slice("file:".length);
255
+ return "sqlite:" + raw;
256
+ }
257
+
258
+ /**
259
+ * Infer the ORM dialect from a raw database URL.
260
+ * @internal
261
+ */
262
+ function _detectDialect(raw: string): "sqlite" | "postgres" | "mysql" {
263
+ if (raw.startsWith("postgres://") || raw.startsWith("postgresql://")) return "postgres";
264
+ if (raw.startsWith("mysql://") || raw.startsWith("mysql2://")) return "mysql";
265
+ return "sqlite";
266
+ }