@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,40 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+ import type { HasManyThroughOptions } from "./hasManyThrough.ts";
3
+
4
+ /**
5
+ * Declare a has-one-through relation: reach a single related record across one
6
+ * intermediate ("through") model (the one-to-one counterpart of
7
+ * {@link hasManyThrough}).
8
+ *
9
+ * @remarks Not supported by {@link has} / {@link whereHas}; use eager
10
+ * {@link ModelQueryBuilder.with | with()} to load it.
11
+ *
12
+ * @param related - Lazy factory returning the far/related model class.
13
+ * @param through - Lazy factory returning the intermediate model class.
14
+ * @param options - First/second key configuration across the two hops.
15
+ * @category Relationships
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * class Country extends BaseModel {
20
+ * \@hasOneThrough(() => Post, () => User, { firstKey: 'country_id', secondKey: 'user_id' })
21
+ * latestPost!: HasOne<Post>;
22
+ * }
23
+ * ```
24
+ */
25
+ export function hasOneThrough(
26
+ related: () => unknown,
27
+ through: () => unknown,
28
+ options: HasManyThroughOptions,
29
+ ) {
30
+ return makeRelationDecorator(() => ({
31
+ type: "hasOneThrough" as const,
32
+ related,
33
+ through,
34
+ firstKey: options.firstKey,
35
+ secondKey: options.secondKey,
36
+ foreignKey: options.secondKey,
37
+ localKey: options.localKey ?? "id",
38
+ throughLocalKey: options.throughLocalKey ?? "id",
39
+ }));
40
+ }
@@ -0,0 +1,55 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ /** Options for {@link manyToMany}. */
4
+ export interface ManyToManyOptions {
5
+ /** Name of the pivot (join) table (e.g. `role_user`). */
6
+ pivotTable: string;
7
+ /** Pivot column referencing this (parent) model (e.g. `user_id`). */
8
+ pivotForeignKey: string;
9
+ /** Pivot column referencing the related model (e.g. `role_id`). */
10
+ pivotRelatedKey: string;
11
+ /** Parent local key the pivot FK references. Defaults to `'id'`. */
12
+ localKey?: string;
13
+ /** Related local key the pivot relatedKey references. Defaults to `'id'`. */
14
+ relatedKey?: string;
15
+ /** Extra pivot columns to hydrate onto each related model's `pivot` bag. */
16
+ withPivot?: string[];
17
+ /** Maintain created_at / updated_at on the pivot table during attach/sync. */
18
+ withTimestamps?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Declare a many-to-many relation through a pivot table (e.g. `User` ↔ `Role`
23
+ * via `role_user`). The relation property is a {@link ManyToMany} collection that
24
+ * behaves as an array yet also exposes pivot methods (`attach` / `detach` /
25
+ * `sync` / `toggle`).
26
+ *
27
+ * @param related - Lazy factory returning the related model class.
28
+ * @param options - Pivot table and key configuration.
29
+ * @category Relationships
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * class User extends BaseModel {
34
+ * \@manyToMany(() => Role, {
35
+ * pivotTable: 'role_user',
36
+ * pivotForeignKey: 'user_id',
37
+ * pivotRelatedKey: 'role_id',
38
+ * })
39
+ * roles!: ManyToMany<Role>;
40
+ * }
41
+ * ```
42
+ */
43
+ export function manyToMany(related: () => unknown, options: ManyToManyOptions) {
44
+ return makeRelationDecorator(() => ({
45
+ type: "manyToMany" as const,
46
+ related,
47
+ foreignKey: options.pivotForeignKey,
48
+ localKey: options.localKey ?? "id",
49
+ pivotTable: options.pivotTable,
50
+ pivotForeignKey: options.pivotForeignKey,
51
+ pivotRelatedKey: options.pivotRelatedKey,
52
+ ...(options.withPivot ? { pivotColumns: options.withPivot } : {}),
53
+ ...(options.withTimestamps ? { pivotTimestamps: options.withTimestamps } : {}),
54
+ }));
55
+ }
@@ -0,0 +1,38 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ export interface MorphManyOptions {
4
+ /**
5
+ * The morph name — prefix for the `_type` and `_id` columns on the related table.
6
+ * e.g. `morphName: 'commentable'` → columns `commentable_type`, `commentable_id`.
7
+ */
8
+ morphName: string;
9
+ /** Override the local key. Defaults to 'id'. */
10
+ localKey?: string;
11
+ }
12
+
13
+ /**
14
+ * Declare the "one" side of a polymorphic one-to-many: the parent owns many
15
+ * related rows that reference it via `{morphName}_id` / `{morphName}_type`
16
+ * columns (e.g. a `Post` has many `Comment`s through a `commentable` morph).
17
+ *
18
+ * @param related - Lazy factory returning the related model class.
19
+ * @param options - The morph name and optional local key.
20
+ * @category Relationships
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * class Post extends BaseModel {
25
+ * \@morphMany(() => Comment, { morphName: 'commentable' })
26
+ * comments!: MorphMany<Comment>;
27
+ * }
28
+ * ```
29
+ */
30
+ export function morphMany(related: () => unknown, options: MorphManyOptions) {
31
+ return makeRelationDecorator(() => ({
32
+ type: "morphMany" as const,
33
+ related,
34
+ foreignKey: `${options.morphName}_id`,
35
+ localKey: options.localKey ?? "id",
36
+ morphTypeColumn: `${options.morphName}_type`,
37
+ }));
38
+ }
@@ -0,0 +1,38 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ export interface MorphOneOptions {
4
+ /**
5
+ * The morph name — prefix for the `_type` and `_id` columns on the related table.
6
+ * e.g. `morphName: 'imageable'` → columns `imageable_type`, `imageable_id`.
7
+ */
8
+ morphName: string;
9
+ /** Override the local key. Defaults to 'id'. */
10
+ localKey?: string;
11
+ }
12
+
13
+ /**
14
+ * Declare the "one" side of a polymorphic one-to-one: the parent owns a single
15
+ * related row that references it via `{morphName}_id` / `{morphName}_type`
16
+ * columns (e.g. a `User` has one `Image` through an `imageable` morph).
17
+ *
18
+ * @param related - Lazy factory returning the related model class.
19
+ * @param options - The morph name and optional local key.
20
+ * @category Relationships
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * class User extends BaseModel {
25
+ * \@morphOne(() => Image, { morphName: 'imageable' })
26
+ * avatar!: MorphOne<Image>;
27
+ * }
28
+ * ```
29
+ */
30
+ export function morphOne(related: () => unknown, options: MorphOneOptions) {
31
+ return makeRelationDecorator(() => ({
32
+ type: "morphOne" as const,
33
+ related,
34
+ foreignKey: `${options.morphName}_id`,
35
+ localKey: options.localKey ?? "id",
36
+ morphTypeColumn: `${options.morphName}_type`,
37
+ }));
38
+ }
@@ -0,0 +1,51 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ export interface MorphToOptions {
4
+ /** Maps the discriminator string stored in the _type column to the model factory. */
5
+ morphMap: Record<string, () => unknown>;
6
+ /**
7
+ * Override the _type column name. Defaults to `<propertyName>_type`.
8
+ * e.g. property `commentable` → `commentable_type`.
9
+ */
10
+ morphTypeColumn?: string;
11
+ /**
12
+ * Override the _id column name. Defaults to `<propertyName>_id`.
13
+ * e.g. property `commentable` → `commentable_id`.
14
+ */
15
+ morphForeignKey?: string;
16
+ }
17
+
18
+ function _toSnake(s: string): string {
19
+ return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
20
+ }
21
+
22
+ /**
23
+ * Declare the inverse side of a polymorphic relation: this model belongs to one
24
+ * of several possible parent types, resolved at load time from the `_type`
25
+ * discriminator column via the {@link MorphToOptions.morphMap | morphMap}
26
+ * (e.g. a `Comment` belongs to a `Post` or `Video` through `commentable`).
27
+ *
28
+ * @param options - The type-to-model map plus optional column-name overrides.
29
+ * @category Relationships
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * class Comment extends BaseModel {
34
+ * \@morphTo({ morphMap: { Post: () => Post, Video: () => Video } })
35
+ * commentable!: MorphTo<Post | Video>;
36
+ * }
37
+ * ```
38
+ */
39
+ export function morphTo(options: MorphToOptions) {
40
+ return makeRelationDecorator((_ctor, field) => {
41
+ const snake = _toSnake(field);
42
+ return {
43
+ type: "morphTo" as const,
44
+ related: () => ({}),
45
+ foreignKey: options.morphForeignKey ?? `${snake}_id`,
46
+ localKey: "id",
47
+ morphMap: options.morphMap,
48
+ morphTypeColumn: options.morphTypeColumn ?? `${snake}_type`,
49
+ };
50
+ });
51
+ }
@@ -0,0 +1,49 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ export interface MorphToManyOptions {
4
+ /** Morph name — prefix for the pivot `{name}_id` / `{name}_type` columns (e.g. 'taggable'). */
5
+ morphName: string;
6
+ /** FK on the pivot table referencing the related model (e.g. 'tag_id'). */
7
+ relatedPivotKey: string;
8
+ /** Pivot table name. Defaults to `{morphName}s`. */
9
+ pivotTable?: string;
10
+ /** Extra pivot columns to hydrate. */
11
+ withPivot?: string[];
12
+ /** Maintain pivot timestamps. */
13
+ withTimestamps?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Declare a polymorphic many-to-many from the "owning" side: several parent types
18
+ * share one pivot table that carries a `_type` discriminator, linking each to the
19
+ * related model (e.g. `Post` / `Video` —(taggables)→ `Tag`). The property is a
20
+ * {@link ManyToMany} collection with pivot methods.
21
+ *
22
+ * @param related - Lazy factory returning the related model class.
23
+ * @param options - Morph name, related pivot key, and optional pivot config.
24
+ * @category Relationships
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * class Post extends BaseModel {
29
+ * \@morphToMany(() => Tag, { morphName: 'taggable', relatedPivotKey: 'tag_id' })
30
+ * tags!: ManyToMany<Tag>;
31
+ * }
32
+ * ```
33
+ */
34
+ export function morphToMany(related: () => unknown, options: MorphToManyOptions) {
35
+ const pivotTable = options.pivotTable ?? `${options.morphName}s`;
36
+ return makeRelationDecorator((ctor) => ({
37
+ type: "morphToMany" as const,
38
+ related,
39
+ pivotTable,
40
+ pivotForeignKey: `${options.morphName}_id`,
41
+ pivotRelatedKey: options.relatedPivotKey,
42
+ pivotMorphType: `${options.morphName}_type`,
43
+ pivotMorphValue: ctor.name,
44
+ foreignKey: `${options.morphName}_id`,
45
+ localKey: "id",
46
+ ...(options.withPivot ? { pivotColumns: options.withPivot } : {}),
47
+ ...(options.withTimestamps ? { pivotTimestamps: options.withTimestamps } : {}),
48
+ }));
49
+ }
@@ -0,0 +1,46 @@
1
+ import { makeRelationDecorator } from "./_registerRelation.ts";
2
+
3
+ export interface MorphedByManyOptions {
4
+ /** Morph name — the pivot `{name}_id` / `{name}_type` columns describe the related side. */
5
+ morphName: string;
6
+ /** FK on the pivot referencing the parent model (e.g. 'tag_id'). */
7
+ parentPivotKey: string;
8
+ /** Pivot table name. Defaults to `{morphName}s`. */
9
+ pivotTable?: string;
10
+ withPivot?: string[];
11
+ withTimestamps?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Declare a polymorphic many-to-many from the "inverse" side: the shared model
16
+ * reaches back to each of the owning types through the same morph pivot table
17
+ * (e.g. `Tag` —(taggables)→ `Post` / `Video`). The property is a
18
+ * {@link ManyToMany} collection with pivot methods.
19
+ *
20
+ * @param related - Lazy factory returning the related (owning) model class.
21
+ * @param options - Morph name, parent pivot key, and optional pivot config.
22
+ * @category Relationships
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * class Tag extends BaseModel {
27
+ * \@morphedByMany(() => Post, { morphName: 'taggable', parentPivotKey: 'tag_id' })
28
+ * posts!: ManyToMany<Post>;
29
+ * }
30
+ * ```
31
+ */
32
+ export function morphedByMany(related: () => unknown, options: MorphedByManyOptions) {
33
+ const pivotTable = options.pivotTable ?? `${options.morphName}s`;
34
+ return makeRelationDecorator(() => ({
35
+ type: "morphedByMany" as const,
36
+ related,
37
+ pivotTable,
38
+ pivotForeignKey: options.parentPivotKey,
39
+ pivotRelatedKey: `${options.morphName}_id`,
40
+ pivotMorphType: `${options.morphName}_type`,
41
+ foreignKey: options.parentPivotKey,
42
+ localKey: "id",
43
+ ...(options.withPivot ? { pivotColumns: options.withPivot } : {}),
44
+ ...(options.withTimestamps ? { pivotTimestamps: options.withTimestamps } : {}),
45
+ }));
46
+ }
@@ -0,0 +1,124 @@
1
+ import { drainPendingMembers } from "./_metadata.ts";
2
+
3
+ /**
4
+ * Options object accepted as the second argument to `@table()`.
5
+ * Equivalent to calling the fluent chain methods — choose whichever style you prefer.
6
+ *
7
+ * @example
8
+ * @table("users", { timestamps: true })
9
+ * export class User extends BaseModel { ... }
10
+ */
11
+ export interface TableOptions {
12
+ /** Enable / disable automatic timestamp columns. @default true */
13
+ timestamps?: boolean;
14
+ /** Override the primary key column name. @default "id" */
15
+ primaryKey?: string;
16
+ }
17
+
18
+ /**
19
+ * Configuration collected by the fluent builder.
20
+ * @internal
21
+ */
22
+ interface TableConfig {
23
+ tableName: string;
24
+ timestamps: boolean;
25
+ primaryKey: string;
26
+ }
27
+
28
+ /**
29
+ * A function that acts as a class decorator AND exposes chainable configuration
30
+ * methods. Every chain method returns the same builder so you can keep chaining
31
+ * or apply it directly as a decorator.
32
+ *
33
+ * @example
34
+ * // Fluent chain form
35
+ * @table("users").withoutTimestamps()
36
+ * export class User extends BaseModel { ... }
37
+ *
38
+ * @example
39
+ * // Options-object form — same result, no parens needed on the decorator
40
+ * @table("users", { timestamps: true })
41
+ * export class User extends BaseModel { ... }
42
+ *
43
+ * @example
44
+ * // Non-standard primary key
45
+ * @table("posts").primaryKey("post_id")
46
+ * export class Post extends BaseModel { ... }
47
+ *
48
+ * Soft deletes are opt-in via the `SoftDeletes` mixin, not `@table`:
49
+ * `class Post extends BaseModelWith(SoftDeletes) {}` — see /docs/orm/lifecycle.
50
+ */
51
+ export interface TableDecoratorBuilder {
52
+ /** Apply the decorator to a class constructor (called automatically by TS). */
53
+ (target: Function, context?: unknown): void;
54
+
55
+ /** Enable automatic `created_at` / `updated_at` management. Default: on. */
56
+ withTimestamps(): TableDecoratorBuilder;
57
+ /** Disable automatic timestamp columns. */
58
+ withoutTimestamps(): TableDecoratorBuilder;
59
+ /** Override the primary key column name. Default: "id". */
60
+ primaryKey(key: string): TableDecoratorBuilder;
61
+ }
62
+
63
+ /**
64
+ * Fluent class decorator factory for configuring a `BaseModel` subclass.
65
+ *
66
+ * Accepts an optional `options` object as a second argument so you don't need to
67
+ * chain methods if you prefer the inline form. Both styles are fully equivalent.
68
+ *
69
+ * ```ts
70
+ * // Style A — fluent chain
71
+ * @table("users").withoutTimestamps()
72
+ * export class User extends BaseModel { ... }
73
+ *
74
+ * // Style B — options object
75
+ * @table("users", { timestamps: true })
76
+ * export class User extends BaseModel { ... }
77
+ * ```
78
+ *
79
+ * Timestamps are **on by default** — use `.withoutTimestamps()` to opt out. Soft
80
+ * deletes are **opt-in via the `SoftDeletes` mixin**: `extends BaseModelWith(SoftDeletes)`.
81
+ *
82
+ * `@table` is the single, required way to configure a model: besides setting the
83
+ * table name and options, it anchors the class's `@column`/relation registrations at
84
+ * definition time (the decorator runs synchronously with the class in hand). Any class
85
+ * that declares column or relation fields — including a subclass that ADDS fields — must
86
+ * carry its own `@table`, or those fields won't register.
87
+ */
88
+ export function table(tableName: string, options: TableOptions = {}): TableDecoratorBuilder {
89
+ const config: TableConfig = {
90
+ tableName,
91
+ timestamps: options.timestamps ?? true,
92
+ primaryKey: options.primaryKey ?? "id",
93
+ };
94
+
95
+ function apply(target: Function, _context?: unknown): void {
96
+ // Drain the @column / @relation registrations queued while this class's members were
97
+ // decorated. @table is the definition-time anchor that owns this — see _metadata.ts.
98
+ drainPendingMembers(target);
99
+ const ctor = target as unknown as Record<string, unknown>;
100
+ ctor.table = config.tableName;
101
+ ctor.timestamps = config.timestamps;
102
+ ctor.primaryKey = config.primaryKey;
103
+ // NOTE: `softDeletes` is intentionally NOT set here — it's owned by the SoftDeletes
104
+ // mixin (`static softDeletes = true`). Setting it from @table would clobber the
105
+ // mixin's value back to the default on models that compose it.
106
+ }
107
+
108
+ apply.withTimestamps = (): TableDecoratorBuilder => {
109
+ config.timestamps = true;
110
+ return apply as TableDecoratorBuilder;
111
+ };
112
+
113
+ apply.withoutTimestamps = (): TableDecoratorBuilder => {
114
+ config.timestamps = false;
115
+ return apply as TableDecoratorBuilder;
116
+ };
117
+
118
+ apply.primaryKey = (key: string): TableDecoratorBuilder => {
119
+ config.primaryKey = key;
120
+ return apply as TableDecoratorBuilder;
121
+ };
122
+
123
+ return apply as TableDecoratorBuilder;
124
+ }
@@ -0,0 +1,110 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { currentOrmContext } from "../OrmContext.ts";
3
+
4
+ /**
5
+ * The set of model lifecycle points a hook can attach to. Each fires once per
6
+ * persistence operation; `beforeSave`/`afterSave` wrap both inserts and updates,
7
+ * and `afterFind` fires when a row is hydrated from the database.
8
+ */
9
+ export type HookName =
10
+ | "beforeCreate"
11
+ | "afterCreate"
12
+ | "beforeSave"
13
+ | "afterSave"
14
+ | "beforeUpdate"
15
+ | "afterUpdate"
16
+ | "beforeDelete"
17
+ | "afterDelete"
18
+ | "afterFind";
19
+
20
+ type HookFn<T> = (model: T) => Promise<void> | void;
21
+
22
+ function _registry(): Map<Function, Map<HookName, HookFn<unknown>[]>> {
23
+ return currentOrmContext().hooks as unknown as Map<Function, Map<HookName, HookFn<unknown>[]>>;
24
+ }
25
+
26
+ /**
27
+ * AsyncLocalStorage carrying a boolean that, when `true`, mutes all hook
28
+ * and observer execution for the duration of the callback.
29
+ *
30
+ * Set by `_suppressHooks()` — used by factories to silence observers during
31
+ * seeding. Opt out by calling `.dispatchEvents()` on the factory.
32
+ */
33
+ const _suppressCtx = new AsyncLocalStorage<true>();
34
+
35
+ /**
36
+ * Run `fn` with all model hooks and observers silenced.
37
+ * Used internally by Factory when `dispatchEvents()` has NOT been called.
38
+ */
39
+ export function _suppressHooks<T>(fn: () => Promise<T>): Promise<T> {
40
+ return _suppressCtx.run(true, fn);
41
+ }
42
+
43
+ /**
44
+ * Central store of per-model lifecycle hook callbacks, keyed by model class and
45
+ * {@link HookName}. Hooks registered on a parent class also run for subclasses
46
+ * (the prototype chain is walked in base-to-derived order).
47
+ *
48
+ * Registration is driven by higher-level APIs (model `beforeCreate()` etc. and the
49
+ * observer bridge in {@link registerObserver}); most application code never calls
50
+ * this class directly.
51
+ *
52
+ * @internal
53
+ */
54
+ export class HookRegistry {
55
+ /**
56
+ * Optional post-run callback, invoked after a hook's functions run (and only when hooks
57
+ * are not suppressed). BaseModel sets this to dispatch model events (`dispatchesEvents`).
58
+ */
59
+ static onAfterRun: ((ModelClass: Function, hook: HookName, model: unknown) => void) | undefined;
60
+
61
+ /**
62
+ * Append a hook callback for a model class at a given lifecycle point.
63
+ *
64
+ * @param ModelClass - The model constructor the hook belongs to.
65
+ * @param hook - Which lifecycle point to fire on.
66
+ * @param fn - Callback receiving the model instance; may be async.
67
+ */
68
+ static register<T>(ModelClass: Function, hook: HookName, fn: HookFn<T>): void {
69
+ const registry = _registry();
70
+ if (!registry.has(ModelClass)) {
71
+ registry.set(ModelClass, new Map());
72
+ }
73
+ const hooks = registry.get(ModelClass)!;
74
+ if (!hooks.has(hook)) hooks.set(hook, []);
75
+ hooks.get(hook)!.push(fn as HookFn<unknown>);
76
+ }
77
+
78
+ /**
79
+ * Run every registered callback for `hook` on `ModelClass`, awaiting each in
80
+ * turn, walking the prototype chain base-to-derived. No-op while hooks are
81
+ * suppressed (see {@link _suppressHooks}); otherwise invokes `onAfterRun` afterward.
82
+ *
83
+ * @param ModelClass - The model constructor whose hooks (and inherited hooks) to run.
84
+ * @param hook - Which lifecycle point is firing.
85
+ * @param model - The model instance passed to each callback.
86
+ */
87
+ static async run<T>(ModelClass: Function, hook: HookName, model: T): Promise<void> {
88
+ if (_suppressCtx.getStore()) return;
89
+
90
+ // Walk the prototype chain to collect inherited hooks
91
+ const chain: Function[] = [];
92
+ let cur: Function | null = ModelClass;
93
+ while (cur && cur !== Function.prototype) {
94
+ chain.unshift(cur);
95
+ cur = Object.getPrototypeOf(cur) as Function | null;
96
+ }
97
+
98
+ const registry = _registry();
99
+ for (const cls of chain) {
100
+ const fns = registry.get(cls)?.get(hook);
101
+ if (fns) {
102
+ for (const fn of fns) {
103
+ await fn(model as unknown);
104
+ }
105
+ }
106
+ }
107
+
108
+ HookRegistry.onAfterRun?.(ModelClass, hook, model as unknown);
109
+ }
110
+ }