metal-orm 1.0.99 → 1.0.100

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.
@@ -1,562 +1,609 @@
1
- import { Dialect } from '../core/dialect/abstract.js';
2
- import { eq } from '../core/ast/expression.js';
3
- import type { DbExecutor } from '../core/execution/db-executor.js';
4
- import { SelectQueryBuilder } from '../query-builder/select.js';
5
- import { findPrimaryKey } from '../query-builder/hydration-planner.js';
6
- import type { ColumnDef } from '../schema/column-types.js';
7
- import type { TableDef } from '../schema/table.js';
8
- import { EntityInstance } from '../schema/types.js';
9
- import { RelationDef } from '../schema/relation.js';
10
-
11
- import { selectFromEntity, getTableDefFromEntity } from '../decorators/bootstrap.js';
12
- import type { EntityConstructor } from './entity-metadata.js';
13
- import { Orm } from './orm.js';
14
- import { IdentityMap } from './identity-map.js';
15
- import { UnitOfWork } from './unit-of-work.js';
16
- import { DomainEventBus, DomainEventHandler, InitialHandlers } from './domain-event-bus.js';
17
- import { RelationChangeProcessor } from './relation-change-processor.js';
18
- import { createQueryLoggingExecutor, QueryLogger } from './query-logger.js';
19
- import { ExecutionContext } from './execution-context.js';
20
- import type { HydrationContext } from './hydration-context.js';
21
- import type { EntityContext, PrimaryKey } from './entity-context.js';
22
- import {
23
- DomainEvent,
24
- OrmDomainEvent,
25
- RelationChange,
26
- RelationChangeEntry,
27
- RelationKey,
28
- TrackedEntity
29
- } from './runtime-types.js';
30
- import { executeHydrated } from './execute.js';
31
- import { runInTransaction } from './transaction-runner.js';
32
- import { saveGraphInternal, SaveGraphOptions } from './save-graph.js';
33
- import type { SaveGraphInputPayload } from './save-graph-types.js';
34
-
35
- /**
36
- * Interface for ORM interceptors that allow hooking into the flush lifecycle.
37
- */
38
- export interface OrmInterceptor {
39
- /**
40
- * Called before the flush operation begins.
41
- * @param ctx - The entity context
42
- */
43
- beforeFlush?(ctx: EntityContext): Promise<void> | void;
44
-
45
- /**
46
- * Called after the flush operation completes.
47
- * @param ctx - The entity context
48
- */
49
- afterFlush?(ctx: EntityContext): Promise<void> | void;
50
- }
51
-
52
- /**
53
- * Options for creating an OrmSession instance.
54
- * @template E - The domain event type
55
- */
56
- export interface OrmSessionOptions<E extends DomainEvent = OrmDomainEvent> {
57
- /** The ORM instance */
58
- orm: Orm<E>;
59
- /** The database executor */
60
- executor: DbExecutor;
61
- /** Optional query logger for debugging */
62
- queryLogger?: QueryLogger;
63
- /** Optional interceptors for flush lifecycle hooks */
64
- interceptors?: OrmInterceptor[];
65
- /** Optional domain event handlers */
66
- domainEventHandlers?: InitialHandlers<E, OrmSession<E>>;
67
- }
68
-
69
- export interface SaveGraphSessionOptions extends SaveGraphOptions {
70
- /** Wrap the save operation in a transaction (default: true). */
71
- transactional?: boolean;
72
- /** Flush after saveGraph when not transactional (default: false). */
73
- flush?: boolean;
74
- }
75
-
76
- /**
77
- * ORM Session that manages entity lifecycle, identity mapping, and database operations.
78
- * @template E - The domain event type
79
- */
80
- export class OrmSession<E extends DomainEvent = OrmDomainEvent> implements EntityContext {
81
- /** The ORM instance */
82
- readonly orm: Orm<E>;
83
- /** The database executor */
84
- readonly executor: DbExecutor;
85
- /** The identity map for tracking entity instances */
86
- readonly identityMap: IdentityMap;
87
- /** The unit of work for tracking entity changes */
88
- readonly unitOfWork: UnitOfWork;
89
- /** The domain event bus */
90
- readonly domainEvents: DomainEventBus<E, OrmSession<E>>;
91
- /** The relation change processor */
92
- readonly relationChanges: RelationChangeProcessor;
93
-
94
- private readonly interceptors: OrmInterceptor[];
95
- private saveGraphDefaults?: SaveGraphSessionOptions;
96
-
97
- /**
98
- * Creates a new OrmSession instance.
99
- * @param opts - Session options
100
- */
101
- constructor(opts: OrmSessionOptions<E>) {
102
- this.orm = opts.orm;
103
- this.executor = createQueryLoggingExecutor(opts.executor, opts.queryLogger);
104
- this.interceptors = [...(opts.interceptors ?? [])];
105
-
106
- this.identityMap = new IdentityMap();
107
- this.unitOfWork = new UnitOfWork(this.orm.dialect, this.executor, this.identityMap, () => this);
108
- this.relationChanges = new RelationChangeProcessor(this.unitOfWork, this.orm.dialect, this.executor);
109
- this.domainEvents = new DomainEventBus<E, OrmSession<E>>(opts.domainEventHandlers);
110
- }
111
-
112
- /**
113
- * Releases resources associated with this session (executor/pool leases) and resets tracking.
114
- * Must be safe to call multiple times.
115
- */
116
- async dispose(): Promise<void> {
117
- try {
118
- await this.executor.dispose();
119
- } finally {
120
- // Always reset in-memory tracking.
121
- this.unitOfWork.reset();
122
- this.relationChanges.reset();
123
- }
124
- }
125
-
126
- /**
127
- * Gets the database dialect.
128
- */
129
- get dialect(): Dialect {
130
- return this.orm.dialect;
131
- }
132
-
133
- /**
134
- * Gets the identity buckets map.
135
- */
136
- get identityBuckets(): Map<string, Map<string, TrackedEntity>> {
137
- return this.unitOfWork.identityBuckets;
138
- }
139
-
140
- /**
141
- * Gets all tracked entities.
142
- */
143
- get tracked(): TrackedEntity[] {
144
- return this.unitOfWork.getTracked();
145
- }
146
-
147
- /**
148
- * Gets an entity by table and primary key.
149
- * @param table - The table definition
150
- * @param pk - The primary key value
151
- * @returns The entity or undefined if not found
152
- */
153
- getEntity(table: TableDef, pk: PrimaryKey): object | undefined {
154
- return this.unitOfWork.getEntity(table, pk);
155
- }
156
-
157
- /**
158
- * Sets an entity in the identity map.
159
- * @param table - The table definition
160
- * @param pk - The primary key value
161
- * @param entity - The entity instance
162
- */
163
- setEntity(table: TableDef, pk: PrimaryKey, entity: object): void {
164
- this.unitOfWork.setEntity(table, pk, entity);
165
- }
166
-
167
- /**
168
- * Tracks a new entity.
169
- * @param table - The table definition
170
- * @param entity - The entity instance
171
- * @param pk - Optional primary key value
172
- */
173
- trackNew(table: TableDef, entity: object, pk?: PrimaryKey): void {
174
- this.unitOfWork.trackNew(table, entity, pk);
175
- }
176
-
177
- /**
178
- * Tracks a managed entity.
179
- * @param table - The table definition
180
- * @param pk - The primary key value
181
- * @param entity - The entity instance
182
- */
183
- trackManaged(table: TableDef, pk: PrimaryKey, entity: object): void {
184
- this.unitOfWork.trackManaged(table, pk, entity);
185
- }
186
-
187
- /**
188
- * Marks an entity as dirty (modified).
189
- * @param entity - The entity to mark as dirty
190
- */
191
- markDirty(entity: object): void {
192
- this.unitOfWork.markDirty(entity);
193
- }
194
-
195
- /**
196
- * Marks an entity as removed.
197
- * @param entity - The entity to mark as removed
198
- */
199
- markRemoved(entity: object): void {
200
- this.unitOfWork.markRemoved(entity);
201
- }
202
-
203
- /**
204
- * Registers a relation change.
205
- * @param root - The root entity
206
- * @param relationKey - The relation key
207
- * @param rootTable - The root table definition
208
- * @param relationName - The relation name
209
- * @param relation - The relation definition
210
- * @param change - The relation change
211
- */
212
- registerRelationChange = (
213
- root: unknown,
214
- relationKey: RelationKey,
215
- rootTable: TableDef,
216
- relationName: string,
217
- relation: RelationDef,
218
- change: RelationChange<unknown>
219
- ): void => {
220
- this.relationChanges.registerChange(
221
- buildRelationChangeEntry(root, relationKey, rootTable, relationName, relation, change)
222
- );
223
- };
224
-
225
- /**
226
- * Gets all tracked entities for a specific table.
227
- * @param table - The table definition
228
- * @returns Array of tracked entities
229
- */
230
- getEntitiesForTable(table: TableDef): TrackedEntity[] {
231
- return this.unitOfWork.getEntitiesForTable(table);
232
- }
233
-
234
- /**
235
- * Registers an interceptor for flush lifecycle hooks.
236
- * @param interceptor - The interceptor to register
237
- */
238
- registerInterceptor(interceptor: OrmInterceptor): void {
239
- this.interceptors.push(interceptor);
240
- }
241
-
242
- /**
243
- * Registers a domain event handler.
244
- * @param type - The event type
245
- * @param handler - The event handler
246
- */
247
- registerDomainEventHandler<TType extends E['type']>(
248
- type: TType,
249
- handler: DomainEventHandler<Extract<E, { type: TType }>, OrmSession<E>>
250
- ): void {
251
- this.domainEvents.on(type, handler);
252
- }
253
-
254
- /**
255
- * Sets default options applied to all saveGraph calls for this session.
256
- * Per-call options override these defaults.
257
- * @param defaults - Default saveGraph options for the session
258
- */
259
- withSaveGraphDefaults(defaults: SaveGraphSessionOptions): this {
260
- this.saveGraphDefaults = { ...defaults };
261
- return this;
262
- }
263
-
264
- /**
265
- * Finds an entity by its primary key.
266
- * @template TCtor - The entity constructor type
267
- * @param entityClass - The entity constructor
268
- * @param id - The primary key value
269
- * @returns The entity instance or null if not found
270
- * @throws If entity metadata is not bootstrapped or table has no primary key
271
- */
272
- async find<TCtor extends EntityConstructor<object>>(
273
- entityClass: TCtor,
274
- id: unknown
275
- ): Promise<InstanceType<TCtor> | null> {
276
- const table = getTableDefFromEntity(entityClass);
277
- if (!table) {
278
- throw new Error('Entity metadata has not been bootstrapped');
279
- }
280
- const primaryKey = findPrimaryKey(table);
281
- const column = table.columns[primaryKey];
282
- if (!column) {
283
- throw new Error('Entity table does not expose a primary key');
284
- }
285
- const columnSelections = Object.values(table.columns).reduce<Record<string, ColumnDef>>((acc, col) => {
286
- acc[col.name] = col;
287
- return acc;
288
- }, {});
289
- const qb = selectFromEntity(entityClass)
290
- .select(columnSelections)
291
- .where(eq(column, id as string | number))
292
- .limit(1);
293
- const rows = await executeHydrated(this, qb);
294
- return (rows[0] ?? null) as InstanceType<TCtor> | null;
295
- }
296
-
297
- /**
298
- * Finds a single entity using a query builder.
299
- * @template TTable - The table type
300
- * @param qb - The query builder
301
- * @returns The first entity instance or null if not found
302
- */
303
- async findOne<TTable extends TableDef>(qb: SelectQueryBuilder<unknown, TTable>): Promise<EntityInstance<TTable> | null> {
304
- const limited = qb.limit(1);
305
- const rows = await executeHydrated(this, limited);
306
- return rows[0] ?? null;
307
- }
308
-
309
- /**
310
- * Finds multiple entities using a query builder.
311
- * @template TTable - The table type
312
- * @param qb - The query builder
313
- * @returns Array of entity instances
314
- */
315
- async findMany<TTable extends TableDef>(qb: SelectQueryBuilder<unknown, TTable>): Promise<EntityInstance<TTable>[]> {
316
- return executeHydrated(this, qb);
317
- }
318
-
319
- /**
320
- * Saves an entity graph (root + nested relations) based on a DTO-like payload.
321
- * @param entityClass - Root entity constructor
322
- * @param payload - DTO payload containing column values and nested relations
323
- * @param options - Graph save options
324
- * @returns The root entity instance
325
- */
326
- async saveGraph<TCtor extends EntityConstructor<object>>(
327
- entityClass: TCtor,
328
- payload: SaveGraphInputPayload<InstanceType<TCtor>>,
329
- options?: SaveGraphSessionOptions
330
- ): Promise<InstanceType<TCtor>>;
331
- async saveGraph<TCtor extends EntityConstructor<object>>(
332
- entityClass: TCtor,
333
- payload: Record<string, unknown>,
334
- options?: SaveGraphSessionOptions
335
- ): Promise<InstanceType<TCtor>> {
336
- const resolved = this.resolveSaveGraphOptions(options);
337
- const { transactional = true, flush = false, ...graphOptions } = resolved;
338
- const execute = () => saveGraphInternal(this, entityClass, payload, graphOptions);
339
- if (!transactional) {
340
- const result = await execute();
341
- if (flush) {
342
- await this.flush();
343
- }
344
- return result;
345
- }
346
- return this.transaction(() => execute());
347
- }
348
-
349
- /**
350
- * Saves an entity graph and flushes immediately (defaults to transactional: false).
351
- * @param entityClass - Root entity constructor
352
- * @param payload - DTO payload containing column values and nested relations
353
- * @param options - Graph save options
354
- * @returns The root entity instance
355
- */
356
- async saveGraphAndFlush<TCtor extends EntityConstructor<object>>(
357
- entityClass: TCtor,
358
- payload: SaveGraphInputPayload<InstanceType<TCtor>>,
359
- options?: SaveGraphSessionOptions
360
- ): Promise<InstanceType<TCtor>> {
361
- const merged = { ...(options ?? {}), flush: true, transactional: options?.transactional ?? false };
362
- return this.saveGraph(entityClass, payload, merged);
363
- }
364
-
365
- /**
366
- * Updates an existing entity graph (requires a primary key in the payload).
367
- * @param entityClass - Root entity constructor
368
- * @param payload - DTO payload containing column values and nested relations
369
- * @param options - Graph save options
370
- * @returns The root entity instance or null if not found
371
- */
372
- async updateGraph<TCtor extends EntityConstructor<object>>(
373
- entityClass: TCtor,
374
- payload: SaveGraphInputPayload<InstanceType<TCtor>>,
375
- options?: SaveGraphSessionOptions
376
- ): Promise<InstanceType<TCtor> | null> {
377
- const table = getTableDefFromEntity(entityClass);
378
- if (!table) {
379
- throw new Error('Entity metadata has not been bootstrapped');
380
- }
381
- const primaryKey = findPrimaryKey(table);
382
- const pkValue = (payload as Record<string, unknown>)[primaryKey];
383
- if (pkValue === undefined || pkValue === null) {
384
- throw new Error(`updateGraph requires a primary key value for "${primaryKey}"`);
385
- }
386
-
387
- const resolved = this.resolveSaveGraphOptions(options);
388
- const { transactional = true, flush = false, ...graphOptions } = resolved;
389
- const execute = async (): Promise<InstanceType<TCtor> | null> => {
390
- const tracked = this.getEntity(table, pkValue as PrimaryKey) as InstanceType<TCtor> | undefined;
391
- const existing = tracked ?? await this.find(entityClass, pkValue);
392
- if (!existing) return null;
393
- return saveGraphInternal(this, entityClass, payload, graphOptions);
394
- };
395
-
396
- if (!transactional) {
397
- const result = await execute();
398
- if (result && flush) {
399
- await this.flush();
400
- }
401
- return result;
402
- }
403
- return this.transaction(() => execute());
404
- }
405
-
406
- /**
407
- * Persists an entity (either inserts or updates).
408
- * @param entity - The entity to persist
409
- * @throws If entity metadata is not bootstrapped
410
- */
411
- async persist(entity: object): Promise<void> {
412
- if (this.unitOfWork.findTracked(entity)) {
413
- return;
414
- }
415
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
416
- const table = getTableDefFromEntity((entity as { constructor: EntityConstructor }).constructor);
417
- if (!table) {
418
- throw new Error('Entity metadata has not been bootstrapped');
419
- }
420
- const primaryKey = findPrimaryKey(table);
421
- const pkValue = (entity as Record<string, unknown>)[primaryKey];
422
- if (pkValue !== undefined && pkValue !== null) {
423
- this.trackManaged(table, pkValue as PrimaryKey, entity);
424
- } else {
425
- this.trackNew(table, entity);
426
- }
427
- }
428
-
429
- /**
430
- * Marks an entity for removal.
431
- * @param entity - The entity to remove
432
- */
433
- async remove(entity: object): Promise<void> {
434
- this.markRemoved(entity);
435
- }
436
-
437
- /**
438
- * Flushes pending changes to the database without session hooks, relation processing, or domain events.
439
- */
440
- async flush(): Promise<void> {
441
- await this.unitOfWork.flush();
442
- }
443
-
444
- /**
445
- * Flushes pending changes with interceptors and relation processing.
446
- */
447
- private async flushWithHooks(): Promise<void> {
448
- for (const interceptor of this.interceptors) {
449
- await interceptor.beforeFlush?.(this);
450
- }
451
-
452
- await this.unitOfWork.flush();
453
- await this.relationChanges.process();
454
- await this.unitOfWork.flush();
455
-
456
- for (const interceptor of this.interceptors) {
457
- await interceptor.afterFlush?.(this);
458
- }
459
- }
460
-
461
- /**
462
- * Commits the current transaction.
463
- */
464
- async commit(): Promise<void> {
465
- await runInTransaction(this.executor, async () => {
466
- await this.flushWithHooks();
467
- });
468
-
469
- await this.domainEvents.dispatch(this.unitOfWork.getTracked(), this);
470
- }
471
-
472
- /**
473
- * Executes a function within a transaction.
474
- * @template T - The return type
475
- * @param fn - The function to execute
476
- * @returns The result of the function
477
- * @throws If the transaction fails
478
- */
479
- async transaction<T>(fn: (session: OrmSession<E>) => Promise<T>): Promise<T> {
480
- // If the executor can't do transactions, just run and commit once.
481
- if (!this.executor.capabilities.transactions) {
482
- const result = await fn(this);
483
- await this.commit();
484
- return result;
485
- }
486
-
487
- await this.executor.beginTransaction();
488
- try {
489
- const result = await fn(this);
490
- await this.flushWithHooks();
491
- await this.executor.commitTransaction();
492
- await this.domainEvents.dispatch(this.unitOfWork.getTracked(), this);
493
- return result;
494
- } catch (err) {
495
- await this.rollback();
496
- throw err;
497
- }
498
- }
499
-
500
- /**
501
- * Rolls back the current transaction.
502
- */
503
- async rollback(): Promise<void> {
504
- if (this.executor.capabilities.transactions) {
505
- await this.executor.rollbackTransaction();
506
- }
507
- this.unitOfWork.reset();
508
- this.relationChanges.reset();
509
- }
510
-
511
- /**
512
- * Gets the execution context.
513
- * @returns The execution context
514
- */
515
- getExecutionContext(): ExecutionContext {
516
- return {
517
- dialect: this.orm.dialect,
518
- executor: this.executor,
519
- interceptors: this.orm.interceptors
520
- };
521
- }
522
-
523
- /**
524
- * Gets the hydration context.
525
- * @returns The hydration context
526
- */
527
- getHydrationContext(): HydrationContext<E> {
528
- return {
529
- identityMap: this.identityMap,
530
- unitOfWork: this.unitOfWork,
531
- domainEvents: this.domainEvents,
532
- relationChanges: this.relationChanges,
533
- entityContext: this
534
- };
535
- }
536
-
537
- /**
538
- * Merges session defaults with per-call saveGraph options.
539
- * @param options - Per-call saveGraph options
540
- * @returns Combined options with per-call values taking precedence
541
- */
542
- private resolveSaveGraphOptions(options?: SaveGraphSessionOptions): SaveGraphSessionOptions {
543
- return { ...(this.saveGraphDefaults ?? {}), ...(options ?? {}) };
544
- }
545
- }
546
-
547
- const buildRelationChangeEntry = (
548
- root: unknown,
549
- relationKey: RelationKey,
550
- rootTable: TableDef,
551
- relationName: string,
552
- relation: RelationDef,
553
- change: RelationChange<unknown>
554
- ): RelationChangeEntry => ({
555
- root,
556
- relationKey,
557
- rootTable,
558
- relationName,
559
- relation,
560
- change
561
- });
562
-
1
+ import { Dialect } from '../core/dialect/abstract.js';
2
+ import { eq } from '../core/ast/expression.js';
3
+ import type { DbExecutor } from '../core/execution/db-executor.js';
4
+ import { SelectQueryBuilder } from '../query-builder/select.js';
5
+ import { findPrimaryKey } from '../query-builder/hydration-planner.js';
6
+ import type { ColumnDef } from '../schema/column-types.js';
7
+ import type { TableDef } from '../schema/table.js';
8
+ import { EntityInstance } from '../schema/types.js';
9
+ import { RelationDef } from '../schema/relation.js';
10
+
11
+ import { selectFromEntity, getTableDefFromEntity } from '../decorators/bootstrap.js';
12
+ import type { EntityConstructor } from './entity-metadata.js';
13
+ import { Orm } from './orm.js';
14
+ import { IdentityMap } from './identity-map.js';
15
+ import { UnitOfWork } from './unit-of-work.js';
16
+ import { DomainEventBus, DomainEventHandler, InitialHandlers } from './domain-event-bus.js';
17
+ import { RelationChangeProcessor } from './relation-change-processor.js';
18
+ import { createQueryLoggingExecutor, QueryLogger } from './query-logger.js';
19
+ import { ExecutionContext } from './execution-context.js';
20
+ import type { HydrationContext } from './hydration-context.js';
21
+ import type { EntityContext, PrimaryKey } from './entity-context.js';
22
+ import {
23
+ DomainEvent,
24
+ OrmDomainEvent,
25
+ RelationChange,
26
+ RelationChangeEntry,
27
+ RelationKey,
28
+ TrackedEntity
29
+ } from './runtime-types.js';
30
+ import { executeHydrated } from './execute.js';
31
+ import { runInTransaction } from './transaction-runner.js';
32
+ import { saveGraphInternal, patchGraphInternal, SaveGraphOptions } from './save-graph.js';
33
+ import type { SaveGraphInputPayload, PatchGraphInputPayload } from './save-graph-types.js';
34
+
35
+ /**
36
+ * Interface for ORM interceptors that allow hooking into the flush lifecycle.
37
+ */
38
+ export interface OrmInterceptor {
39
+ /**
40
+ * Called before the flush operation begins.
41
+ * @param ctx - The entity context
42
+ */
43
+ beforeFlush?(ctx: EntityContext): Promise<void> | void;
44
+
45
+ /**
46
+ * Called after the flush operation completes.
47
+ * @param ctx - The entity context
48
+ */
49
+ afterFlush?(ctx: EntityContext): Promise<void> | void;
50
+ }
51
+
52
+ /**
53
+ * Options for creating an OrmSession instance.
54
+ * @template E - The domain event type
55
+ */
56
+ export interface OrmSessionOptions<E extends DomainEvent = OrmDomainEvent> {
57
+ /** The ORM instance */
58
+ orm: Orm<E>;
59
+ /** The database executor */
60
+ executor: DbExecutor;
61
+ /** Optional query logger for debugging */
62
+ queryLogger?: QueryLogger;
63
+ /** Optional interceptors for flush lifecycle hooks */
64
+ interceptors?: OrmInterceptor[];
65
+ /** Optional domain event handlers */
66
+ domainEventHandlers?: InitialHandlers<E, OrmSession<E>>;
67
+ }
68
+
69
+ export interface SaveGraphSessionOptions extends SaveGraphOptions {
70
+ /** Wrap the save operation in a transaction (default: true). */
71
+ transactional?: boolean;
72
+ /** Flush after saveGraph when not transactional (default: false). */
73
+ flush?: boolean;
74
+ }
75
+
76
+ /**
77
+ * ORM Session that manages entity lifecycle, identity mapping, and database operations.
78
+ * @template E - The domain event type
79
+ */
80
+ export class OrmSession<E extends DomainEvent = OrmDomainEvent> implements EntityContext {
81
+ /** The ORM instance */
82
+ readonly orm: Orm<E>;
83
+ /** The database executor */
84
+ readonly executor: DbExecutor;
85
+ /** The identity map for tracking entity instances */
86
+ readonly identityMap: IdentityMap;
87
+ /** The unit of work for tracking entity changes */
88
+ readonly unitOfWork: UnitOfWork;
89
+ /** The domain event bus */
90
+ readonly domainEvents: DomainEventBus<E, OrmSession<E>>;
91
+ /** The relation change processor */
92
+ readonly relationChanges: RelationChangeProcessor;
93
+
94
+ private readonly interceptors: OrmInterceptor[];
95
+ private saveGraphDefaults?: SaveGraphSessionOptions;
96
+
97
+ /**
98
+ * Creates a new OrmSession instance.
99
+ * @param opts - Session options
100
+ */
101
+ constructor(opts: OrmSessionOptions<E>) {
102
+ this.orm = opts.orm;
103
+ this.executor = createQueryLoggingExecutor(opts.executor, opts.queryLogger);
104
+ this.interceptors = [...(opts.interceptors ?? [])];
105
+
106
+ this.identityMap = new IdentityMap();
107
+ this.unitOfWork = new UnitOfWork(this.orm.dialect, this.executor, this.identityMap, () => this);
108
+ this.relationChanges = new RelationChangeProcessor(this.unitOfWork, this.orm.dialect, this.executor);
109
+ this.domainEvents = new DomainEventBus<E, OrmSession<E>>(opts.domainEventHandlers);
110
+ }
111
+
112
+ /**
113
+ * Releases resources associated with this session (executor/pool leases) and resets tracking.
114
+ * Must be safe to call multiple times.
115
+ */
116
+ async dispose(): Promise<void> {
117
+ try {
118
+ await this.executor.dispose();
119
+ } finally {
120
+ // Always reset in-memory tracking.
121
+ this.unitOfWork.reset();
122
+ this.relationChanges.reset();
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Gets the database dialect.
128
+ */
129
+ get dialect(): Dialect {
130
+ return this.orm.dialect;
131
+ }
132
+
133
+ /**
134
+ * Gets the identity buckets map.
135
+ */
136
+ get identityBuckets(): Map<string, Map<string, TrackedEntity>> {
137
+ return this.unitOfWork.identityBuckets;
138
+ }
139
+
140
+ /**
141
+ * Gets all tracked entities.
142
+ */
143
+ get tracked(): TrackedEntity[] {
144
+ return this.unitOfWork.getTracked();
145
+ }
146
+
147
+ /**
148
+ * Gets an entity by table and primary key.
149
+ * @param table - The table definition
150
+ * @param pk - The primary key value
151
+ * @returns The entity or undefined if not found
152
+ */
153
+ getEntity(table: TableDef, pk: PrimaryKey): object | undefined {
154
+ return this.unitOfWork.getEntity(table, pk);
155
+ }
156
+
157
+ /**
158
+ * Sets an entity in the identity map.
159
+ * @param table - The table definition
160
+ * @param pk - The primary key value
161
+ * @param entity - The entity instance
162
+ */
163
+ setEntity(table: TableDef, pk: PrimaryKey, entity: object): void {
164
+ this.unitOfWork.setEntity(table, pk, entity);
165
+ }
166
+
167
+ /**
168
+ * Tracks a new entity.
169
+ * @param table - The table definition
170
+ * @param entity - The entity instance
171
+ * @param pk - Optional primary key value
172
+ */
173
+ trackNew(table: TableDef, entity: object, pk?: PrimaryKey): void {
174
+ this.unitOfWork.trackNew(table, entity, pk);
175
+ }
176
+
177
+ /**
178
+ * Tracks a managed entity.
179
+ * @param table - The table definition
180
+ * @param pk - The primary key value
181
+ * @param entity - The entity instance
182
+ */
183
+ trackManaged(table: TableDef, pk: PrimaryKey, entity: object): void {
184
+ this.unitOfWork.trackManaged(table, pk, entity);
185
+ }
186
+
187
+ /**
188
+ * Marks an entity as dirty (modified).
189
+ * @param entity - The entity to mark as dirty
190
+ */
191
+ markDirty(entity: object): void {
192
+ this.unitOfWork.markDirty(entity);
193
+ }
194
+
195
+ /**
196
+ * Marks an entity as removed.
197
+ * @param entity - The entity to mark as removed
198
+ */
199
+ markRemoved(entity: object): void {
200
+ this.unitOfWork.markRemoved(entity);
201
+ }
202
+
203
+ /**
204
+ * Registers a relation change.
205
+ * @param root - The root entity
206
+ * @param relationKey - The relation key
207
+ * @param rootTable - The root table definition
208
+ * @param relationName - The relation name
209
+ * @param relation - The relation definition
210
+ * @param change - The relation change
211
+ */
212
+ registerRelationChange = (
213
+ root: unknown,
214
+ relationKey: RelationKey,
215
+ rootTable: TableDef,
216
+ relationName: string,
217
+ relation: RelationDef,
218
+ change: RelationChange<unknown>
219
+ ): void => {
220
+ this.relationChanges.registerChange(
221
+ buildRelationChangeEntry(root, relationKey, rootTable, relationName, relation, change)
222
+ );
223
+ };
224
+
225
+ /**
226
+ * Gets all tracked entities for a specific table.
227
+ * @param table - The table definition
228
+ * @returns Array of tracked entities
229
+ */
230
+ getEntitiesForTable(table: TableDef): TrackedEntity[] {
231
+ return this.unitOfWork.getEntitiesForTable(table);
232
+ }
233
+
234
+ /**
235
+ * Registers an interceptor for flush lifecycle hooks.
236
+ * @param interceptor - The interceptor to register
237
+ */
238
+ registerInterceptor(interceptor: OrmInterceptor): void {
239
+ this.interceptors.push(interceptor);
240
+ }
241
+
242
+ /**
243
+ * Registers a domain event handler.
244
+ * @param type - The event type
245
+ * @param handler - The event handler
246
+ */
247
+ registerDomainEventHandler<TType extends E['type']>(
248
+ type: TType,
249
+ handler: DomainEventHandler<Extract<E, { type: TType }>, OrmSession<E>>
250
+ ): void {
251
+ this.domainEvents.on(type, handler);
252
+ }
253
+
254
+ /**
255
+ * Sets default options applied to all saveGraph calls for this session.
256
+ * Per-call options override these defaults.
257
+ * @param defaults - Default saveGraph options for the session
258
+ */
259
+ withSaveGraphDefaults(defaults: SaveGraphSessionOptions): this {
260
+ this.saveGraphDefaults = { ...defaults };
261
+ return this;
262
+ }
263
+
264
+ /**
265
+ * Finds an entity by its primary key.
266
+ * @template TCtor - The entity constructor type
267
+ * @param entityClass - The entity constructor
268
+ * @param id - The primary key value
269
+ * @returns The entity instance or null if not found
270
+ * @throws If entity metadata is not bootstrapped or table has no primary key
271
+ */
272
+ async find<TCtor extends EntityConstructor<object>>(
273
+ entityClass: TCtor,
274
+ id: unknown
275
+ ): Promise<InstanceType<TCtor> | null> {
276
+ const table = getTableDefFromEntity(entityClass);
277
+ if (!table) {
278
+ throw new Error('Entity metadata has not been bootstrapped');
279
+ }
280
+ const primaryKey = findPrimaryKey(table);
281
+ const column = table.columns[primaryKey];
282
+ if (!column) {
283
+ throw new Error('Entity table does not expose a primary key');
284
+ }
285
+ const columnSelections = Object.values(table.columns).reduce<Record<string, ColumnDef>>((acc, col) => {
286
+ acc[col.name] = col;
287
+ return acc;
288
+ }, {});
289
+ const qb = selectFromEntity(entityClass)
290
+ .select(columnSelections)
291
+ .where(eq(column, id as string | number))
292
+ .limit(1);
293
+ const rows = await executeHydrated(this, qb);
294
+ return (rows[0] ?? null) as InstanceType<TCtor> | null;
295
+ }
296
+
297
+ /**
298
+ * Finds a single entity using a query builder.
299
+ * @template TTable - The table type
300
+ * @param qb - The query builder
301
+ * @returns The first entity instance or null if not found
302
+ */
303
+ async findOne<TTable extends TableDef>(qb: SelectQueryBuilder<unknown, TTable>): Promise<EntityInstance<TTable> | null> {
304
+ const limited = qb.limit(1);
305
+ const rows = await executeHydrated(this, limited);
306
+ return rows[0] ?? null;
307
+ }
308
+
309
+ /**
310
+ * Finds multiple entities using a query builder.
311
+ * @template TTable - The table type
312
+ * @param qb - The query builder
313
+ * @returns Array of entity instances
314
+ */
315
+ async findMany<TTable extends TableDef>(qb: SelectQueryBuilder<unknown, TTable>): Promise<EntityInstance<TTable>[]> {
316
+ return executeHydrated(this, qb);
317
+ }
318
+
319
+ /**
320
+ * Saves an entity graph (root + nested relations) based on a DTO-like payload.
321
+ * @param entityClass - Root entity constructor
322
+ * @param payload - DTO payload containing column values and nested relations
323
+ * @param options - Graph save options
324
+ * @returns The root entity instance
325
+ */
326
+ async saveGraph<TCtor extends EntityConstructor<object>>(
327
+ entityClass: TCtor,
328
+ payload: SaveGraphInputPayload<InstanceType<TCtor>>,
329
+ options?: SaveGraphSessionOptions
330
+ ): Promise<InstanceType<TCtor>>;
331
+ async saveGraph<TCtor extends EntityConstructor<object>>(
332
+ entityClass: TCtor,
333
+ payload: Record<string, unknown>,
334
+ options?: SaveGraphSessionOptions
335
+ ): Promise<InstanceType<TCtor>> {
336
+ const resolved = this.resolveSaveGraphOptions(options);
337
+ const { transactional = true, flush = false, ...graphOptions } = resolved;
338
+ const execute = () => saveGraphInternal(this, entityClass, payload, graphOptions);
339
+ if (!transactional) {
340
+ const result = await execute();
341
+ if (flush) {
342
+ await this.flush();
343
+ }
344
+ return result;
345
+ }
346
+ return this.transaction(() => execute());
347
+ }
348
+
349
+ /**
350
+ * Saves an entity graph and flushes immediately (defaults to transactional: false).
351
+ * @param entityClass - Root entity constructor
352
+ * @param payload - DTO payload containing column values and nested relations
353
+ * @param options - Graph save options
354
+ * @returns The root entity instance
355
+ */
356
+ async saveGraphAndFlush<TCtor extends EntityConstructor<object>>(
357
+ entityClass: TCtor,
358
+ payload: SaveGraphInputPayload<InstanceType<TCtor>>,
359
+ options?: SaveGraphSessionOptions
360
+ ): Promise<InstanceType<TCtor>> {
361
+ const merged = { ...(options ?? {}), flush: true, transactional: options?.transactional ?? false };
362
+ return this.saveGraph(entityClass, payload, merged);
363
+ }
364
+
365
+ /**
366
+ * Updates an existing entity graph (requires a primary key in the payload).
367
+ * @param entityClass - Root entity constructor
368
+ * @param payload - DTO payload containing column values and nested relations
369
+ * @param options - Graph save options
370
+ * @returns The root entity instance or null if not found
371
+ */
372
+ async updateGraph<TCtor extends EntityConstructor<object>>(
373
+ entityClass: TCtor,
374
+ payload: SaveGraphInputPayload<InstanceType<TCtor>>,
375
+ options?: SaveGraphSessionOptions
376
+ ): Promise<InstanceType<TCtor> | null> {
377
+ const table = getTableDefFromEntity(entityClass);
378
+ if (!table) {
379
+ throw new Error('Entity metadata has not been bootstrapped');
380
+ }
381
+ const primaryKey = findPrimaryKey(table);
382
+ const pkValue = (payload as Record<string, unknown>)[primaryKey];
383
+ if (pkValue === undefined || pkValue === null) {
384
+ throw new Error(`updateGraph requires a primary key value for "${primaryKey}"`);
385
+ }
386
+
387
+ const resolved = this.resolveSaveGraphOptions(options);
388
+ const { transactional = true, flush = false, ...graphOptions } = resolved;
389
+ const execute = async (): Promise<InstanceType<TCtor> | null> => {
390
+ const tracked = this.getEntity(table, pkValue as PrimaryKey) as InstanceType<TCtor> | undefined;
391
+ const existing = tracked ?? await this.find(entityClass, pkValue);
392
+ if (!existing) return null;
393
+ return saveGraphInternal(this, entityClass, payload, graphOptions);
394
+ };
395
+
396
+ if (!transactional) {
397
+ const result = await execute();
398
+ if (result && flush) {
399
+ await this.flush();
400
+ }
401
+ return result;
402
+ }
403
+ return this.transaction(() => execute());
404
+ }
405
+
406
+ /**
407
+ * Patches an existing entity with partial data (requires a primary key in the payload).
408
+ * Only the provided fields are updated; other fields remain unchanged.
409
+ * @param entityClass - Root entity constructor
410
+ * @param payload - Partial DTO payload containing column values and nested relations
411
+ * @param options - Graph save options
412
+ * @returns The patched entity instance or null if not found
413
+ */
414
+ async patchGraph<TCtor extends EntityConstructor<object>>(
415
+ entityClass: TCtor,
416
+ payload: PatchGraphInputPayload<InstanceType<TCtor>>,
417
+ options?: SaveGraphSessionOptions
418
+ ): Promise<InstanceType<TCtor> | null>;
419
+ async patchGraph<TCtor extends EntityConstructor<object>>(
420
+ entityClass: TCtor,
421
+ payload: Record<string, unknown>,
422
+ options?: SaveGraphSessionOptions
423
+ ): Promise<InstanceType<TCtor> | null> {
424
+ const table = getTableDefFromEntity(entityClass);
425
+ if (!table) {
426
+ throw new Error('Entity metadata has not been bootstrapped');
427
+ }
428
+ const primaryKey = findPrimaryKey(table);
429
+ const pkValue = payload[primaryKey];
430
+ if (pkValue === undefined || pkValue === null) {
431
+ throw new Error(`patchGraph requires a primary key value for "${primaryKey}"`);
432
+ }
433
+
434
+ const resolved = this.resolveSaveGraphOptions(options);
435
+ const { transactional = true, flush = false, ...graphOptions } = resolved;
436
+ const execute = async (): Promise<InstanceType<TCtor> | null> => {
437
+ const tracked = this.getEntity(table, pkValue as PrimaryKey) as InstanceType<TCtor> | undefined;
438
+ const existing = tracked ?? await this.find(entityClass, pkValue);
439
+ if (!existing) return null;
440
+ return patchGraphInternal(this, entityClass, existing, payload, graphOptions);
441
+ };
442
+
443
+ if (!transactional) {
444
+ const result = await execute();
445
+ if (result && flush) {
446
+ await this.flush();
447
+ }
448
+ return result;
449
+ }
450
+ return this.transaction(() => execute());
451
+ }
452
+
453
+ /**
454
+ * Persists an entity (either inserts or updates).
455
+ * @param entity - The entity to persist
456
+ * @throws If entity metadata is not bootstrapped
457
+ */
458
+ async persist(entity: object): Promise<void> {
459
+ if (this.unitOfWork.findTracked(entity)) {
460
+ return;
461
+ }
462
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
463
+ const table = getTableDefFromEntity((entity as { constructor: EntityConstructor }).constructor);
464
+ if (!table) {
465
+ throw new Error('Entity metadata has not been bootstrapped');
466
+ }
467
+ const primaryKey = findPrimaryKey(table);
468
+ const pkValue = (entity as Record<string, unknown>)[primaryKey];
469
+ if (pkValue !== undefined && pkValue !== null) {
470
+ this.trackManaged(table, pkValue as PrimaryKey, entity);
471
+ } else {
472
+ this.trackNew(table, entity);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Marks an entity for removal.
478
+ * @param entity - The entity to remove
479
+ */
480
+ async remove(entity: object): Promise<void> {
481
+ this.markRemoved(entity);
482
+ }
483
+
484
+ /**
485
+ * Flushes pending changes to the database without session hooks, relation processing, or domain events.
486
+ */
487
+ async flush(): Promise<void> {
488
+ await this.unitOfWork.flush();
489
+ }
490
+
491
+ /**
492
+ * Flushes pending changes with interceptors and relation processing.
493
+ */
494
+ private async flushWithHooks(): Promise<void> {
495
+ for (const interceptor of this.interceptors) {
496
+ await interceptor.beforeFlush?.(this);
497
+ }
498
+
499
+ await this.unitOfWork.flush();
500
+ await this.relationChanges.process();
501
+ await this.unitOfWork.flush();
502
+
503
+ for (const interceptor of this.interceptors) {
504
+ await interceptor.afterFlush?.(this);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Commits the current transaction.
510
+ */
511
+ async commit(): Promise<void> {
512
+ await runInTransaction(this.executor, async () => {
513
+ await this.flushWithHooks();
514
+ });
515
+
516
+ await this.domainEvents.dispatch(this.unitOfWork.getTracked(), this);
517
+ }
518
+
519
+ /**
520
+ * Executes a function within a transaction.
521
+ * @template T - The return type
522
+ * @param fn - The function to execute
523
+ * @returns The result of the function
524
+ * @throws If the transaction fails
525
+ */
526
+ async transaction<T>(fn: (session: OrmSession<E>) => Promise<T>): Promise<T> {
527
+ // If the executor can't do transactions, just run and commit once.
528
+ if (!this.executor.capabilities.transactions) {
529
+ const result = await fn(this);
530
+ await this.commit();
531
+ return result;
532
+ }
533
+
534
+ await this.executor.beginTransaction();
535
+ try {
536
+ const result = await fn(this);
537
+ await this.flushWithHooks();
538
+ await this.executor.commitTransaction();
539
+ await this.domainEvents.dispatch(this.unitOfWork.getTracked(), this);
540
+ return result;
541
+ } catch (err) {
542
+ await this.rollback();
543
+ throw err;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Rolls back the current transaction.
549
+ */
550
+ async rollback(): Promise<void> {
551
+ if (this.executor.capabilities.transactions) {
552
+ await this.executor.rollbackTransaction();
553
+ }
554
+ this.unitOfWork.reset();
555
+ this.relationChanges.reset();
556
+ }
557
+
558
+ /**
559
+ * Gets the execution context.
560
+ * @returns The execution context
561
+ */
562
+ getExecutionContext(): ExecutionContext {
563
+ return {
564
+ dialect: this.orm.dialect,
565
+ executor: this.executor,
566
+ interceptors: this.orm.interceptors
567
+ };
568
+ }
569
+
570
+ /**
571
+ * Gets the hydration context.
572
+ * @returns The hydration context
573
+ */
574
+ getHydrationContext(): HydrationContext<E> {
575
+ return {
576
+ identityMap: this.identityMap,
577
+ unitOfWork: this.unitOfWork,
578
+ domainEvents: this.domainEvents,
579
+ relationChanges: this.relationChanges,
580
+ entityContext: this
581
+ };
582
+ }
583
+
584
+ /**
585
+ * Merges session defaults with per-call saveGraph options.
586
+ * @param options - Per-call saveGraph options
587
+ * @returns Combined options with per-call values taking precedence
588
+ */
589
+ private resolveSaveGraphOptions(options?: SaveGraphSessionOptions): SaveGraphSessionOptions {
590
+ return { ...(this.saveGraphDefaults ?? {}), ...(options ?? {}) };
591
+ }
592
+ }
593
+
594
+ const buildRelationChangeEntry = (
595
+ root: unknown,
596
+ relationKey: RelationKey,
597
+ rootTable: TableDef,
598
+ relationName: string,
599
+ relation: RelationDef,
600
+ change: RelationChange<unknown>
601
+ ): RelationChangeEntry => ({
602
+ root,
603
+ relationKey,
604
+ rootTable,
605
+ relationName,
606
+ relation,
607
+ change
608
+ });
609
+