@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,296 @@
1
+ import { pageElements } from "@zerotal/core";
2
+
3
+ /** Operators accepted by {@link QueryBuilder.where} and its variants. */
4
+ export type WhereOperator =
5
+ "=" | "!=" | ">" | ">=" | "<" | "<=" | "like" | "not like" | "in" | "not in";
6
+ /** Sort direction for `ORDER BY`. */
7
+ export type OrderDirection = "asc" | "desc";
8
+
9
+ /** @internal One accumulated WHERE predicate in the builder's query state. */
10
+ export interface WhereClause {
11
+ column: string;
12
+ /** Broad string so the builder can store the standard operators plus
13
+ * internal markers ('is null', '__raw__', 'between', 'column', 'exists', …). */
14
+ operator: string;
15
+ value: unknown;
16
+ boolean: "and" | "or";
17
+ /** Second column name — used by whereColumn(). */
18
+ column2?: string;
19
+ /**
20
+ * Nested predicates, present only when `operator === "__group__"`. Rendered inside
21
+ * parentheses, which is what keeps an `OR` chain from escaping the surrounding `AND`s.
22
+ */
23
+ group?: WhereClause[];
24
+ }
25
+
26
+ /** @internal One accumulated ORDER BY term in the builder's query state. */
27
+ export interface OrderClause {
28
+ column: string;
29
+ /** `"__raw__"` marks a raw `orderByRaw()` expression (column holds the raw SQL). */
30
+ direction: OrderDirection | "__raw__";
31
+ }
32
+
33
+ /** @internal One accumulated HAVING predicate in the builder's query state. */
34
+ export interface HavingClause {
35
+ column: string;
36
+ operator: string;
37
+ value: unknown;
38
+ }
39
+
40
+ /** The kinds of join the builder can emit. */
41
+ export type JoinType = "inner" | "left" | "right" | "cross";
42
+
43
+ /** @internal One accumulated JOIN clause in the builder's query state. */
44
+ export interface JoinClause {
45
+ type: JoinType;
46
+ /** Table expression — a bare table name or `(subquery) AS alias`. */
47
+ table: string;
48
+ first?: string;
49
+ operator?: string;
50
+ second?: string;
51
+ /** Bindings introduced by a subquery join (joinSub). */
52
+ bindings?: unknown[];
53
+ }
54
+
55
+ /** @internal One accumulated UNION arm (a compiled subquery + bindings). */
56
+ export interface UnionClause {
57
+ sql: string;
58
+ bindings: unknown[];
59
+ all: boolean;
60
+ }
61
+
62
+ /** @internal The full mutable state a {@link QueryBuilder} compiles into SQL. */
63
+ export interface QueryState {
64
+ table: string;
65
+ selects: string[];
66
+ distinct: boolean;
67
+ joins: JoinClause[];
68
+ wheres: WhereClause[];
69
+ orders: OrderClause[];
70
+ groupBys: string[];
71
+ havings: HavingClause[];
72
+ unions: UnionClause[];
73
+ limit: number | undefined;
74
+ offset: number | undefined;
75
+ /** Pessimistic-lock suffix, e.g. 'FOR UPDATE' / 'FOR SHARE'. */
76
+ lock: string | undefined;
77
+ }
78
+
79
+ /** Standard pagination metadata block. */
80
+ export interface PaginateMeta {
81
+ /** Index (1-based) of the first item on the current page, or null when empty. */
82
+ from: number | null;
83
+ /** Index (1-based) of the last item on the current page, or null when empty. */
84
+ to: number | null;
85
+ /** Current page number. */
86
+ currentPage: number;
87
+ /** Last (highest) page number. */
88
+ lastPage: number;
89
+ /** Items per page. */
90
+ perPage: number;
91
+ /** Total number of matching rows. */
92
+ total: number;
93
+ /** Base URL used for link generation. */
94
+ path: string;
95
+ }
96
+
97
+ /**
98
+ * Result of {@link QueryBuilder.paginate} — a full offset paginator with total
99
+ * row count, page metadata and URL helpers.
100
+ */
101
+ export interface PaginateResult<T = Record<string, unknown>> {
102
+ data: T[];
103
+ total: number;
104
+ page: number;
105
+ perPage: number;
106
+ lastPage: number;
107
+ /** Index (1-based) of the first item on this page, or null when the page is empty. */
108
+ from: number | null;
109
+ /** Index (1-based) of the last item on this page, or null when the page is empty. */
110
+ to: number | null;
111
+ /** Standard metadata block (from/to/path/total/…) for driving UI components. */
112
+ meta: PaginateMeta;
113
+ /**
114
+ * URL for the next page, or null on the last page.
115
+ * Extra query params (e.g. filters) are merged and preserved.
116
+ * @example result.nextPageUrl('/posts', { search: 'bun' })
117
+ */
118
+ nextPageUrl(baseUrl?: string, query?: Record<string, string>): string | null;
119
+ /**
120
+ * URL for the previous page, or null on the first page.
121
+ * Extra query params are merged and preserved.
122
+ */
123
+ previousPageUrl(baseUrl?: string, query?: Record<string, string>): string | null;
124
+ /**
125
+ * Build the URL for any page number (relative to `baseUrl`, default the
126
+ * paginator's configured `path`).
127
+ * @example result.url(3) // '/posts?page=3'
128
+ */
129
+ url(page: number, baseUrl?: string, query?: Record<string, string>): string;
130
+ /** True when there is at least one more page after the current one. */
131
+ hasMorePages: boolean;
132
+ /** True when the current page is the first page. */
133
+ onFirstPage: boolean;
134
+ /** True when the current page is the last page. */
135
+ onLastPage: boolean;
136
+ /**
137
+ * Page-number window for a numbered pager, with `"..."` gaps —
138
+ * e.g. `[1, "...", 4, 5, 6, "...", 20]`.
139
+ * @param each - Page links on each side of the current page (default `1`).
140
+ */
141
+ elements(each?: number): (number | "...")[];
142
+ /**
143
+ * Array of page links — useful for rendering pagination UI.
144
+ * Extra query params are merged onto every link URL.
145
+ * @example result.links('/posts', { search: 'bun' })
146
+ */
147
+ links(
148
+ baseUrl?: string,
149
+ query?: Record<string, string>,
150
+ ): Array<{ page: number; url: string; active: boolean }>;
151
+ }
152
+
153
+ /** Build a query-string from a page number plus optional extra params. */
154
+ function _pageUrl(baseUrl: string, page: number, extra: Record<string, string> = {}): string {
155
+ const params = new URLSearchParams({ ...extra, page: String(page) });
156
+ return `${baseUrl}?${params.toString()}`;
157
+ }
158
+
159
+ /** Options accepted by the pagination helper factories. */
160
+ export interface PaginationHelperOptions {
161
+ /** Base path used by no-argument URL helpers and `meta.path`. Default: `''`. */
162
+ path?: string;
163
+ }
164
+
165
+ /** Attach URL helper methods + metadata to a plain paginate result object. */
166
+ export function withPaginationHelpers<T>(
167
+ raw: {
168
+ data: T[];
169
+ total: number;
170
+ page: number;
171
+ perPage: number;
172
+ lastPage: number;
173
+ },
174
+ options: PaginationHelperOptions = {},
175
+ ): PaginateResult<T> {
176
+ const r = raw as PaginateResult<T>;
177
+ const path = options.path ?? "";
178
+
179
+ const from = raw.total === 0 ? null : (raw.page - 1) * raw.perPage + 1;
180
+ const to = raw.total === 0 || raw.data.length === 0 ? null : (from ?? 0) + raw.data.length - 1;
181
+
182
+ r.from = from;
183
+ r.to = to;
184
+ r.meta = {
185
+ from,
186
+ to,
187
+ currentPage: raw.page,
188
+ lastPage: raw.lastPage,
189
+ perPage: raw.perPage,
190
+ total: raw.total,
191
+ path,
192
+ };
193
+
194
+ r.url = (page, baseUrl = path, query = {}) => _pageUrl(baseUrl, page, query);
195
+ r.nextPageUrl = (baseUrl = path, query = {}) =>
196
+ raw.page < raw.lastPage ? _pageUrl(baseUrl, raw.page + 1, query) : null;
197
+ r.previousPageUrl = (baseUrl = path, query = {}) =>
198
+ raw.page > 1 ? _pageUrl(baseUrl, raw.page - 1, query) : null;
199
+ r.hasMorePages = raw.page < raw.lastPage;
200
+ r.onFirstPage = raw.page <= 1;
201
+ r.onLastPage = raw.page >= raw.lastPage;
202
+ r.elements = (each = 1) => pageElements(raw.page, raw.lastPage, each);
203
+ r.links = (baseUrl = path, query = {}) =>
204
+ Array.from({ length: raw.lastPage }, (_, i) => ({
205
+ page: i + 1,
206
+ url: _pageUrl(baseUrl, i + 1, query),
207
+ active: i + 1 === raw.page,
208
+ }));
209
+ return r;
210
+ }
211
+
212
+ /**
213
+ * Result of {@link QueryBuilder.simplePaginate} — "next/prev only" pagination
214
+ * that skips the expensive `COUNT(*)` query. There is no `total` or `lastPage`.
215
+ */
216
+ export interface SimplePaginateResult<T = Record<string, unknown>> {
217
+ data: T[];
218
+ perPage: number;
219
+ page: number;
220
+ /** Index (1-based) of the first item on this page, or null when empty. */
221
+ from: number | null;
222
+ /** Index (1-based) of the last item on this page, or null when empty. */
223
+ to: number | null;
224
+ /** True when another page follows (a `perPage + 1` probe row was found). */
225
+ hasMorePages: boolean;
226
+ /** True when the current page is the first page. */
227
+ onFirstPage: boolean;
228
+ nextPageUrl(baseUrl?: string, query?: Record<string, string>): string | null;
229
+ previousPageUrl(baseUrl?: string, query?: Record<string, string>): string | null;
230
+ url(page: number, baseUrl?: string, query?: Record<string, string>): string;
231
+ }
232
+
233
+ /** Attach URL helpers + metadata to a simple-paginate result object. */
234
+ export function withSimplePaginationHelpers<T>(
235
+ raw: {
236
+ data: T[];
237
+ perPage: number;
238
+ page: number;
239
+ hasMore: boolean;
240
+ },
241
+ options: PaginationHelperOptions = {},
242
+ ): SimplePaginateResult<T> {
243
+ const { hasMore, ...rest } = raw;
244
+ const r = rest as unknown as SimplePaginateResult<T>;
245
+ const path = options.path ?? "";
246
+
247
+ const from = raw.data.length === 0 ? null : (raw.page - 1) * raw.perPage + 1;
248
+ const to = raw.data.length === 0 ? null : (from ?? 0) + raw.data.length - 1;
249
+ r.from = from;
250
+ r.to = to;
251
+
252
+ r.url = (page, baseUrl = path, query = {}) => _pageUrl(baseUrl, page, query);
253
+ r.hasMorePages = hasMore;
254
+ r.onFirstPage = raw.page <= 1;
255
+ r.nextPageUrl = (baseUrl = path, query = {}) =>
256
+ hasMore ? _pageUrl(baseUrl, raw.page + 1, query) : null;
257
+ r.previousPageUrl = (baseUrl = path, query = {}) =>
258
+ raw.page > 1 ? _pageUrl(baseUrl, raw.page - 1, query) : null;
259
+ return r;
260
+ }
261
+
262
+ /** Result of {@link QueryBuilder.cursorPaginate} — id-based cursor pagination. */
263
+ export interface CursorPaginateResult<T = Record<string, unknown>> {
264
+ data: T[];
265
+ /** Cursor pointing past the last item, or null on the final page. */
266
+ nextCursor: number | null;
267
+ /** Cursor for the page that preceded this one, or null on the first page. */
268
+ prevCursor: number | null;
269
+ /** True when another page follows (equivalent to `nextCursor !== null`). */
270
+ hasMore: boolean;
271
+ }
272
+
273
+ /** Options for {@link QueryBuilder.keysetPaginate}. */
274
+ export interface KeysetOptions {
275
+ /**
276
+ * Opaque cursor string from the previous page's `nextCursor`.
277
+ * `null` or omitted = first page.
278
+ */
279
+ cursor?: string | null;
280
+ /** Column used for keyset ordering. Must be a safe SQL identifier. Default: `'id'`. */
281
+ column?: string;
282
+ /** Sort direction. Default: `'asc'`. */
283
+ direction?: "asc" | "desc";
284
+ /** Items per page. Default: `15`. */
285
+ limit?: number;
286
+ }
287
+
288
+ /** Result of {@link QueryBuilder.keysetPaginate} — opaque-cursor keyset pagination. */
289
+ export interface KeysetPaginateResult<T = Record<string, unknown>> {
290
+ data: T[];
291
+ /**
292
+ * Opaque base64 cursor pointing past the last item, or `null` on the final page.
293
+ * Pass this directly to the next `keysetPaginate({ cursor })` call.
294
+ */
295
+ nextCursor: string | null;
296
+ }
@@ -0,0 +1,25 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown when `fill()` / `create()` receives an attribute that the model's
5
+ * mass-assignment rules do not allow.
6
+ *
7
+ * Zerotal guards by default: a model that declares neither `fillable` nor
8
+ * `guarded` (and has not opted into `static unguarded = true`) rejects every
9
+ * mass-assigned attribute, so a stray `role`/`is_admin`/`id` in a request body
10
+ * can never reach the database. Declare `fillable` to allow specific columns,
11
+ * or use `forceFill()` / `forceCreate()` for trusted, framework-internal writes.
12
+ */
13
+ export class MassAssignmentError extends ZerotalError {
14
+ constructor(model: string, attribute: string) {
15
+ super(
16
+ `[Zerotal ORM] "${attribute}" is not mass-assignable on ${model}. ` +
17
+ `Add it to \`static fillable\`, or use forceFill()/forceCreate() for trusted data. ` +
18
+ `Models guard all attributes by default — set \`static unguarded = true\` to opt out.`,
19
+ "E_MASS_ASSIGNMENT",
20
+ 422,
21
+ { model, attribute },
22
+ );
23
+ this.name = "MassAssignmentError";
24
+ }
25
+ }
@@ -0,0 +1,18 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown when a migration's `up`/`down` throws, wrapping the underlying error with
5
+ * the failing migration's name for context.
6
+ *
7
+ * @param migrationName - The migration that failed.
8
+ * @param cause - The underlying error thrown by the migration.
9
+ */
10
+ export class MigrationError extends ZerotalError {
11
+ constructor(migrationName: string, cause: Error) {
12
+ super(`Migration '${migrationName}' failed: ${cause.message}`, "E_MIGRATION_FAILED", 500, {
13
+ migration: migrationName,
14
+ });
15
+ // Preserve the original error so its stack and message survive re-throwing.
16
+ this.cause = cause;
17
+ }
18
+ }
@@ -0,0 +1,21 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown when a query that requires a result finds none — e.g. `findOrFail()`,
5
+ * `firstOrFail()`, or implicit route-model binding. Carries an HTTP 404 status.
6
+ *
7
+ * @param model - The model name for the message.
8
+ * @param id - The looked-up id, when the failure was a by-id lookup.
9
+ */
10
+ export class ModelNotFoundError extends ZerotalError {
11
+ constructor(model: string, id?: number | string) {
12
+ super(
13
+ id === undefined
14
+ ? `No ${model} record found for the given query`
15
+ : `No ${model} record found for ID: ${String(id)}`,
16
+ "E_MODEL_NOT_FOUND",
17
+ 404,
18
+ id === undefined ? { model } : { model, id },
19
+ );
20
+ }
21
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Re-export of the error thrown by the N+1 query detector when a relation is
3
+ * lazily loaded more times than the configured threshold in `mode: "throw"`.
4
+ * Defined in `../db/NPlusOneDetector.ts`.
5
+ */
6
+ export { NPlusOneError } from "../db/NPlusOneDetector.ts";
@@ -0,0 +1,19 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown when a relation is accessed on a model that was not eager-loaded with
5
+ * `.with('relation')`, guarding against accidental lazy loads / N+1 queries.
6
+ *
7
+ * @param relation - The relation name that was accessed.
8
+ * @param model - The model the relation was accessed on.
9
+ */
10
+ export class RelationNotLoadedError extends ZerotalError {
11
+ constructor(relation: string, model: string) {
12
+ super(
13
+ `Relation "${relation}" was accessed on ${model} without eager loading. Use .with('${relation}') in your query.`,
14
+ "E_RELATION_NOT_LOADED",
15
+ 500,
16
+ { relation, model },
17
+ );
18
+ }
19
+ }
@@ -0,0 +1,18 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown when `model.transitionTo()` is called with a state that is not
5
+ * reachable from the model's current state, or when a target-state guard
6
+ * rejects the transition.
7
+ */
8
+ export class StateError extends ZerotalError {
9
+ constructor(model: string, from: string, to: string, detail?: string) {
10
+ super(
11
+ detail ?? `Illegal transition on ${model}: cannot move from '${from}' to '${to}'.`,
12
+ "E_INVALID_TRANSITION",
13
+ 422,
14
+ { model, from, to },
15
+ );
16
+ this.name = "StateError";
17
+ }
18
+ }
@@ -0,0 +1,13 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown when a database transaction cannot proceed — e.g. committing or rolling
5
+ * back outside an active transaction, or nesting/savepoint misuse.
6
+ *
7
+ * @param message - Human-readable description of the transaction failure.
8
+ */
9
+ export class TransactionError extends ZerotalError {
10
+ constructor(message: string) {
11
+ super(message, "E_TRANSACTION_ERROR", 500);
12
+ }
13
+ }
@@ -0,0 +1,18 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /**
4
+ * Thrown when a feature is invoked on a database dialect that cannot support
5
+ * it — e.g. `DB.advisoryLock()` on SQLite, which has no advisory-lock
6
+ * primitive. Failing loudly beats silently emitting another engine's SQL.
7
+ */
8
+ export class UnsupportedDialectError extends ZerotalError {
9
+ constructor(feature: string, dialect: string) {
10
+ super(
11
+ `${feature} is not supported on the "${dialect}" dialect.`,
12
+ "E_UNSUPPORTED_DIALECT",
13
+ 500,
14
+ { feature, dialect },
15
+ );
16
+ this.name = "UnsupportedDialectError";
17
+ }
18
+ }
@@ -0,0 +1,7 @@
1
+ export { ModelNotFoundError } from "./ModelNotFoundError.ts";
2
+ export { RelationNotLoadedError } from "./RelationNotLoadedError.ts";
3
+ export { TransactionError } from "./TransactionError.ts";
4
+ export { MigrationError } from "./MigrationError.ts";
5
+ export { StateError } from "./StateError.ts";
6
+ export { MassAssignmentError } from "./MassAssignmentError.ts";
7
+ export { UnsupportedDialectError } from "./UnsupportedDialectError.ts";
package/src/events.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The ORM's framework-event vocabulary. These are emitted on core's synchronous
3
+ * {@link FrameworkEvents} bus by the ORM's query, transaction, migration, and
4
+ * model-lifecycle machinery; observability packages subscribe to them by kind
5
+ * (their class name) without importing the classes.
6
+ *
7
+ * `ctx` is typed as `object` (the request `HttpContext` when inside a request)
8
+ * so these events carry no dependency back into the kernel or the pipeline.
9
+ */
10
+
11
+ /**
12
+ * Emitted after every SQL query completes — carries the SQL, its bindings,
13
+ * timing, row count, and the active request context when inside one. Powers the
14
+ * query log, slow-query warnings, and the N+1 detector.
15
+ *
16
+ * @category Database
17
+ */
18
+ export class QueryExecuted {
19
+ constructor(
20
+ readonly sql: string,
21
+ readonly bindings: unknown[],
22
+ readonly startMs: number,
23
+ readonly durationMs: number,
24
+ readonly rowCount: number,
25
+ /** Active HttpContext object, or undefined when outside a request. */
26
+ readonly ctx: object | undefined,
27
+ ) {}
28
+ }
29
+
30
+ /**
31
+ * Emitted when the N+1 detector fires for a repeated query pattern (the same
32
+ * SQL fingerprint run `count` times within one request).
33
+ *
34
+ * @category Database
35
+ */
36
+ export class NPlusOneDetected {
37
+ constructor(
38
+ /** Normalised SQL fingerprint (parameter placeholders replaced with \x00). */
39
+ readonly fingerprint: string,
40
+ readonly count: number,
41
+ readonly ctx: object | undefined,
42
+ ) {}
43
+ }
44
+
45
+ /**
46
+ * Emitted when a database transaction begins.
47
+ * @category Database
48
+ */
49
+ export class TransactionStarted {
50
+ constructor(
51
+ readonly txId: string,
52
+ readonly ctx: object | undefined,
53
+ ) {}
54
+ }
55
+
56
+ /**
57
+ * Emitted when a database transaction commits successfully.
58
+ * @category Database
59
+ */
60
+ export class TransactionCommitted {
61
+ constructor(
62
+ readonly txId: string,
63
+ readonly durationMs: number,
64
+ readonly ctx: object | undefined,
65
+ ) {}
66
+ }
67
+
68
+ /**
69
+ * Emitted when a database transaction rolls back. `reason` is set when the
70
+ * rollback was triggered by a caught error.
71
+ * @category Database
72
+ */
73
+ export class TransactionRolledBack {
74
+ constructor(
75
+ readonly txId: string,
76
+ readonly durationMs: number,
77
+ readonly reason: string | undefined,
78
+ readonly ctx: object | undefined,
79
+ ) {}
80
+ }
81
+
82
+ /**
83
+ * Emitted after a single migration runs up or down; `ok` is false and `error`
84
+ * is set when it failed.
85
+ * @category Database
86
+ */
87
+ export class MigrationRan {
88
+ constructor(
89
+ readonly name: string,
90
+ readonly direction: "up" | "down",
91
+ readonly durationMs: number,
92
+ readonly ok: boolean,
93
+ readonly error?: string,
94
+ ) {}
95
+ }
96
+
97
+ /**
98
+ * Emitted after a model row is created, updated, or deleted. The panel aggregates
99
+ * these into per-model change counts. Suppressed during factory seeding (it rides
100
+ * the hook system, which mutes there).
101
+ *
102
+ * @category Database
103
+ */
104
+ export class ModelChanged {
105
+ constructor(
106
+ /** Model class name, e.g. "User". */
107
+ readonly model: string,
108
+ /** Backing table, e.g. "users". */
109
+ readonly table: string,
110
+ readonly operation: "created" | "updated" | "deleted",
111
+ ) {}
112
+ }
@@ -0,0 +1,17 @@
1
+ // Ambient declarations specific to this package.
2
+ // Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
3
+ // Only declarations bun-types does NOT provide are kept here.
4
+
5
+ // Bun extends Request with native route params (e.g. /users/:id → { id: '42' })
6
+ interface Request {
7
+ readonly params?: Record<string, string>;
8
+ }
9
+
10
+ // Ambient mirror of the exported SQLInstance (see ./db/sql-types.ts) so test files
11
+ // that reference it without an import still resolve. Source files import the exported
12
+ // type, which shadows this within those modules.
13
+ interface SQLInstance {
14
+ <T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
15
+ begin<T>(fn: (tx: SQLInstance) => Promise<T>): Promise<T>;
16
+ end(): Promise<void>;
17
+ }
@@ -0,0 +1,73 @@
1
+ import { setImplicitModelResolver, Str, singularize } from "@zerotal/core";
2
+ import type { ModelBindingResolver, HttpContext } from "@zerotal/core";
3
+ import { modelsByName } from "./model/decorators/_metadata.ts";
4
+
5
+ interface ImplicitModel {
6
+ name: string;
7
+ implicitBinding?: boolean;
8
+ implicitBindingKey?: string;
9
+ findOrFail(id: number | string): Promise<unknown>;
10
+ resolveRouteBinding?(value: string, ctx: HttpContext, param: string): Promise<unknown>;
11
+ }
12
+
13
+ /**
14
+ * Find the registered model that should resolve a given route param, or undefined.
15
+ *
16
+ * Resolution order:
17
+ * 1. A model whose `static implicitBindingKey` equals the param name.
18
+ * 2. Class-name convention: `:user` -> `User`, `:blogPost` -> `BlogPost`, `:users` -> `User`.
19
+ * Models with `static implicitBinding = false` are skipped.
20
+ *
21
+ * @param paramName - The route parameter name (without the leading colon).
22
+ * @returns The matching model class, or `undefined` if none resolves.
23
+ * @internal
24
+ */
25
+ export function modelForParam(paramName: string): ImplicitModel | undefined {
26
+ // 1. Explicit implicitBindingKey wins.
27
+ for (const ctor of modelsByName.values()) {
28
+ const M = ctor as unknown as ImplicitModel;
29
+ if (M.implicitBinding === false) continue;
30
+ if (M.implicitBindingKey && M.implicitBindingKey === paramName) return M;
31
+ }
32
+ // 2. Class-name convention.
33
+ // A model that set an explicit implicitBindingKey claims only that key (handled above),
34
+ // so it no longer answers to its class name here.
35
+ for (const candidate of [Str.pascalCase(paramName), Str.pascalCase(singularize(paramName))]) {
36
+ const ctor = modelsByName.get(candidate);
37
+ if (!ctor) continue;
38
+ const M = ctor as unknown as ImplicitModel;
39
+ if (M.implicitBinding === false) continue;
40
+ if (M.implicitBindingKey) continue;
41
+ return M;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ /**
47
+ * Install the implicit route-model-binding resolver into the core Router. Called by
48
+ * `DatabaseProvider.onRegister()`. The resolver reads the model registry lazily (at route
49
+ * compile time), so it works no matter when models register during boot.
50
+ * @internal
51
+ */
52
+ /**
53
+ * Build the binding resolver for a route param, or undefined when no model claims it.
54
+ *
55
+ * A model may own its lookup by declaring `static resolveRouteBinding` — resolving by a slug
56
+ * or username, scoping to the tenant, eager-loading a relation. It receives the param name,
57
+ * so one model can answer differently for `:user` and `:username` without matching on the
58
+ * URL. Without it, the default is a primary-key `findOrFail`.
59
+ *
60
+ * @internal
61
+ */
62
+ export function resolverForParam(paramName: string): ModelBindingResolver | undefined {
63
+ const M = modelForParam(paramName);
64
+ if (!M) return undefined;
65
+ return (value, ctx) =>
66
+ typeof M.resolveRouteBinding === "function"
67
+ ? M.resolveRouteBinding(value, ctx, paramName)
68
+ : M.findOrFail(value);
69
+ }
70
+
71
+ export function registerImplicitBinding(): void {
72
+ setImplicitModelResolver(resolverForParam);
73
+ }