@expo/entity 0.61.0 → 0.63.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 (36) hide show
  1. package/build/src/EntityDatabaseAdapter.d.ts +4 -3
  2. package/build/src/EntityDatabaseAdapter.js +3 -5
  3. package/build/src/EntityInvalidationUtils.d.ts +2 -4
  4. package/build/src/EntityInvalidationUtils.js +1 -1
  5. package/build/src/EntityLoaderFactory.d.ts +1 -1
  6. package/build/src/EntityLoaderFactory.js +1 -1
  7. package/build/src/EntityMutationInfo.d.ts +31 -0
  8. package/build/src/EntityMutationInfo.js +12 -0
  9. package/build/src/EntityQueryContext.d.ts +52 -3
  10. package/build/src/EntityQueryContext.js +44 -5
  11. package/build/src/EntityQueryContextProvider.js +7 -2
  12. package/build/src/EntitySecondaryCacheLoader.d.ts +1 -2
  13. package/build/src/EntitySecondaryCacheLoader.js +1 -2
  14. package/build/src/ReadonlyEntity.d.ts +1 -1
  15. package/build/src/ViewerScopedEntityLoaderFactory.d.ts +1 -1
  16. package/build/src/errors/EntityInvalidFieldValueError.d.ts +2 -0
  17. package/build/src/errors/EntityInvalidFieldValueError.js +4 -0
  18. package/build/src/metrics/IEntityMetricsAdapter.d.ts +27 -4
  19. package/build/src/metrics/IEntityMetricsAdapter.js +27 -4
  20. package/package.json +3 -3
  21. package/src/EntityDatabaseAdapter.ts +5 -12
  22. package/src/EntityInvalidationUtils.ts +0 -17
  23. package/src/EntityLoaderFactory.ts +0 -2
  24. package/src/EntityMutationInfo.ts +31 -0
  25. package/src/EntityQueryContext.ts +64 -3
  26. package/src/EntityQueryContextProvider.ts +9 -3
  27. package/src/EntitySecondaryCacheLoader.ts +1 -2
  28. package/src/ReadonlyEntity.ts +1 -8
  29. package/src/ViewerScopedEntityLoaderFactory.ts +0 -1
  30. package/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts +0 -3
  31. package/src/__tests__/EntityDatabaseAdapter-test.ts +9 -10
  32. package/src/__tests__/EntityQueryContext-test.ts +84 -0
  33. package/src/errors/EntityInvalidFieldValueError.ts +5 -0
  34. package/src/errors/__tests__/EntityError-test.ts +2 -0
  35. package/src/metrics/IEntityMetricsAdapter.ts +24 -1
  36. package/src/utils/__testfixtures__/StubDatabaseAdapter.ts +3 -3
@@ -56,10 +56,11 @@ export declare abstract class EntityDatabaseAdapter<TFields extends Record<strin
56
56
  * @param idField - the field in the object that is the ID
57
57
  * @param id - the value of the ID field in the object
58
58
  * @param object - the object to update
59
- * @returns the updated object
60
59
  */
61
- updateAsync<K extends keyof TFields>(queryContext: EntityQueryContext, idField: K, id: any, object: Readonly<Partial<TFields>>): Promise<Readonly<TFields>>;
62
- protected abstract updateInternalAsync(queryInterface: any, tableName: string, tableIdField: string, id: any, object: object): Promise<object[]>;
60
+ updateAsync<K extends keyof TFields>(queryContext: EntityQueryContext, idField: K, id: any, object: Readonly<Partial<TFields>>): Promise<void>;
61
+ protected abstract updateInternalAsync(queryInterface: any, tableName: string, tableIdField: string, id: any, object: object): Promise<{
62
+ updatedRowCount: number;
63
+ }>;
63
64
  /**
64
65
  * Delete an object by ID.
65
66
  *
@@ -87,22 +87,20 @@ export class EntityDatabaseAdapter {
87
87
  * @param idField - the field in the object that is the ID
88
88
  * @param id - the value of the ID field in the object
89
89
  * @param object - the object to update
90
- * @returns the updated object
91
90
  */
