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