92
91
  async updateAsync(queryContext, idField, id, object) {
93
92
  const idColumn = getDatabaseFieldForEntityField(this.entityConfiguration, idField);
94
93
  const dbObject = transformFieldsToDatabaseObject(this.entityConfiguration, this.fieldTransformerMap, object);
95
- const results = await this.updateInternalAsync(queryContext.getQueryInterface(), this.entityConfiguration.tableName, idColumn, id, dbObject);
96
- if (results.length > 1) {
94
+ const { updatedRowCount } = await this.updateInternalAsync(queryContext.getQueryInterface(), this.entityConfiguration.tableName, idColumn, id, dbObject);
95
+ if (updatedRowCount > 1) {
97
96
  // This should never happen with a properly implemented database adapter unless the underlying table has a non-unique
98
97
  // primary key column.
99
98
  throw new EntityDatabaseAdapterExcessiveUpdateResultError(`Excessive results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`);
100
99
  }
101
- else if (results.length === 0) {
100
+ else if (updatedRowCount === 0) {
102
101
  // This happens when the object to update does not exist. It may have been deleted by another process.
103
102
  throw new EntityDatabaseAdapterEmptyUpdateResultError(`Empty results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`);
104
103
  }
105
- return transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, results[0]);
106
104
  }
107
105
  /**
108
106
  * Delete an object by ID.
@@ -1,6 +1,4 @@
1
- import type { IEntityClass } from './Entity.ts';
2
1
  import type { EntityConfiguration } from './EntityConfiguration.ts';
3
- import type { EntityPrivacyPolicy } from './EntityPrivacyPolicy.ts';
4
2
  import type { EntityTransactionalQueryContext } from './EntityQueryContext.ts';
5
3
  import type { ReadonlyEntity } from './ReadonlyEntity.ts';
6
4
  import type { ViewerContext } from './ViewerContext.ts';
@@ -10,11 +8,11 @@ import type { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter.ts';
10
8
  * Entity invalidation utilities.
11
9
  * Methods are exposed publicly since in rare cases they may need to be called manually.
12
10
  */
13
- export declare class EntityInvalidationUtils<TFields extends Record<string, any>, TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>, TViewerContext extends ViewerContext, TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>, TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>, TSelectedFields extends keyof TFields> {
11
+ export declare class EntityInvalidationUtils<TFields extends Record<string, any>, TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>, TViewerContext extends ViewerContext, TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>, TSelectedFields extends keyof TFields> {
14
12
  private readonly entityConfiguration;
15
13
  private readonly dataManager;
16
14
  protected readonly metricsAdapter: IEntityMetricsAdapter;
17
- constructor(entityConfiguration: EntityConfiguration<TFields, TIDField>, _entityClass: IEntityClass<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>, dataManager: EntityDataManager<TFields, TIDField>, metricsAdapter: IEntityMetricsAdapter);
15
+ constructor(entityConfiguration: EntityConfiguration<TFields, TIDField>, dataManager: EntityDataManager<TFields, TIDField>, metricsAdapter: IEntityMetricsAdapter);
18
16
  private getKeyValuePairsFromObjectFields;
19
17
  /**
20
18
  * Invalidate all caches and local dataloaders for an entity's fields. Exposed primarily for internal use by EntityMutator.
@@ -7,7 +7,7 @@ export class EntityInvalidationUtils {
7
7
  entityConfiguration;
8
8
  dataManager;
9
9
  metricsAdapter;
10
- constructor(entityConfiguration, _entityClass, dataManager, metricsAdapter) {
10
+ constructor(entityConfiguration, dataManager, metricsAdapter) {
11
11
  this.entityConfiguration = entityConfiguration;
12
12
  this.dataManager = dataManager;
13
13
  this.metricsAdapter = metricsAdapter;
@@ -16,7 +16,7 @@ export declare class EntityLoaderFactory<TFields extends Record<string, any>, TI
16
16
  private readonly dataManager;
17
17
  protected readonly metricsAdapter: IEntityMetricsAdapter;
18
18
  constructor(entityCompanion: EntityCompanion<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>, dataManager: EntityDataManager<TFields, TIDField>, metricsAdapter: IEntityMetricsAdapter);
19
- invalidationUtils(): EntityInvalidationUtils<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>;
19
+ invalidationUtils(): EntityInvalidationUtils<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>;
20
20
  constructionUtils(viewerContext: TViewerContext, queryContext: EntityQueryContext, privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>): EntityConstructionUtils<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>;
21
21
  /**
22
22
  * Vend loader for loading an entity in a given query context.
@@ -14,7 +14,7 @@ export class EntityLoaderFactory {
14
14
  this.metricsAdapter = metricsAdapter;
15
15
  }
16
16
  invalidationUtils() {
17
- return new EntityInvalidationUtils(this.entityCompanion.entityCompanionDefinition.entityConfiguration, this.entityCompanion.entityCompanionDefinition.entityClass, this.dataManager, this.metricsAdapter);
17
+ return new EntityInvalidationUtils(this.entityCompanion.entityCompanionDefinition.entityConfiguration, this.dataManager, this.metricsAdapter);
18
18
  }
19
19
  constructionUtils(viewerContext, queryContext, privacyPolicyEvaluationContext) {
20
20
  return new EntityConstructionUtils(viewerContext, queryContext, privacyPolicyEvaluationContext, this.entityCompanion.entityCompanionDefinition.entityConfiguration, this.entityCompanion.entityCompanionDefinition.entityClass, this.entityCompanion.entityCompanionDefinition.entitySelectedFields, this.entityCompanion.privacyPolicy, this.metricsAdapter);
@@ -1,8 +1,20 @@
1
1
  import type { Entity } from './Entity.ts';
2
2
  import type { ViewerContext } from './ViewerContext.ts';
3
+ /**
4
+ * The type of mutation that is occurring to an entity.
5
+ */
3
6
  export declare enum EntityMutationType {
7
+ /**
8
+ * Create mutation.
9
+ */
4
10
  CREATE = 0,
11
+ /**
12
+ * Update mutation.
13
+ */
5
14
  UPDATE = 1,
15
+ /**
16
+ * Delete mutation.
17
+ */
6
18
  DELETE = 2
7
19
  }
8
20
  /**
@@ -19,13 +31,32 @@ export type EntityCascadingDeletionInfo = {
19
31
  cascadingDeleteCause: EntityCascadingDeletionInfo | null;
20
32
  };
21
33
  type EntityTriggerOrValidatorMutationInfo<TFields extends Record<string, any>, TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>, TViewerContext extends ViewerContext, TEntity extends Entity<TFields, TIDField, TViewerContext, TSelectedFields>, TSelectedFields extends keyof TFields = keyof TFields> = {
34
+ /**
35
+ * The type of mutation that invoked this trigger or validator.
36
+ */
22
37
  type: EntityMutationType.CREATE;
23
38
  } | {
39
+ /**
40
+ * The type of mutation that invoked this trigger or validator.
41
+ */
24
42
  type: EntityMutationType.UPDATE;
43
+ /**
44
+ * The previous value of the entity before the update.
45
+ */
25
46
  previousValue: TEntity;
47
+ /**
48
+ * If this update is part of a cascading deletion (cascade set null), this field will contain information about the cascade
49
+ * that caused this update. Otherwise, it will be null.
50
+ */
26
51
  cascadingDeleteCause: EntityCascadingDeletionInfo | null;
27
52
  } | {
53
+ /**
54
+ * The type of mutation that invoked this trigger or validator.
55
+ */
28
56
  type: EntityMutationType.DELETE;
57
+ /**
58
+ * If this delete is part of a cascading deletion, this field will contain information about the cascade that caused this cascading delete.
59
+ */
29
60
  cascadingDeleteCause: EntityCascadingDeletionInfo | null;
30
61
  };
31
62
  export type EntityValidatorMutationInfo<TFields extends Record<string, any>, TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>, TViewerContext extends ViewerContext, TEntity extends Entity<TFields, TIDField, TViewerContext, TSelectedFields>, TSelectedFields extends keyof TFields = keyof TFields> = EntityTriggerOrValidatorMutationInfo<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>;
@@ -1,6 +1,18 @@
1
+ /**
2
+ * The type of mutation that is occurring to an entity.
3
+ */
1
4
  export var EntityMutationType;
2
5
  (function (EntityMutationType) {
6
+ /**
7
+ * Create mutation.
8
+ */
3
9
  EntityMutationType[EntityMutationType["CREATE"] = 0] = "CREATE";
10
+ /**
11
+ * Update mutation.
12
+ */
4
13
  EntityMutationType[EntityMutationType["UPDATE"] = 1] = "UPDATE";
14
+ /**
15
+ * Delete mutation.
16
+ */
5
17
  EntityMutationType[EntityMutationType["DELETE"] = 2] = "DELETE";
6
18
  })(EntityMutationType || (EntityMutationType = {}));
@@ -1,20 +1,59 @@
1
1
  import type { EntityQueryContextProvider } from './EntityQueryContextProvider.ts';
2
2
  export type PostCommitCallback = (...args: any) => Promise<any>;
3
3
  export type PreCommitCallback = (queryContext: EntityTransactionalQueryContext, ...args: any) => Promise<any>;
4
+ /**
5
+ * Database transaction isolation level. Controls the visibility of changes made by
6
+ * concurrent transactions, trading off between consistency and performance.
7
+ */
4
8
  export declare enum TransactionIsolationLevel {
9
+ /**
10
+ * Each statement sees only data committed before it began. Default for most databases.
11
+ */
5
12
  READ_COMMITTED = "READ_COMMITTED",
13
+ /**
14
+ * All statements in the transaction see the same snapshot taken at the start of the transaction.
15
+ */
6
16
  REPEATABLE_READ = "REPEATABLE_READ",
17
+ /**
18
+ * Transactions execute as if they were run one at a time. Strongest guarantee but lowest throughput.
19
+ */
7
20
  SERIALIZABLE = "SERIALIZABLE"
8
21
  }
22
+ /**
23
+ * Controls DataLoader behavior within a transaction.
24
+ */
9
25
  export declare enum TransactionalDataLoaderMode {
26
+ /**
27
+ * Default mode where DataLoader is fully enabled, providing both batching and caching benefits within the transaction.
28
+ */
10
29
  ENABLED = "ENABLED",
30
+ /**
31
+ * DataLoader is enabled for batching queries together but does not cache results. Use this mode when you want to benefit from batching but need to ensure that each load reflects the most current database state, even within the same transaction.
32
+ */
11
33
  ENABLED_BATCH_ONLY = "ENABLED_BATCH_ONLY",
34
+ /**
35
+ * DataLoader is completely disabled for the transaction. Each load will directly query the database without any batching or caching. Use this mode when you need to ensure that every load reflects the most current database state and are not concerned about the performance benefits of batching or caching within the transaction.
36
+ */
12
37
  DISABLED = "DISABLED"
13
38
  }
39
+ /**
40
+ * Configuration options for running a transaction. This includes the isolation level and DataLoader mode for the transaction.
41
+ */
14
42
  export type TransactionConfig = {
43
+ /**
44
+ * Transaction isolation level. When omitted, the database default is used (typically READ_COMMITTED).
45
+ */
15
46
  isolationLevel?: TransactionIsolationLevel;
47
+ /**
48
+ * DataLoader mode for the transaction. When omitted, defaults to ENABLED.
49
+ */
16
50
  transactionalDataLoaderMode?: TransactionalDataLoaderMode;
17
51
  };
52
+ /**
53
+ * Resolved transaction configuration with all default values filled in.
54
+ * This is the configuration that is actually used for a transaction after applying defaults.
55
+ */
56
+ export type ResolvedTransactionConfig = Pick<TransactionConfig, 'isolationLevel'> & Required<Pick<TransactionConfig, 'transactionalDataLoaderMode'>>;
18
57
  /**
19
58
  * Entity framework representation of transactional and non-transactional database
20
59
  * query execution units.
@@ -52,7 +91,7 @@ export declare class EntityTransactionalQueryContext extends EntityQueryContext
52
91
  * @internal
53
92
  */
54
93
  readonly transactionId: string;
55
- readonly transactionalDataLoaderMode: TransactionalDataLoaderMode;
94
+ readonly transactionConfig: ResolvedTransactionConfig;
56
95
  /**
57
96
  * @internal
58
97
  */
@@ -64,7 +103,17 @@ export declare class EntityTransactionalQueryContext extends EntityQueryContext
64
103
  /**
65
104
  * @internal
66
105
  */
67
- transactionId: string, transactionalDataLoaderMode: TransactionalDataLoaderMode);
106
+ transactionId: string, transactionConfig: ResolvedTransactionConfig);
107
+ /**
108
+ * DataLoader mode for this transaction set at time of transaction creation, controlling DataLoader caching and batching behavior within the transaction.
109
+ */
110
+ get transactionalDataLoaderMode(): TransactionalDataLoaderMode;
111
+ /**
112
+ * Transaction isolation level for this transaction set at time of transaction creation.
113
+ * This controls the visibility of changes made by concurrent transactions.
114
+ * When undefined, the database default isolation level is used (typically READ_COMMITTED).
115
+ */
116
+ get isolationLevel(): TransactionIsolationLevel | undefined;
68
117
  /**
69
118
  * Schedule a pre-commit callback. These will be run within the transaction right before it is
70
119
  * committed, and will be run in the order specified. Ordering of callbacks scheduled with the
@@ -118,7 +167,7 @@ export declare class EntityNestedTransactionalQueryContext extends EntityTransac
118
167
  /**
119
168
  * @internal
120
169
  */
121
- parentQueryContext: EntityTransactionalQueryContext, entityQueryContextProvider: EntityQueryContextProvider, transactionId: string, transactionalDataLoaderMode: TransactionalDataLoaderMode);
170
+ parentQueryContext: EntityTransactionalQueryContext, entityQueryContextProvider: EntityQueryContextProvider, transactionId: string, transactionConfig: ResolvedTransactionConfig);
122
171
  isInNestedTransaction(): this is EntityNestedTransactionalQueryContext;
123
172
  appendPostCommitCallback(callback: PostCommitCallback): void;
124
173
  appendPostCommitInvalidationCallback(callback: PostCommitCallback): void;
@@ -1,14 +1,39 @@
1
1
  import assert from 'assert';
2
+ /**
3
+ * Database transaction isolation level. Controls the visibility of changes made by
4
+ * concurrent transactions, trading off between consistency and performance.
5
+ */
2
6
  export var TransactionIsolationLevel;
3
7
  (function (TransactionIsolationLevel) {
8
+ /**
9
+ * Each statement sees only data committed before it began. Default for most databases.
10
+ */
4
11
  TransactionIsolationLevel["READ_COMMITTED"] = "READ_COMMITTED";
12
+ /**
13
+ * All statements in the transaction see the same snapshot taken at the start of the transaction.
14
+ */
5
15
  TransactionIsolationLevel["REPEATABLE_READ"] = "REPEATABLE_READ";
16
+ /**
17
+ * Transactions execute as if they were run one at a time. Strongest guarantee but lowest throughput.
18
+ */
6
19
  TransactionIsolationLevel["SERIALIZABLE"] = "SERIALIZABLE";
7
20
  })(TransactionIsolationLevel || (TransactionIsolationLevel = {}));
21
+ /**
22
+ * Controls DataLoader behavior within a transaction.
23
+ */
8
24
  export var TransactionalDataLoaderMode;
9
25
  (function (TransactionalDataLoaderMode) {
26
+ /**
27
+ * Default mode where DataLoader is fully enabled, providing both batching and caching benefits within the transaction.
28
+ */
10
29
  TransactionalDataLoaderMode["ENABLED"] = "ENABLED";
30
+ /**
31
+ * DataLoader is enabled for batching queries together but does not cache results. Use this mode when you want to benefit from batching but need to ensure that each load reflects the most current database state, even within the same transaction.
32
+ */
11
33
  TransactionalDataLoaderMode["ENABLED_BATCH_ONLY"] = "ENABLED_BATCH_ONLY";
34
+ /**
35
+ * DataLoader is completely disabled for the transaction. Each load will directly query the database without any batching or caching. Use this mode when you need to ensure that every load reflects the most current database state and are not concerned about the performance benefits of batching or caching within the transaction.
36
+ */
12
37
  TransactionalDataLoaderMode["DISABLED"] = "DISABLED";
13
38
  })(TransactionalDataLoaderMode || (TransactionalDataLoaderMode = {}));
14
39
  /**
@@ -54,7 +79,7 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
54
79
  export class EntityTransactionalQueryContext extends EntityQueryContext {
55
80
  entityQueryContextProvider;
56
81
  transactionId;
57
- transactionalDataLoaderMode;
82
+ transactionConfig;
58
83
  /**
59
84
  * @internal
60
85
  */
@@ -66,11 +91,25 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
66
91
  /**
67
92
  * @internal
68
93
  */
69
- transactionId, transactionalDataLoaderMode) {
94
+ transactionId, transactionConfig) {
70
95
  super(queryInterface);
71
96
  this.entityQueryContextProvider = entityQueryContextProvider;
72
97
  this.transactionId = transactionId;
73
- this.transactionalDataLoaderMode = transactionalDataLoaderMode;
98
+ this.transactionConfig = transactionConfig;
99
+ }
100
+ /**
101
+ * DataLoader mode for this transaction set at time of transaction creation, controlling DataLoader caching and batching behavior within the transaction.
102
+ */
103
+ get transactionalDataLoaderMode() {
104
+ return this.transactionConfig.transactionalDataLoaderMode;
105
+ }
106
+ /**
107
+ * Transaction isolation level for this transaction set at time of transaction creation.
108
+ * This controls the visibility of changes made by concurrent transactions.
109
+ * When undefined, the database default isolation level is used (typically READ_COMMITTED).
110
+ */
111
+ get isolationLevel() {
112
+ return this.transactionConfig.isolationLevel;
74
113
  }
75
114
  /**
76
115
  * Schedule a pre-commit callback. These will be run within the transaction right before it is
@@ -153,8 +192,8 @@ export class EntityNestedTransactionalQueryContext extends EntityTransactionalQu
153
192
  /**
154
193
  * @internal
155
194
  */
156
- parentQueryContext, entityQueryContextProvider, transactionId, transactionalDataLoaderMode) {
157
- super(queryInterface, entityQueryContextProvider, transactionId, transactionalDataLoaderMode);
195
+ parentQueryContext, entityQueryContextProvider, transactionId, transactionConfig) {
196
+ super(queryInterface, entityQueryContextProvider, transactionId, transactionConfig);
158
197
  this.parentQueryContext = parentQueryContext;
159
198
  parentQueryContext.childQueryContexts.push(this);
160
199
  }
@@ -22,7 +22,12 @@ export class EntityQueryContextProvider {
22
22
  */
23
23
  async runInTransactionAsync(transactionScope, transactionConfig) {
24
24
  const [returnedValue, queryContext] = await this.createTransactionRunner(transactionConfig)(async (queryInterface) => {
25
- const queryContext = new EntityTransactionalQueryContext(queryInterface, this, randomUUID(), transactionConfig?.transactionalDataLoaderMode ?? this.defaultTransactionalDataLoaderMode());
25
+ const resolvedTransactionConfig = {
26
+ ...transactionConfig,
27
+ transactionalDataLoaderMode: transactionConfig?.transactionalDataLoaderMode ??
28
+ this.defaultTransactionalDataLoaderMode(),
29
+ };
30
+ const queryContext = new EntityTransactionalQueryContext(queryInterface, this, randomUUID(), resolvedTransactionConfig);
26
31
  const result = await transactionScope(queryContext);
27
32
  await queryContext.runPreCommitCallbacksAsync();
28
33
  return [result, queryContext];
@@ -38,7 +43,7 @@ export class EntityQueryContextProvider {
38
43
  */
39
44
  async runInNestedTransactionAsync(outerQueryContext, transactionScope) {
40
45
  const [returnedValue, innerQueryContext] = await this.createNestedTransactionRunner(outerQueryContext.getQueryInterface())(async (innerQueryInterface) => {
41
- const innerQueryContext = new EntityNestedTransactionalQueryContext(innerQueryInterface, outerQueryContext, this, randomUUID(), outerQueryContext.transactionalDataLoaderMode);
46
+ const innerQueryContext = new EntityNestedTransactionalQueryContext(innerQueryInterface, outerQueryContext, this, randomUUID(), outerQueryContext.transactionConfig);
42
47
  const result = await transactionScope(innerQueryContext);
43
48
  await innerQueryContext.runPreCommitCallbacksAsync();
44
49
  return [result, innerQueryContext];
@@ -32,8 +32,7 @@ export interface ISecondaryEntityCache<TFields extends Record<string, any>, TLoa
32
32
  * when the underlying data of a cache key could be stale.
33
33
  *
34
34
  * This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading
35
- * due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync` or
36
- * `loadManyByRawWhereClauseAsync`.
35
+ * due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync`.
37
36
  */
38
37
  export declare abstract class EntitySecondaryCacheLoader<TLoadParams, TFields extends Record<string, any>, TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>, TViewerContext extends ViewerContext, TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>, TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>, TSelectedFields extends keyof TFields = keyof TFields> {
39
38
  private readonly secondaryEntityCache;
@@ -7,8 +7,7 @@ import { mapMap } from "./utils/collections/maps.js";
7
7
  * when the underlying data of a cache key could be stale.
8
8
  *
9
9
  * This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading
10
- * due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync` or
11
- * `loadManyByRawWhereClauseAsync`.
10
+ * due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync`.
12
11
  */
13
12
  export class EntitySecondaryCacheLoader {
14
13
  secondaryEntityCache;
@@ -88,5 +88,5 @@ export declare abstract class ReadonlyEntity<TFields extends Record<string, any>
88
88
  * Utilities for entity invalidation.
89
89
  * Call these manually to keep entity cache consistent when performing operations outside of the entity framework.
90
90
  */
91
- static invalidationUtils<TMFields extends object, TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>, TMViewerContext extends ViewerContext, TMViewerContext2 extends TMViewerContext, TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>, TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMIDField, TMViewerContext, TMEntity, TMSelectedFields>, TMSelectedFields extends keyof TMFields = keyof TMFields>(this: IEntityClass<TMFields, TMIDField, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields>, viewerContext: TMViewerContext2): EntityInvalidationUtils<TMFields, TMIDField, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields>;
91
+ static invalidationUtils<TMFields extends object, TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>, TMViewerContext extends ViewerContext, TMViewerContext2 extends TMViewerContext, TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>, TMPrivacyPolicy extends EntityPrivacyPolicy<TMFields, TMIDField, TMViewerContext, TMEntity, TMSelectedFields>, TMSelectedFields extends keyof TMFields = keyof TMFields>(this: IEntityClass<TMFields, TMIDField, TMViewerContext, TMEntity, TMPrivacyPolicy, TMSelectedFields>, viewerContext: TMViewerContext2): EntityInvalidationUtils<TMFields, TMIDField, TMViewerContext, TMEntity, TMSelectedFields>;
92
92
  }
@@ -13,7 +13,7 @@ export declare class ViewerScopedEntityLoaderFactory<TFields extends Record<stri
13
13
  private readonly entityLoaderFactory;
14
14
  private readonly viewerContext;
15
15
  constructor(entityLoaderFactory: EntityLoaderFactory<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>, viewerContext: TViewerContext);
16
- invalidationUtils(): EntityInvalidationUtils<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>;
16
+ invalidationUtils(): EntityInvalidationUtils<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>;
17
17
  constructionUtils(queryContext: EntityQueryContext, privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>): EntityConstructionUtils<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>;
18
18
  forLoad(queryContext: EntityQueryContext, privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>): AuthorizationResultBasedEntityLoader<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>;
19
19
  }
@@ -9,5 +9,7 @@ import { EntityError, EntityErrorCode, EntityErrorState } from './EntityError.ts
9
9
  export declare class EntityInvalidFieldValueError<TFields extends Record<string, any>, TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>, TViewerContext extends ViewerContext, TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>, TPrivacyPolicy extends EntityPrivacyPolicy<TFields, TIDField, TViewerContext, TEntity, TSelectedFields>, N extends keyof TFields, TSelectedFields extends keyof TFields = keyof TFields> extends EntityError {
10
10
  get state(): EntityErrorState.PERMANENT;
11
11
  get code(): EntityErrorCode.ERR_ENTITY_INVALID_FIELD_VALUE;
12
+ readonly fieldName: N;
13
+ readonly fieldValue: TFields[N] | undefined;
12
14
  constructor(entityClass: IEntityClass<TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields>, fieldName: N, fieldValue?: TFields[N]);
13
15
  }
@@ -12,7 +12,11 @@ export class EntityInvalidFieldValueError extends EntityError {
12
12
  get code() {
13
13
  return EntityErrorCode.ERR_ENTITY_INVALID_FIELD_VALUE;
14
14
  }
15
+ fieldName;
16
+ fieldValue;
15
17
  constructor(entityClass, fieldName, fieldValue) {
16
18
  super(`Entity field not valid: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
19
+ this.fieldName = fieldName;
20
+ this.fieldValue = fieldValue;
17
21
  }
18
22
  }
@@ -1,12 +1,29 @@
1
1
  import type { EntityAuthorizationAction, EntityPrivacyPolicyEvaluationMode } from '../EntityPrivacyPolicy.ts';
2
2
  import type { EntityLoadMethodType } from '../internal/EntityLoadInterfaces.ts';
3
+ /**
4
+ * The type of the load method being called.
5
+ */
3
6
  export declare enum EntityMetricsLoadType {
7
+ /**
8
+ * Standard EntityLoader load (the methods from EnforcingEntityLoader or AuthorizationResultBasedEntityLoader).
9
+ */
4
10
  LOAD_MANY = 0,
11
+ /**
12
+ * Knex loader load using loadManyByFieldEqualityConjunctionAsync.
13
+ */
5
14
  LOAD_MANY_EQUALITY_CONJUNCTION = 1,
6
- LOAD_MANY_RAW = 2,
7
- LOAD_MANY_SQL = 3,
8
- LOAD_ONE = 4,
9
- LOAD_PAGE = 5
15
+ /**
16
+ * Knex loader load using loadManyBySQL.
17
+ */
18
+ LOAD_MANY_SQL = 2,
19
+ /**
20
+ * Internal data manager load via database adapter method loadOneEqualingAsync.
21
+ */
22
+ LOAD_ONE = 3,
23
+ /**
24
+ * Knex loader load using loadPageAsync.
25
+ */
26
+ LOAD_PAGE = 4
10
27
  }
11
28
  /**
12
29
  * Event about a single call to an EntityLoader method.
@@ -33,6 +50,9 @@ export interface EntityMetricsLoadEvent {
33
50
  */
34
51
  count: number;
35
52
  }
53
+ /**
54
+ * The type of mutation being performed.
55
+ */
36
56
  export declare enum EntityMetricsMutationType {
37
57
  CREATE = 0,
38
58
  UPDATE = 1,
@@ -56,6 +76,9 @@ export interface EntityMetricsMutationEvent {
56
76
  */
57
77
  duration: number;
58
78
  }
79
+ /**
80
+ * Type used to delineate dataloader, cache, and database load counts in EntityDataManager.
81
+ */
59
82
  export declare enum IncrementLoadCountEventType {
60
83
  /**
61
84
  * Type for when a dataloader load is initiated via the standard load methods
@@ -1,18 +1,41 @@
1
+ /**
2
+ * The type of the load method being called.
3
+ */
1
4
  export var EntityMetricsLoadType;
2
5
  (function (EntityMetricsLoadType) {
6
+ /**
7
+ * Standard EntityLoader load (the methods from EnforcingEntityLoader or AuthorizationResultBasedEntityLoader).
8
+ */
3
9
  EntityMetricsLoadType[EntityMetricsLoadType["LOAD_MANY"] = 0] = "LOAD_MANY";
10
+ /**
11
+ * Knex loader load using loadManyByFieldEqualityConjunctionAsync.
12
+ */
4
13
  EntityMetricsLoadType[EntityMetricsLoadType["LOAD_MANY_EQUALITY_CONJUNCTION"] = 1] = "LOAD_MANY_EQUALITY_CONJUNCTION";
5
- EntityMetricsLoadType[EntityMetricsLoadType["LOAD_MANY_RAW"] = 2] = "LOAD_MANY_RAW";
6
- EntityMetricsLoadType[EntityMetricsLoadType["LOAD_MANY_SQL"] = 3] = "LOAD_MANY_SQL";
7
- EntityMetricsLoadType[EntityMetricsLoadType["LOAD_ONE"] = 4] = "LOAD_ONE";
8
- EntityMetricsLoadType[EntityMetricsLoadType["LOAD_PAGE"] = 5] = "LOAD_PAGE";
14
+ /**
15
+ * Knex loader load using loadManyBySQL.
16
+ */
17
+ EntityMetricsLoadType[EntityMetricsLoadType["LOAD_MANY_SQL"] = 2] = "LOAD_MANY_SQL";
18
+ /**
19
+ * Internal data manager load via database adapter method loadOneEqualingAsync.
20
+ */
21
+ EntityMetricsLoadType[EntityMetricsLoadType["LOAD_ONE"] = 3] = "LOAD_ONE";
22
+ /**
23
+ * Knex loader load using loadPageAsync.
24
+ */
25
+ EntityMetricsLoadType[EntityMetricsLoadType["LOAD_PAGE"] = 4] = "LOAD_PAGE";
9
26
  })(EntityMetricsLoadType || (EntityMetricsLoadType = {}));
27
+ /**
28
+ * The type of mutation being performed.
29
+ */
10
30
  export var EntityMetricsMutationType;
11
31
  (function (EntityMetricsMutationType) {
12
32
  EntityMetricsMutationType[EntityMetricsMutationType["CREATE"] = 0] = "CREATE";
13
33
  EntityMetricsMutationType[EntityMetricsMutationType["UPDATE"] = 1] = "UPDATE";
14
34
  EntityMetricsMutationType[EntityMetricsMutationType["DELETE"] = 2] = "DELETE";
15
35
  })(EntityMetricsMutationType || (EntityMetricsMutationType = {}));
36
+ /**
37
+ * Type used to delineate dataloader, cache, and database load counts in EntityDataManager.
38
+ */
16
39
  export var IncrementLoadCountEventType;
17
40
  (function (IncrementLoadCountEventType) {
18
41
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/entity",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "description": "A privacy-first data model",
5
5
  "files": [
6
6
  "build",
@@ -33,7 +33,7 @@
33
33
  "invariant": "^2.2.4"
34
34
  },
35
35
  "devDependencies": {
36
- "@jest/globals": "30.2.0",
36
+ "@jest/globals": "30.3.0",
37
37
  "@types/invariant": "2.2.37",
38
38
  "@types/lodash-es": "4.17.12",
39
39
  "@types/node": "24.12.0",
@@ -42,5 +42,5 @@
42
42
  "typescript": "5.9.3",
43
43
  "uuid": "13.0.0"
44
44
  },
45
- "gitHead": "a6f1dd723262cb1c3e8022d3a8799c17602cd15b"
45
+ "gitHead": "dbd1cc847952754acc0eb407165cbb7b8350d2df"
46
46
  }
@@ -202,21 +202,20 @@ export abstract class EntityDatabaseAdapter<
202
202
  * @param idField - the field in the object that is the ID
203
203
  * @param id - the value of the ID field in the object
204
204
  * @param object - the object to update
205
- * @returns the updated object
206
205
  */
207
206
  async updateAsync<K extends keyof TFields>(
208
207
  queryContext: EntityQueryContext,
209
208
  idField: K,
210
209
  id: any,
211
210
  object: Readonly<Partial<TFields>>,
212
- ): Promise<Readonly<TFields>> {
211
+ ): Promise<void> {
213
212
  const idColumn = getDatabaseFieldForEntityField(this.entityConfiguration, idField);
214
213
  const dbObject = transformFieldsToDatabaseObject(
215
214
  this.entityConfiguration,
216
215
  this.fieldTransformerMap,
217
216
  object,
218
217
  );
219
- const results = await this.updateInternalAsync(
218
+ const { updatedRowCount } = await this.updateInternalAsync(
220
219
  queryContext.getQueryInterface(),
221
220
  this.entityConfiguration.tableName,
222
221
  idColumn,
@@ -224,24 +223,18 @@ export abstract class EntityDatabaseAdapter<
224
223
  dbObject,
225
224
  );
226
225
 
227
- if (results.length > 1) {
226
+ if (updatedRowCount > 1) {
228
227
  // This should never happen with a properly implemented database adapter unless the underlying table has a non-unique
229
228
  // primary key column.
230
229
  throw new EntityDatabaseAdapterExcessiveUpdateResultError(
231
230
  `Excessive results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`,
232
231
  );
233
- } else if (results.length === 0) {
232
+ } else if (updatedRowCount === 0) {
234
233
  // This happens when the object to update does not exist. It may have been deleted by another process.
235
234
  throw new EntityDatabaseAdapterEmptyUpdateResultError(
236
235
  `Empty results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`,
237
236
  );
238
237
  }
239
-
240
- return transformDatabaseObjectToFields(
241
- this.entityConfiguration,
242
- this.fieldTransformerMap,
243
- results[0]!,
244
- );
245
238
  }
246
239
 
247
240
  protected abstract updateInternalAsync(
@@ -250,7 +243,7 @@ export abstract class EntityDatabaseAdapter<
250
243
  tableIdField: string,
251
244
  id: any,
252
245
  object: object,
253
- ): Promise<object[]>;
246
+ ): Promise<{ updatedRowCount: number }>;
254
247
 
255
248
  /**
256
249
  * Delete an object by ID.
@@ -1,6 +1,4 @@
1
- import type { IEntityClass } from './Entity.ts';
2
1
  import type { EntityConfiguration } from './EntityConfiguration.ts';
3
- import type { EntityPrivacyPolicy } from './EntityPrivacyPolicy.ts';
4
2
  import type { EntityTransactionalQueryContext } from './EntityQueryContext.ts';
5
3
  import type { ReadonlyEntity } from './ReadonlyEntity.ts';
6
4
  import type { ViewerContext } from './ViewerContext.ts';
@@ -18,25 +16,10 @@ export class EntityInvalidationUtils<
18
16
  TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
19
17
  TViewerContext extends ViewerContext,
20
18
  TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
21
- TPrivacyPolicy extends EntityPrivacyPolicy<
22
- TFields,
23
- TIDField,
24
- TViewerContext,
25
- TEntity,
26
- TSelectedFields
27
- >,
28
19
  TSelectedFields extends keyof TFields,
29
20
  > {
30
21
  constructor(
31
22
  private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
32
- _entityClass: IEntityClass<
33
- TFields,
34
- TIDField,
35
- TViewerContext,
36
- TEntity,
37
- TPrivacyPolicy,
38
- TSelectedFields
39
- >,
40
23
  private readonly dataManager: EntityDataManager<TFields, TIDField>,
41
24
  protected readonly metricsAdapter: IEntityMetricsAdapter,
42
25
  ) {}
@@ -47,12 +47,10 @@ export class EntityLoaderFactory<
47
47
  TIDField,
48
48
  TViewerContext,
49
49
  TEntity,
50
- TPrivacyPolicy,
51
50
  TSelectedFields
52
51
  > {
53
52
  return new EntityInvalidationUtils(
54
53
  this.entityCompanion.entityCompanionDefinition.entityConfiguration,
55
- this.entityCompanion.entityCompanionDefinition.entityClass,
56
54
  this.dataManager,
57
55
  this.metricsAdapter,
58
56
  );
@@ -1,9 +1,21 @@
1
1
  import type { Entity } from './Entity.ts';
2
2
  import type { ViewerContext } from './ViewerContext.ts';
3
3
 
4
+ /**
5
+ * The type of mutation that is occurring to an entity.
6
+ */
4
7
  export enum EntityMutationType {
8
+ /**
9
+ * Create mutation.
10
+ */
5
11
  CREATE,
12
+ /**
13
+ * Update mutation.
14
+ */
6
15
  UPDATE,
16
+ /**
17
+ * Delete mutation.
18
+ */
7
19
  DELETE,
8
20
  }
9
21
 
@@ -30,15 +42,34 @@ type EntityTriggerOrValidatorMutationInfo<
30
42
  TSelectedFields extends keyof TFields = keyof TFields,
31
43
  > =
32
44
  | {
45
+ /**
46
+ * The type of mutation that invoked this trigger or validator.
47
+ */
33
48
  type: EntityMutationType.CREATE;
34
49
  }
35
50
  | {
51
+ /**
52
+ * The type of mutation that invoked this trigger or validator.
53
+ */
36
54
  type: EntityMutationType.UPDATE;
55
+ /**
56
+ * The previous value of the entity before the update.
57
+ */
37
58
  previousValue: TEntity;
59
+ /**
60
+ * If this update is part of a cascading deletion (cascade set null), this field will contain information about the cascade
61
+ * that caused this update. Otherwise, it will be null.
62
+ */
38
63
  cascadingDeleteCause: EntityCascadingDeletionInfo | null;
39
64
  }
40
65
  | {
66
+ /**
67
+ * The type of mutation that invoked this trigger or validator.
68
+ */
41
69
  type: EntityMutationType.DELETE;
70
+ /**
71
+ * If this delete is part of a cascading deletion, this field will contain information about the cascade that caused this cascading delete.
72
+ */
42
73
  cascadingDeleteCause: EntityCascadingDeletionInfo | null;
43
74
  };
44
75
 
@@ -8,23 +8,68 @@ export type PreCommitCallback = (
8
8
  ...args: any
9
9
  ) => Promise<any>;
10
10
 
11
+ /**
12
+ * Database transaction isolation level. Controls the visibility of changes made by
13
+ * concurrent transactions, trading off between consistency and performance.
14
+ */
11
15
  export enum TransactionIsolationLevel {
16
+ /**
17
+ * Each statement sees only data committed before it began. Default for most databases.
18
+ */
12
19
  READ_COMMITTED = 'READ_COMMITTED',
20
+
21
+ /**
22
+ * All statements in the transaction see the same snapshot taken at the start of the transaction.
23
+ */
13
24
  REPEATABLE_READ = 'REPEATABLE_READ',
25
+
26
+ /**
27
+ * Transactions execute as if they were run one at a time. Strongest guarantee but lowest throughput.
28
+ */
14
29
  SERIALIZABLE = 'SERIALIZABLE',
15
30
  }
16
31
 
32
+ /**
33
+ * Controls DataLoader behavior within a transaction.
34
+ */
17
35
  export enum TransactionalDataLoaderMode {
36
+ /**
37
+ * Default mode where DataLoader is fully enabled, providing both batching and caching benefits within the transaction.
38
+ */
18
39
  ENABLED = 'ENABLED',
40
+
41
+ /**
42
+ * DataLoader is enabled for batching queries together but does not cache results. Use this mode when you want to benefit from batching but need to ensure that each load reflects the most current database state, even within the same transaction.
43
+ */
19
44
  ENABLED_BATCH_ONLY = 'ENABLED_BATCH_ONLY',
45
+
46
+ /**
47
+ * DataLoader is completely disabled for the transaction. Each load will directly query the database without any batching or caching. Use this mode when you need to ensure that every load reflects the most current database state and are not concerned about the performance benefits of batching or caching within the transaction.
48
+ */
20
49
  DISABLED = 'DISABLED',
21
50
  }
22
51
 
52
+ /**
53
+ * Configuration options for running a transaction. This includes the isolation level and DataLoader mode for the transaction.
54
+ */
23
55
  export type TransactionConfig = {
56
+ /**
57
+ * Transaction isolation level. When omitted, the database default is used (typically READ_COMMITTED).
58
+ */
24
59
  isolationLevel?: TransactionIsolationLevel;
60
+ /**
61
+ * DataLoader mode for the transaction. When omitted, defaults to ENABLED.
62
+ */
25
63
  transactionalDataLoaderMode?: TransactionalDataLoaderMode;
26
64
  };
27
65
 
66
+ /**
67
+ * Resolved transaction configuration with all default values filled in.
68
+ * This is the configuration that is actually used for a transaction after applying defaults.
69
+ */
70
+ export type ResolvedTransactionConfig = Pick<TransactionConfig, 'isolationLevel'> &
71
+ Required<Pick<TransactionConfig, 'transactionalDataLoaderMode'>>;
72
+
28
73
  /**
29
74
  * Entity framework representation of transactional and non-transactional database
30
75
  * query execution units.
@@ -99,11 +144,27 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
99
144
  * @internal
100
145
  */
101
146
  readonly transactionId: string,
102
- public readonly transactionalDataLoaderMode: TransactionalDataLoaderMode,
147
+ public readonly transactionConfig: ResolvedTransactionConfig,
103
148
  ) {
104
149
  super(queryInterface);
105
150
  }
106
151
 
152
+ /**
153
+ * DataLoader mode for this transaction set at time of transaction creation, controlling DataLoader caching and batching behavior within the transaction.
154
+ */
155
+ get transactionalDataLoaderMode(): TransactionalDataLoaderMode {
156
+ return this.transactionConfig.transactionalDataLoaderMode;
157
+ }
158
+
159
+ /**
160
+ * Transaction isolation level for this transaction set at time of transaction creation.
161
+ * This controls the visibility of changes made by concurrent transactions.
162
+ * When undefined, the database default isolation level is used (typically READ_COMMITTED).
163
+ */
164
+ get isolationLevel(): TransactionIsolationLevel | undefined {
165
+ return this.transactionConfig.isolationLevel;
166
+ }
167
+
107
168
  /**
108
169
  * Schedule a pre-commit callback. These will be run within the transaction right before it is
109
170
  * committed, and will be run in the order specified. Ordering of callbacks scheduled with the
@@ -214,9 +275,9 @@ export class EntityNestedTransactionalQueryContext extends EntityTransactionalQu
214
275
  readonly parentQueryContext: EntityTransactionalQueryContext,
215
276
  entityQueryContextProvider: EntityQueryContextProvider,
216
277
  transactionId: string,
217
- transactionalDataLoaderMode: TransactionalDataLoaderMode,
278
+ transactionConfig: ResolvedTransactionConfig,
218
279
  ) {
219
- super(queryInterface, entityQueryContextProvider, transactionId, transactionalDataLoaderMode);
280
+ super(queryInterface, entityQueryContextProvider, transactionId, transactionConfig);
220
281
  parentQueryContext.childQueryContexts.push(this);
221
282
  }
222
283
 
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
- import type { TransactionConfig } from './EntityQueryContext.ts';
3
+ import type { ResolvedTransactionConfig, TransactionConfig } from './EntityQueryContext.ts';
4
4
  import {
5
5
  EntityNestedTransactionalQueryContext,
6
6
  EntityNonTransactionalQueryContext,
@@ -53,11 +53,17 @@ export abstract class EntityQueryContextProvider {
53
53
  const [returnedValue, queryContext] = await this.createTransactionRunner<
54
54
  [T, EntityTransactionalQueryContext]
55
55
  >(transactionConfig)(async (queryInterface) => {
56
+ const resolvedTransactionConfig: ResolvedTransactionConfig = {
57
+ ...transactionConfig,
58
+ transactionalDataLoaderMode:
59
+ transactionConfig?.transactionalDataLoaderMode ??
60
+ this.defaultTransactionalDataLoaderMode(),
61
+ };
56
62
  const queryContext = new EntityTransactionalQueryContext(
57
63
  queryInterface,
58
64
  this,
59
65
  randomUUID(),
60
- transactionConfig?.transactionalDataLoaderMode ?? this.defaultTransactionalDataLoaderMode(),
66
+ resolvedTransactionConfig,
61
67
  );
62
68
  const result = await transactionScope(queryContext);
63
69
  await queryContext.runPreCommitCallbacksAsync();
@@ -85,7 +91,7 @@ export abstract class EntityQueryContextProvider {
85
91
  outerQueryContext,
86
92
  this,
87
93
  randomUUID(),
88
- outerQueryContext.transactionalDataLoaderMode,
94
+ outerQueryContext.transactionConfig,
89
95
  );
90
96
  const result = await transactionScope(innerQueryContext);
91
97
  await innerQueryContext.runPreCommitCallbacksAsync();
@@ -42,8 +42,7 @@ export interface ISecondaryEntityCache<TFields extends Record<string, any>, TLoa
42
42
  * when the underlying data of a cache key could be stale.
43
43
  *
44
44
  * This is most commonly used to further optimize hot paths that cannot make use of normal entity cache loading
45
- * due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync` or
46
- * `loadManyByRawWhereClauseAsync`.
45
+ * due to use of a non-unique-field-based EntityLoader method like `loadManyByFieldEqualityConjunctionAsync`.
47
46
  */
48
47
  export abstract class EntitySecondaryCacheLoader<
49
48
  TLoadParams,
@@ -257,14 +257,7 @@ export abstract class ReadonlyEntity<
257
257
  TMSelectedFields
258
258
  >,
259
259
  viewerContext: TMViewerContext2,
260
- ): EntityInvalidationUtils<
261
- TMFields,
262
- TMIDField,
263
- TMViewerContext,
264
- TMEntity,
265
- TMPrivacyPolicy,
266
- TMSelectedFields
267
- > {
260
+ ): EntityInvalidationUtils<TMFields, TMIDField, TMViewerContext, TMEntity, TMSelectedFields> {
268
261
  return viewerContext
269
262
  .getViewerScopedEntityCompanionForClass(this)
270
263
  .getLoaderFactory()
@@ -44,7 +44,6 @@ export class ViewerScopedEntityLoaderFactory<
44
44
  TIDField,
45
45
  TViewerContext,
46
46
  TEntity,
47
- TPrivacyPolicy,
48
47
  TSelectedFields
49
48
  > {
50
49
  return this.entityLoaderFactory.invalidationUtils();
@@ -409,7 +409,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
409
409
  const id1 = uuidv4();
410
410
  const invalidationUtils = new EntityInvalidationUtils(
411
411
  testEntityConfiguration,
412
- TestEntity,
413
412
  dataManagerInstance,
414
413
  metricsAdapter,
415
414
  );
@@ -488,7 +487,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
488
487
 
489
488
  const invalidationUtils = new EntityInvalidationUtils(
490
489
  testEntityConfiguration,
491
- TestEntity,
492
490
  dataManagerInstance,
493
491
  metricsAdapter,
494
492
  );
@@ -560,7 +558,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
560
558
  await new StubQueryContextProvider().runInTransactionAsync(async (queryContext) => {
561
559
  const invalidationUtils = new EntityInvalidationUtils(
562
560
  testEntityConfiguration,
563
- TestEntity,
564
561
  dataManagerInstance,
565
562
  metricsAdapter,
566
563
  );
@@ -23,20 +23,20 @@ class TestEntityDatabaseAdapter extends EntityDatabaseAdapter<TestFields, 'custo
23
23
  private readonly fetchResults: object[];
24
24
  private readonly fetchOneResult: object | null;
25
25
  private readonly insertResults: object[];
26
- private readonly updateResults: object[];
26
+ private readonly updateResults: { updatedRowCount: number };
27
27
  private readonly deleteCount: number;
28
28
 
29
29
  constructor({
30
30
  fetchResults = [],
31
31
  fetchOneResult = null,
32
32
  insertResults = [],
33
- updateResults = [],
33
+ updateResults = { updatedRowCount: 0 },
34
34
  deleteCount = 0,
35
35
  }: {
36
36
  fetchResults?: object[];
37
37
  fetchOneResult?: object | null;
38
38
  insertResults?: object[];
39
- updateResults?: object[];
39
+ updateResults?: { updatedRowCount: number };
40
40
  deleteCount?: number;
41
41
  }) {
42
42
  super(testEntityConfiguration);
@@ -83,7 +83,7 @@ class TestEntityDatabaseAdapter extends EntityDatabaseAdapter<TestFields, 'custo
83
83
  _tableIdField: string,
84
84
  _id: any,
85
85
  _object: object,
86
- ): Promise<object[]> {
86
+ ): Promise<{ updatedRowCount: number }> {
87
87
  return this.updateResults;
88
88
  }
89
89
 
@@ -256,16 +256,15 @@ describe(EntityDatabaseAdapter, () => {
256
256
  });
257
257
 
258
258
  describe('updateAsync', () => {
259
- it('transforms object', async () => {
259
+ it('succeeds when one row updated', async () => {
260
260
  const queryContext = instance(mock(EntityQueryContext));
261
- const adapter = new TestEntityDatabaseAdapter({ updateResults: [{ string_field: 'hello' }] });
262
- const result = await adapter.updateAsync(queryContext, 'customIdField', 'wat', {});
263
- expect(result).toEqual({ stringField: 'hello' });
261
+ const adapter = new TestEntityDatabaseAdapter({ updateResults: { updatedRowCount: 1 } });
262
+ await adapter.updateAsync(queryContext, 'customIdField', 'wat', {});
264
263
  });
265
264
 
266
265
  it('throws when update result count zero', async () => {
267
266
  const queryContext = instance(mock(EntityQueryContext));
268
- const adapter = new TestEntityDatabaseAdapter({ updateResults: [] });
267
+ const adapter = new TestEntityDatabaseAdapter({ updateResults: { updatedRowCount: 0 } });
269
268
  await expect(adapter.updateAsync(queryContext, 'customIdField', 'wat', {})).rejects.toThrow(
270
269
  EntityDatabaseAdapterEmptyUpdateResultError,
271
270
  );
@@ -274,7 +273,7 @@ describe(EntityDatabaseAdapter, () => {
274
273
  it('throws when update result count greater than 1', async () => {
275
274
  const queryContext = instance(mock(EntityQueryContext));
276
275
  const adapter = new TestEntityDatabaseAdapter({
277
- updateResults: [{ string_field: 'hello' }, { string_field: 'hello2' }],
276
+ updateResults: { updatedRowCount: 2 },
278
277
  });
279
278
  await expect(adapter.updateAsync(queryContext, 'customIdField', 'wat', {})).rejects.toThrow(
280
279
  EntityDatabaseAdapterExcessiveUpdateResultError,
@@ -203,6 +203,90 @@ describe(EntityQueryContext, () => {
203
203
  },
204
204
  );
205
205
  });
206
+
207
+ it('exposes isolationLevel on the query context', async () => {
208
+ const companionProvider = createUnitTestEntityCompanionProvider();
209
+ const viewerContext = new ViewerContext(companionProvider);
210
+
211
+ await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
212
+ 'postgres',
213
+ async (queryContext) => {
214
+ assert(queryContext.isInTransaction());
215
+ expect(queryContext.isolationLevel).toBe(TransactionIsolationLevel.SERIALIZABLE);
216
+ },
217
+ { isolationLevel: TransactionIsolationLevel.SERIALIZABLE },
218
+ );
219
+
220
+ await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
221
+ 'postgres',
222
+ async (queryContext) => {
223
+ assert(queryContext.isInTransaction());
224
+ expect(queryContext.isolationLevel).toBe(TransactionIsolationLevel.REPEATABLE_READ);
225
+ },
226
+ { isolationLevel: TransactionIsolationLevel.REPEATABLE_READ },
227
+ );
228
+
229
+ await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
230
+ 'postgres',
231
+ async (queryContext) => {
232
+ assert(queryContext.isInTransaction());
233
+ expect(queryContext.isolationLevel).toBeUndefined();
234
+ },
235
+ );
236
+ });
237
+
238
+ it('exposes the full resolved transactionConfig on the query context', async () => {
239
+ const companionProvider = createUnitTestEntityCompanionProvider();
240
+ const viewerContext = new ViewerContext(companionProvider);
241
+
242
+ await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
243
+ 'postgres',
244
+ async (queryContext) => {
245
+ assert(queryContext.isInTransaction());
246
+ expect(queryContext.transactionConfig).toEqual({
247
+ isolationLevel: TransactionIsolationLevel.SERIALIZABLE,
248
+ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED,
249
+ });
250
+ },
251
+ {
252
+ isolationLevel: TransactionIsolationLevel.SERIALIZABLE,
253
+ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED,
254
+ },
255
+ );
256
+
257
+ await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
258
+ 'postgres',
259
+ async (queryContext) => {
260
+ assert(queryContext.isInTransaction());
261
+ expect(queryContext.transactionConfig).toEqual({
262
+ transactionalDataLoaderMode: TransactionalDataLoaderMode.ENABLED,
263
+ });
264
+ },
265
+ );
266
+ });
267
+
268
+ it('propagates transactionConfig to nested transactions', async () => {
269
+ const companionProvider = createUnitTestEntityCompanionProvider();
270
+ const viewerContext = new ViewerContext(companionProvider);
271
+
272
+ await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
273
+ 'postgres',
274
+ async (queryContext) => {
275
+ assert(queryContext.isInTransaction());
276
+ await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
277
+ expect(innerQueryContext.transactionConfig).toEqual(queryContext.transactionConfig);
278
+ expect(innerQueryContext.isolationLevel).toBe(TransactionIsolationLevel.SERIALIZABLE);
279
+ expect(innerQueryContext.transactionalDataLoaderMode).toBe(
280
+ TransactionalDataLoaderMode.DISABLED,
281
+ );
282
+ });
283
+ },
284
+ {
285
+ isolationLevel: TransactionIsolationLevel.SERIALIZABLE,
286
+ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED,
287
+ },
288
+ );
289
+ });
206
290
  });
207
291
 
208
292
  describe('global defaultTransactionalDataLoaderMode', () => {
@@ -34,6 +34,9 @@ export class EntityInvalidFieldValueError<
34
34
  return EntityErrorCode.ERR_ENTITY_INVALID_FIELD_VALUE;
35
35
  }
36
36
 
37
+ readonly fieldName: N;
38
+ readonly fieldValue: TFields[N] | undefined;
39
+
37
40
  constructor(
38
41
  entityClass: IEntityClass<
39
42
  TFields,
@@ -47,5 +50,7 @@ export class EntityInvalidFieldValueError<
47
50
  fieldValue?: TFields[N],
48
51
  ) {
49
52
  super(`Entity field not valid: ${entityClass.name} (${String(fieldName)} = ${fieldValue})`);
53
+ this.fieldName = fieldName;
54
+ this.fieldValue = fieldValue;
50
55
  }
51
56
  }
@@ -34,6 +34,8 @@ describe('EntityError subclasses', () => {
34
34
  const error = new EntityInvalidFieldValueError(SimpleTestEntity, 'id', 'badValue');
35
35
  expect(error.state).toBe(EntityErrorState.PERMANENT);
36
36
  expect(error.code).toBe(EntityErrorCode.ERR_ENTITY_INVALID_FIELD_VALUE);
37
+ expect(error.fieldName).toBe('id');
38
+ expect(error.fieldValue).toBe('badValue');
37
39
  });
38
40
 
39
41
  it('EntityCacheAdapterTransientError has correct state and code', () => {
@@ -4,12 +4,29 @@ import type {
4
4
  } from '../EntityPrivacyPolicy.ts';
5
5
  import type { EntityLoadMethodType } from '../internal/EntityLoadInterfaces.ts';
6
6
 
7
+ /**
8
+ * The type of the load method being called.
9
+ */
7
10
  export enum EntityMetricsLoadType {
11
+ /**
12
+ * Standard EntityLoader load (the methods from EnforcingEntityLoader or AuthorizationResultBasedEntityLoader).
13
+ */
8
14
  LOAD_MANY,
15
+ /**
16
+ * Knex loader load using loadManyByFieldEqualityConjunctionAsync.
17
+ */
9
18
  LOAD_MANY_EQUALITY_CONJUNCTION,
10
- LOAD_MANY_RAW,
19
+ /**
20
+ * Knex loader load using loadManyBySQL.
21
+ */
11
22
  LOAD_MANY_SQL,
23
+ /**
24
+ * Internal data manager load via database adapter method loadOneEqualingAsync.
25
+ */
12
26
  LOAD_ONE,
27
+ /**
28
+ * Knex loader load using loadPageAsync.
29
+ */
13
30
  LOAD_PAGE,
14
31
  }
15
32
 
@@ -43,6 +60,9 @@ export interface EntityMetricsLoadEvent {
43
60
  count: number;
44
61
  }
45
62
 
63
+ /**
64
+ * The type of mutation being performed.
65
+ */
46
66
  export enum EntityMetricsMutationType {
47
67
  CREATE,
48
68
  UPDATE,
@@ -71,6 +91,9 @@ export interface EntityMetricsMutationEvent {
71
91
  duration: number;
72
92
  }
73
93
 
94
+ /**
95
+ * Type used to delineate dataloader, cache, and database load counts in EntityDataManager.
96
+ */
74
97
  export enum IncrementLoadCountEventType {
75
98
  /**
76
99
  * Type for when a dataloader load is initiated via the standard load methods
@@ -135,7 +135,7 @@ export class StubDatabaseAdapter<
135
135
  tableIdField: string,
136
136
  id: any,
137
137
  object: object,
138
- ): Promise<object[]> {
138
+ ): Promise<{ updatedRowCount: number }> {
139
139
  // SQL does not support empty updates, mirror behavior here for better test simulation
140
140
  if (Object.keys(object).length === 0) {
141
141
  throw new Error(`Empty update (${tableIdField} = ${id})`);
@@ -150,14 +150,14 @@ export class StubDatabaseAdapter<
150
150
  // SQL updates to a nonexistent row succeed but affect 0 rows,
151
151
  // mirror that behavior here for better test simulation
152
152
  if (objectIndex < 0) {
153
- return [];
153
+ return { updatedRowCount: 0 };
154
154
  }
155
155
 
156
156
  objectCollection[objectIndex] = {
157
157
  ...objectCollection[objectIndex],
158
158
  ...object,
159
159
  };
160
- return [objectCollection[objectIndex]];
160
+ return { updatedRowCount: 1 };
161
161
  }
162
162
 
163
163
  protected async deleteInternalAsync(