@expo/entity 0.48.0 → 0.50.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 (55) hide show
  1. package/build/src/EntityDatabaseAdapter.js +12 -5
  2. package/build/src/EntityDatabaseAdapter.js.map +1 -1
  3. package/build/src/EntityFields.d.ts +3 -1
  4. package/build/src/EntityFields.js +4 -2
  5. package/build/src/EntityFields.js.map +1 -1
  6. package/build/src/EntityMutationValidator.js +2 -0
  7. package/build/src/EntityMutationValidator.js.map +1 -1
  8. package/build/src/IEntityCacheAdapter.js +2 -0
  9. package/build/src/IEntityCacheAdapter.js.map +1 -1
  10. package/build/src/IEntityCacheAdapterProvider.js +2 -0
  11. package/build/src/IEntityCacheAdapterProvider.js.map +1 -1
  12. package/build/src/IEntityDatabaseAdapterProvider.js +2 -0
  13. package/build/src/IEntityDatabaseAdapterProvider.js.map +1 -1
  14. package/build/src/IEntityGenericCacher.js +2 -0
  15. package/build/src/IEntityGenericCacher.js.map +1 -1
  16. package/build/src/entityUtils.d.ts +2 -2
  17. package/build/src/entityUtils.js.map +1 -1
  18. package/build/src/errors/EntityCacheAdapterError.d.ts +7 -0
  19. package/build/src/errors/EntityCacheAdapterError.js +7 -0
  20. package/build/src/errors/EntityCacheAdapterError.js.map +1 -1
  21. package/build/src/errors/EntityDatabaseAdapterError.d.ts +78 -0
  22. package/build/src/errors/EntityDatabaseAdapterError.js +84 -1
  23. package/build/src/errors/EntityDatabaseAdapterError.js.map +1 -1
  24. package/build/src/errors/EntityError.d.ts +14 -0
  25. package/build/src/errors/EntityError.js +14 -0
  26. package/build/src/errors/EntityError.js.map +1 -1
  27. package/build/src/errors/EntityInvalidFieldValueError.d.ts +3 -0
  28. package/build/src/errors/EntityInvalidFieldValueError.js +3 -0
  29. package/build/src/errors/EntityInvalidFieldValueError.js.map +1 -1
  30. package/build/src/errors/EntityNotAuthorizedError.d.ts +3 -0
  31. package/build/src/errors/EntityNotAuthorizedError.js +3 -0
  32. package/build/src/errors/EntityNotAuthorizedError.js.map +1 -1
  33. package/build/src/errors/EntityNotFoundError.d.ts +3 -0
  34. package/build/src/errors/EntityNotFoundError.js +3 -0
  35. package/build/src/errors/EntityNotFoundError.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/EntityDatabaseAdapter.ts +18 -5
  38. package/src/EntityFields.ts +4 -2
  39. package/src/EntityMutationValidator.ts +4 -0
  40. package/src/IEntityCacheAdapter.ts +4 -0
  41. package/src/IEntityCacheAdapterProvider.ts +4 -0
  42. package/src/IEntityDatabaseAdapterProvider.ts +4 -0
  43. package/src/IEntityGenericCacher.ts +4 -0
  44. package/src/__tests__/EntityDatabaseAdapter-test.ts +12 -5
  45. package/src/__tests__/EntityFields-test.ts +1 -1
  46. package/src/__tests__/GenericEntityCacheAdapter-test.ts +24 -0
  47. package/src/__tests__/GenericSecondaryEntityCache-test.ts +180 -0
  48. package/src/entityUtils.ts +5 -3
  49. package/src/errors/EntityCacheAdapterError.ts +7 -0
  50. package/src/errors/EntityDatabaseAdapterError.ts +83 -0
  51. package/src/errors/EntityError.ts +14 -0
  52. package/src/errors/EntityInvalidFieldValueError.ts +3 -0
  53. package/src/errors/EntityNotAuthorizedError.ts +3 -0
  54. package/src/errors/EntityNotFoundError.ts +3 -0
  55. package/src/utils/__testfixtures__/StubDatabaseAdapter.ts +11 -1
@@ -2,6 +2,9 @@ import { EntityAuthorizationAction } from '../EntityPrivacyPolicy';
2
2
  import { ReadonlyEntity } from '../ReadonlyEntity';
3
3
  import { ViewerContext } from '../ViewerContext';
4
4
  import { EntityError, EntityErrorCode, EntityErrorState } from './EntityError';
5
+ /**
6
+ * Error thrown when viewer context is not authorized to perform an action on an entity.
7
+ */
5
8
  export declare class EntityNotAuthorizedError<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 = keyof TFields> extends EntityError {
6
9
  readonly state = EntityErrorState.PERMANENT;
7
10
  readonly code = EntityErrorCode.ERR_ENTITY_NOT_AUTHORIZED;
@@ -3,6 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.EntityNotAuthorizedError = void 0;
4
4
  const EntityPrivacyPolicy_1 = require("../EntityPrivacyPolicy");
5
5
  const EntityError_1 = require("./EntityError");
6
+ /**
7
+ * Error thrown when viewer context is not authorized to perform an action on an entity.
8
+ */
6
9
  class EntityNotAuthorizedError extends EntityError_1.EntityError {
7
10
  state = EntityError_1.EntityErrorState.PERMANENT;
8
11
  code = EntityError_1.EntityErrorCode.ERR_ENTITY_NOT_AUTHORIZED;
@@ -1 +1 @@
1
- {"version":3,"file":"EntityNotAuthorizedError.js","sourceRoot":"","sources":["../../../src/errors/EntityNotAuthorizedError.ts"],"names":[],"mappings":";;;AAAA,gEAAmE;AAGnE,+CAA+E;AAE/E,MAAa,wBAMX,SAAQ,yBAAW;IACH,KAAK,GAAG,8BAAgB,CAAC,SAAS,CAAC;IACnC,IAAI,GAAG,6BAAe,CAAC,yBAAyB,CAAC;IAEjD,eAAe,CAAS;IAExC,YACE,MAAe,EACf,aAA6B,EAC7B,MAAiC,EACjC,SAAiB;QAEjB,KAAK,CACH,0BAA0B,MAAM,cAAc,aAAa,cAAc,+CAAyB,CAAC,MAAM,CAAC,iBAAiB,SAAS,GAAG,CACxI,CAAC;QACF,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC;IACjD,CAAC;CACF;AAvBD,4DAuBC"}
1
+ {"version":3,"file":"EntityNotAuthorizedError.js","sourceRoot":"","sources":["../../../src/errors/EntityNotAuthorizedError.ts"],"names":[],"mappings":";;;AAAA,gEAAmE;AAGnE,+CAA+E;AAE/E;;GAEG;AACH,MAAa,wBAMX,SAAQ,yBAAW;IACH,KAAK,GAAG,8BAAgB,CAAC,SAAS,CAAC;IACnC,IAAI,GAAG,6BAAe,CAAC,yBAAyB,CAAC;IAEjD,eAAe,CAAS;IAExC,YACE,MAAe,EACf,aAA6B,EAC7B,MAAiC,EACjC,SAAiB;QAEjB,KAAK,CACH,0BAA0B,MAAM,cAAc,aAAa,cAAc,+CAAyB,CAAC,MAAM,CAAC,iBAAiB,SAAS,GAAG,CACxI,CAAC;QACF,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC;IACjD,CAAC;CACF;AAvBD,4DAuBC"}
@@ -8,6 +8,9 @@ type EntityNotFoundOptions<TFields extends Record<string, any>, TIDField extends
8
8
  fieldName: N;
9
9
  fieldValue: TFields[N];
10
10
  };
11
+ /**
12
+ * Error thrown when an entity is not found during certain load methods.
13
+ */
11
14
  export declare class EntityNotFoundError<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 {
12
15
  readonly state = EntityErrorState.PERMANENT;
13
16
  readonly code = EntityErrorCode.ERR_ENTITY_NOT_FOUND;
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.EntityNotFoundError = void 0;
4
4
  const EntityError_1 = require("./EntityError");
5
+ /**
6
+ * Error thrown when an entity is not found during certain load methods.
7
+ */
5
8
  class EntityNotFoundError extends EntityError_1.EntityError {
6
9
  state = EntityError_1.EntityErrorState.PERMANENT;
7
10
  code = EntityError_1.EntityErrorCode.ERR_ENTITY_NOT_FOUND;
@@ -1 +1 @@
1
- {"version":3,"file":"EntityNotFoundError.js","sourceRoot":"","sources":["../../../src/errors/EntityNotFoundError.ts"],"names":[],"mappings":";;;AAIA,+CAA+E;AA6B/E,MAAa,mBAcX,SAAQ,yBAAW;IACH,KAAK,GAAG,8BAAgB,CAAC,SAAS,CAAC;IACnC,IAAI,GAAG,6BAAe,CAAC,oBAAoB,CAAC;IAe5D,YACE,gBAUK;QAEL,IAAI,OAAO,gBAAgB,KAAK,QAAQ,EAAE,CAAC;YACzC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC1B,CAAC;aAAM,CAAC;YACN,KAAK,CACH,qBAAqB,gBAAgB,CAAC,WAAW,CAAC,IAAI,KAAK,MAAM,CAAC,gBAAgB,CAAC,SAAS,CAAC,MAAM,gBAAgB,CAAC,UAAU,GAAG,CAClI,CAAC;QACJ,CAAC;IACH,CAAC;CACF;AApDD,kDAoDC"}
1
+ {"version":3,"file":"EntityNotFoundError.js","sourceRoot":"","sources":["../../../src/errors/EntityNotFoundError.ts"],"names":[],"mappings":";;;AAIA,+CAA+E;AA6B/E;;GAEG;AACH,MAAa,mBAcX,SAAQ,yBAAW;IACH,KAAK,GAAG,8BAAgB,CAAC,SAAS,CAAC;IACnC,IAAI,GAAG,6BAAe,CAAC,oBAAoB,CAAC;IAe5D,YACE,gBAUK;QAEL,IAAI,OAAO,gBAAgB,KAAK,QAAQ,EAAE,CAAC;YACzC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC1B,CAAC;aAAM,CAAC;YACN,KAAK,CACH,qBAAqB,gBAAgB,CAAC,WAAW,CAAC,IAAI,KAAK,MAAM,CAAC,gBAAgB,CAAC,SAAS,CAAC,MAAM,gBAAgB,CAAC,UAAU,GAAG,CAClI,CAAC;QACJ,CAAC;IACH,CAAC;CACF;AApDD,kDAoDC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/entity",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "A privacy-first data model",
5
5
  "files": [
6
6
  "build",
@@ -44,5 +44,5 @@
44
44
  "uuid": "^8.3.0",
45
45
  "uuidv7": "^1.0.0"
46
46
  },
47
- "gitHead": "7b4bec8f841fecdb30af306441b540c0efae1603"
47
+ "gitHead": "59e024eb19fd7a6f6d0b8f1577970b26049bc411"
48
48
  }
@@ -2,6 +2,13 @@ import invariant from 'invariant';
2
2
 
3
3
  import { EntityConfiguration } from './EntityConfiguration';
4
4
  import { EntityQueryContext } from './EntityQueryContext';
5
+ import {
6
+ EntityDatabaseAdapterEmptyInsertResultError,
7
+ EntityDatabaseAdapterEmptyUpdateResultError,
8
+ EntityDatabaseAdapterExcessiveDeleteResultError,
9
+ EntityDatabaseAdapterExcessiveInsertResultError,
10
+ EntityDatabaseAdapterExcessiveUpdateResultError,
11
+ } from './errors/EntityDatabaseAdapterError';
5
12
  import {
6
13
  FieldTransformerMap,
7
14
  getDatabaseFieldForEntityField,
@@ -303,12 +310,15 @@ export abstract class EntityDatabaseAdapter<
303
310
  dbObject,
304
311
  );
305
312
 
313
+ // These should never happen with a properly implemented database adapter unless the underlying database has weird triggers
314
+ // or something.
315
+ // These errors are exposed to help application developers detect and diagnose such issues.
306
316
  if (results.length > 1) {
307
- throw new Error(
317
+ throw new EntityDatabaseAdapterExcessiveInsertResultError(
308
318
  `Excessive results from database adapter insert: ${this.entityConfiguration.tableName}`,
309
319
  );
310
320
  } else if (results.length === 0) {
311
- throw new Error(
321
+ throw new EntityDatabaseAdapterEmptyInsertResultError(
312
322
  `Empty results from database adapter insert: ${this.entityConfiguration.tableName}`,
313
323
  );
314
324
  }
@@ -356,11 +366,14 @@ export abstract class EntityDatabaseAdapter<
356
366
  );
357
367
 
358
368
  if (results.length > 1) {
359
- throw new Error(
369
+ // This should never happen with a properly implemented database adapter unless the underlying table has a non-unique
370
+ // primary key column.
371
+ throw new EntityDatabaseAdapterExcessiveUpdateResultError(
360
372
  `Excessive results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`,
361
373
  );
362
374
  } else if (results.length === 0) {
363
- throw new Error(
375
+ // This happens when the object to update does not exist. It may have been deleted by another process.
376
+ throw new EntityDatabaseAdapterEmptyUpdateResultError(
364
377
  `Empty results from database adapter update: ${this.entityConfiguration.tableName}(id = ${id})`,
365
378
  );
366
379
  }
@@ -401,7 +414,7 @@ export abstract class EntityDatabaseAdapter<
401
414
  );
402
415
 
403
416
  if (numDeleted > 1) {
404
- throw new Error(
417
+ throw new EntityDatabaseAdapterExcessiveDeleteResultError(
405
418
  `Excessive deletions from database adapter delete: ${this.entityConfiguration.tableName}(id = ${id})`,
406
419
  );
407
420
  }
@@ -6,7 +6,7 @@ import {
6
6
 
7
7
  // Use our own regex since the `uuid` package doesn't support validating UUIDv6/7/8 yet
8
8
  const UUID_REGEX =
9
- /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i;
9
+ /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/;
10
10
 
11
11
  /**
12
12
  * EntityFieldDefinition for a column with a JS string type.
@@ -22,7 +22,9 @@ export class StringField<TRequireExplicitCache extends boolean> extends EntityFi
22
22
 
23
23
  /**
24
24
  * EntityFieldDefinition for a column with a JS string type.
25
- * Enforces that the string is a valid UUID.
25
+ * Enforces that the string is a valid UUID and that it is lowercase. Entity requires UUIDs to be lowercase since most
26
+ * databases (e.g. Postgres) treat UUIDs as case-insensitive, which can lead to unexpected entity load results if mixed-case
27
+ * UUIDs are used.
26
28
  */
27
29
  export class UUIDField<
28
30
  TRequireExplicitCache extends boolean,
@@ -1,3 +1,5 @@
1
+ /* c8 ignore start - abstract class only */
2
+
1
3
  import { EntityValidatorMutationInfo } from './EntityMutationInfo';
2
4
  import { EntityTransactionalQueryContext } from './EntityQueryContext';
3
5
  import { ReadonlyEntity } from './ReadonlyEntity';
@@ -27,3 +29,5 @@ export abstract class EntityMutationValidator<
27
29
  >,
28
30
  ): Promise<void>;
29
31
  }
32
+
33
+ /* c8 ignore stop - abstract class only */
@@ -1,3 +1,5 @@
1
+ /* c8 ignore start - interface only */
2
+
1
3
  import { IEntityLoadKey, IEntityLoadValue } from './internal/EntityLoadInterfaces';
2
4
  import { CacheLoadResult } from './internal/ReadThroughEntityCache';
3
5
 
@@ -67,3 +69,5 @@ export interface IEntityCacheAdapter<
67
69
  values: readonly TLoadValue[],
68
70
  ): Promise<void>;
69
71
  }
72
+
73
+ /* c8 ignore stop - interface only */
@@ -1,3 +1,5 @@
1
+ /* c8 ignore start - interface only */
2
+
1
3
  import { EntityConfiguration } from './EntityConfiguration';
2
4
  import { IEntityCacheAdapter } from './IEntityCacheAdapter';
3
5
 
@@ -13,3 +15,5 @@ export interface IEntityCacheAdapterProvider {
13
15
  entityConfiguration: EntityConfiguration<TFields, TIDField>,
14
16
  ): IEntityCacheAdapter<TFields, TIDField>;
15
17
  }
18
+
19
+ /* c8 ignore stop - interface only */
@@ -1,3 +1,5 @@
1
+ /* c8 ignore start - interface only */
2
+
1
3
  import { EntityConfiguration } from './EntityConfiguration';
2
4
  import { EntityDatabaseAdapter } from './EntityDatabaseAdapter';
3
5
 
@@ -13,3 +15,5 @@ export interface IEntityDatabaseAdapterProvider {
13
15
  entityConfiguration: EntityConfiguration<TFields, TIDField>,
14
16
  ): EntityDatabaseAdapter<TFields, TIDField>;
15
17
  }
18
+
19
+ /* c8 ignore stop - interface only */
@@ -1,3 +1,5 @@
1
+ /* c8 ignore start - interface only */
2
+
1
3
  import { IEntityLoadKey, IEntityLoadValue } from './internal/EntityLoadInterfaces';
2
4
  import { CacheLoadResult } from './internal/ReadThroughEntityCache';
3
5
 
@@ -71,3 +73,5 @@ export interface IEntityGenericCacher<
71
73
  value: TLoadValue,
72
74
  ): readonly string[];
73
75
  }
76
+
77
+ /* c8 ignore stop - interface only */
@@ -7,6 +7,13 @@ import {
7
7
  TableFieldSingleValueEqualityCondition,
8
8
  } from '../EntityDatabaseAdapter';
9
9
  import { EntityQueryContext } from '../EntityQueryContext';
10
+ import {
11
+ EntityDatabaseAdapterEmptyInsertResultError,
12
+ EntityDatabaseAdapterEmptyUpdateResultError,
13
+ EntityDatabaseAdapterExcessiveDeleteResultError,
14
+ EntityDatabaseAdapterExcessiveInsertResultError,
15
+ EntityDatabaseAdapterExcessiveUpdateResultError,
16
+ } from '../errors/EntityDatabaseAdapterError';
10
17
  import { CompositeFieldHolder, CompositeFieldValueHolder } from '../internal/CompositeFieldHolder';
11
18
  import { FieldTransformerMap } from '../internal/EntityFieldTransformationUtils';
12
19
  import { SingleFieldHolder, SingleFieldValueHolder } from '../internal/SingleFieldHolder';
@@ -264,7 +271,7 @@ describe(EntityDatabaseAdapter, () => {
264
271
  const queryContext = instance(mock(EntityQueryContext));
265
272
  const adapter = new TestEntityDatabaseAdapter({ insertResults: [] });
266
273
  await expect(adapter.insertAsync(queryContext, {} as any)).rejects.toThrow(
267
- 'Empty results from database adapter insert',
274
+ EntityDatabaseAdapterEmptyInsertResultError,
268
275
  );
269
276
  });
270
277
 
@@ -274,7 +281,7 @@ describe(EntityDatabaseAdapter, () => {
274
281
  insertResults: [{ string_field: 'hello' }, { string_field: 'hello2' }],
275
282
  });
276
283
  await expect(adapter.insertAsync(queryContext, {} as any)).rejects.toThrow(
277
- 'Excessive results from database adapter insert',
284
+ EntityDatabaseAdapterExcessiveInsertResultError,
278
285
  );
279
286
  });
280
287
  });
@@ -292,7 +299,7 @@ describe(EntityDatabaseAdapter, () => {
292
299
  const adapter = new TestEntityDatabaseAdapter({ updateResults: [] });
293
300
  await expect(
294
301
  adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any),
295
- ).rejects.toThrow('Empty results from database adapter update');
302
+ ).rejects.toThrow(EntityDatabaseAdapterEmptyUpdateResultError);
296
303
  });
297
304
 
298
305
  it('throws when update result count greater than 1', async () => {
@@ -302,7 +309,7 @@ describe(EntityDatabaseAdapter, () => {
302
309
  });
303
310
  await expect(
304
311
  adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any),
305
- ).rejects.toThrow('Excessive results from database adapter update');
312
+ ).rejects.toThrow(EntityDatabaseAdapterExcessiveUpdateResultError);
306
313
  });
307
314
  });
308
315
 
@@ -311,7 +318,7 @@ describe(EntityDatabaseAdapter, () => {
311
318
  const queryContext = instance(mock(EntityQueryContext));
312
319
  const adapter = new TestEntityDatabaseAdapter({ deleteCount: 2 });
313
320
  await expect(adapter.deleteAsync(queryContext, 'customIdField', 'wat')).rejects.toThrow(
314
- 'Excessive deletions from database adapter delet',
321
+ EntityDatabaseAdapterExcessiveDeleteResultError,
315
322
  );
316
323
  });
317
324
  });
@@ -65,7 +65,7 @@ describeFieldTestCase(
65
65
  uuidv5('wat', uuidv5.DNS),
66
66
  /* UUIDv7 */ '018ebfda-dc80-782d-a891-22a0aa057d52',
67
67
  ],
68
- [uuidv4().replace('-', ''), '', 'hello'],
68
+ [uuidv4().replace('-', ''), '', 'hello', uuidv4().toUpperCase()],
69
69
  );
70
70
  describeFieldTestCase(new DateField({ columnName: 'wat' }), [new Date()], [Date.now()]);
71
71
  describeFieldTestCase(new BooleanField({ columnName: 'wat' }), [true, false], [0, 1, '']);
@@ -3,6 +3,7 @@ import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito';
3
3
 
4
4
  import { GenericEntityCacheAdapter } from '../GenericEntityCacheAdapter';
5
5
  import { IEntityGenericCacher } from '../IEntityGenericCacher';
6
+ import { EntityCacheAdapterTransientError } from '../errors/EntityCacheAdapterError';
6
7
  import { CacheStatus } from '../internal/ReadThroughEntityCache';
7
8
  import {
8
9
  SingleFieldHolder,
@@ -68,6 +69,29 @@ describe(GenericEntityCacheAdapter, () => {
68
69
  );
69
70
  expect(results).toEqual(new SingleFieldValueHolderMap(new Map()));
70
71
  });
72
+
73
+ it('rethrows EntityCacheAdapterTransientError from underlying cacher', async () => {
74
+ const mockGenericCacher = mock<IEntityGenericCacher<BlahFields, 'id'>>();
75
+ when(
76
+ mockGenericCacher.makeCacheKeyForStorage(
77
+ deepEqualEntityAware(new SingleFieldHolder('id')),
78
+ anything(),
79
+ ),
80
+ ).thenCall((fieldHolder, fieldValueHolder) => {
81
+ return `${fieldHolder.fieldName}.${fieldValueHolder.fieldValue}`;
82
+ });
83
+ const expectedError = new EntityCacheAdapterTransientError('Transient error');
84
+ when(mockGenericCacher.loadManyAsync(deepEqual(['id.wat']))).thenReject(expectedError);
85
+
86
+ const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
87
+ await expect(
88
+ cacheAdapter.loadManyAsync(new SingleFieldHolder('id'), [
89
+ new SingleFieldValueHolder('wat'),
90
+ ]),
91
+ ).rejects.toThrow(expectedError);
92
+
93
+ verify(mockGenericCacher.loadManyAsync(anything())).once();
94
+ });
71
95
  });
72
96
 
73
97
  describe('cacheManyAsync', () => {
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import nullthrows from 'nullthrows';
3
+
4
+ import { EntitySecondaryCacheLoader } from '../EntitySecondaryCacheLoader';
5
+ import { GenericSecondaryEntityCache } from '../GenericSecondaryEntityCache';
6
+ import { IEntityGenericCacher } from '../IEntityGenericCacher';
7
+ import { ViewerContext } from '../ViewerContext';
8
+ import { IEntityLoadKey, IEntityLoadValue } from '../internal/EntityLoadInterfaces';
9
+ import { CacheLoadResult, CacheStatus } from '../internal/ReadThroughEntityCache';
10
+ import {
11
+ TestEntity,
12
+ TestEntityPrivacyPolicy,
13
+ TestFields,
14
+ } from '../utils/__testfixtures__/TestEntity';
15
+ import { createUnitTestEntityCompanionProvider } from '../utils/__testfixtures__/createUnitTestEntityCompanionProvider';
16
+ import { mapMapAsync } from '../utils/collections/maps';
17
+
18
+ type TestLoadParams = { intValue: number };
19
+
20
+ const DOES_NOT_EXIST = Symbol('doesNotExist');
21
+
22
+ class TestGenericCacher implements IEntityGenericCacher<TestFields, 'customIdField'> {
23
+ private readonly localMemoryCache = new Map<
24
+ string,
25
+ Readonly<TestFields> | typeof DOES_NOT_EXIST
26
+ >();
27
+
28
+ public async loadManyAsync(
29
+ keys: readonly string[],
30
+ ): Promise<ReadonlyMap<string, CacheLoadResult<TestFields>>> {
31
+ const cacheResults = new Map<string, CacheLoadResult<TestFields>>();
32
+ for (const key of keys) {
33
+ const cacheResult = this.localMemoryCache.get(key);
34
+ if (cacheResult === DOES_NOT_EXIST) {
35
+ cacheResults.set(key, {
36
+ status: CacheStatus.NEGATIVE,
37
+ });
38
+ } else if (cacheResult) {
39
+ cacheResults.set(key, {
40
+ status: CacheStatus.HIT,
41
+ item: cacheResult as unknown as TestFields,
42
+ });
43
+ } else {
44
+ cacheResults.set(key, {
45
+ status: CacheStatus.MISS,
46
+ });
47
+ }
48
+ }
49
+ return cacheResults;
50
+ }
51
+
52
+ public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TestFields>>): Promise<void> {
53
+ for (const [key, item] of objectMap) {
54
+ this.localMemoryCache.set(key, item);
55
+ }
56
+ }
57
+
58
+ public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
59
+ for (const key of keys) {
60
+ this.localMemoryCache.set(key, DOES_NOT_EXIST);
61
+ }
62
+ }
63
+
64
+ public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
65
+ for (const key of keys) {
66
+ this.localMemoryCache.delete(key);
67
+ }
68
+ }
69
+
70
+ makeCacheKeyForStorage<
71
+ TLoadKey extends IEntityLoadKey<TestFields, 'customIdField', TSerializedLoadValue, TLoadValue>,
72
+ TSerializedLoadValue,
73
+ TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
74
+ >(_key: TLoadKey, _value: TLoadValue): string {
75
+ throw new Error('Method not used by this test.');
76
+ }
77
+
78
+ makeCacheKeysForInvalidation<
79
+ TLoadKey extends IEntityLoadKey<TestFields, 'customIdField', TSerializedLoadValue, TLoadValue>,
80
+ TSerializedLoadValue,
81
+ TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
82
+ >(_key: TLoadKey, _value: TLoadValue): readonly string[] {
83
+ throw new Error('Method not used by this test.');
84
+ }
85
+ }
86
+
87
+ class TestSecondaryEntityCache<
88
+ TFields extends Record<string, any>,
89
+ TIDField extends keyof TFields,
90
+ TLoadParams,
91
+ > extends GenericSecondaryEntityCache<TFields, TIDField, TLoadParams> {}
92
+
93
+ class TestSecondaryCacheLoader extends EntitySecondaryCacheLoader<
94
+ TestLoadParams,
95
+ TestFields,
96
+ 'customIdField',
97
+ ViewerContext,
98
+ TestEntity,
99
+ TestEntityPrivacyPolicy
100
+ > {
101
+ public databaseLoadCount = 0;
102
+
103
+ protected override async fetchObjectsFromDatabaseAsync(
104
+ loadParamsArray: readonly Readonly<TestLoadParams>[],
105
+ ): Promise<ReadonlyMap<Readonly<Readonly<TestLoadParams>>, Readonly<TestFields> | null>> {
106
+ this.databaseLoadCount += loadParamsArray.length;
107
+
108
+ const emptyMap = new Map(loadParamsArray.map((p) => [p, null]));
109
+ return await mapMapAsync(emptyMap, async (_value, loadParams) => {
110
+ return (
111
+ (
112
+ await this.entityLoader.loadManyByFieldEqualityConjunctionAsync([
113
+ { fieldName: 'intField', fieldValue: loadParams.intValue },
114
+ ])
115
+ )[0]
116
+ ?.enforceValue()
117
+ ?.getAllFields() ?? null
118
+ );
119
+ });
120
+ }
121
+ }
122
+
123
+ describe(GenericSecondaryEntityCache, () => {
124
+ it('Loads through secondary loader, caches, and invalidates', async () => {
125
+ const viewerContext = new ViewerContext(createUnitTestEntityCompanionProvider());
126
+
127
+ const createdEntity = await TestEntity.creator(viewerContext)
128
+ .setField('intField', 1)
129
+ .createAsync();
130
+
131
+ const secondaryCacheLoader = new TestSecondaryCacheLoader(
132
+ new TestSecondaryEntityCache(
133
+ new TestGenericCacher(),
134
+ (params) => `intValue.${params.intValue}`,
135
+ ),
136
+ TestEntity.loaderWithAuthorizationResults(viewerContext),
137
+ );
138
+
139
+ const loadParams = { intValue: 1 };
140
+ const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
141
+ expect(nullthrows(results.get(loadParams)).enforceValue().getID()).toEqual(
142
+ createdEntity.getID(),
143
+ );
144
+
145
+ expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
146
+
147
+ const results2 = await secondaryCacheLoader.loadManyAsync([loadParams]);
148
+ expect(nullthrows(results2.get(loadParams)).enforceValue().getID()).toEqual(
149
+ createdEntity.getID(),
150
+ );
151
+
152
+ expect(secondaryCacheLoader.databaseLoadCount).toEqual(1);
153
+
154
+ await secondaryCacheLoader.invalidateManyAsync([loadParams]);
155
+
156
+ const results3 = await secondaryCacheLoader.loadManyAsync([loadParams]);
157
+ expect(nullthrows(results3.get(loadParams)).enforceValue().getID()).toEqual(
158
+ createdEntity.getID(),
159
+ );
160
+
161
+ expect(secondaryCacheLoader.databaseLoadCount).toEqual(2);
162
+ });
163
+
164
+ it('correctly handles uncached and unfetchable load params', async () => {
165
+ const viewerContext = new ViewerContext(createUnitTestEntityCompanionProvider());
166
+
167
+ const secondaryCacheLoader = new TestSecondaryCacheLoader(
168
+ new TestSecondaryEntityCache(
169
+ new TestGenericCacher(),
170
+ (params) => `intValue.${params.intValue}`,
171
+ ),
172
+ TestEntity.loaderWithAuthorizationResults(viewerContext),
173
+ );
174
+
175
+ const loadParams = { intValue: 2 };
176
+ const results = await secondaryCacheLoader.loadManyAsync([loadParams]);
177
+ expect(results.size).toBe(1);
178
+ expect(results.get(loadParams)).toBe(null);
179
+ });
180
+ });
@@ -79,9 +79,9 @@ export type PartitionArrayPredicate<T, U> = (val: T | U) => val is T;
79
79
  * @param predicate - binary predicate to evaluate partition group of each value
80
80
  */
81
81
  export const partitionArray = <T, U>(
82
- values: (T | U)[],
82
+ values: readonly (T | U)[],
83
83
  predicate: PartitionArrayPredicate<T, U>,
84
- ): [T[], U[]] => {
84
+ ): readonly [readonly T[], readonly U[]] => {
85
85
  const ts: T[] = [];
86
86
  const us: U[] = [];
87
87
 
@@ -100,7 +100,9 @@ export const partitionArray = <T, U>(
100
100
  * Partition array of values and errors into an array of values and an array of errors.
101
101
  * @param valuesAndErrors - array of values and errors
102
102
  */
103
- export const partitionErrors = <T>(valuesAndErrors: (T | Error)[]): [T[], Error[]] => {
103
+ export const partitionErrors = <T>(
104
+ valuesAndErrors: readonly (T | Error)[],
105
+ ): readonly [readonly T[], readonly Error[]] => {
104
106
  const [errors, values] = partitionArray<Error, T>(valuesAndErrors, isError);
105
107
  return [values, errors];
106
108
  };
@@ -1,7 +1,14 @@
1
1
  import { EntityError, EntityErrorCode, EntityErrorState } from './EntityError';
2
2
 
3
+ /**
4
+ * Base class for errors thrown by the entity cache adapter.
5
+ */
3
6
  export abstract class EntityCacheAdapterError extends EntityError {}
4
7
 
8
+ /**
9
+ * Error thrown when a transient error occurs in the entity cache adapter.
10
+ * Transient errors may succeed if retried.
11
+ */
5
12
  export class EntityCacheAdapterTransientError extends EntityCacheAdapterError {
6
13
  public readonly state = EntityErrorState.TRANSIENT;
7
14
  public readonly code = EntityErrorCode.ERR_ENTITY_CACHE_ADAPTER_TRANSIENT;
@@ -1,38 +1,121 @@
1
1
  import { EntityError, EntityErrorCode, EntityErrorState } from './EntityError';
2
2
 
3
+ /**
4
+ * Base class for all errors related to the database adapter.
5
+ */
3
6
  export abstract class EntityDatabaseAdapterError extends EntityError {}
4
7
 
8
+ /**
9
+ * Thrown when a transient error occurrs within the database adapter.
10
+ * Transient errors may succeed if retried.
11
+ */
5
12
  export class EntityDatabaseAdapterTransientError extends EntityDatabaseAdapterError {
6
13
  public readonly state = EntityErrorState.TRANSIENT;
7
14
  public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_TRANSIENT;
8
15
  }
9
16
 
17
+ /**
18
+ * Thrown when an unknown error occurrs within the database adapter.
19
+ * This is a catch-all error class for DBMS-specific errors that do not fit into other categories.
20
+ */
10
21
  export class EntityDatabaseAdapterUnknownError extends EntityDatabaseAdapterError {
11
22
  public readonly state = EntityErrorState.UNKNOWN;
12
23
  public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_UNKNOWN;
13
24
  }
14
25
 
26
+ /**
27
+ * Thrown when a check constraint is violated within the database adapter.
28
+ * This indicates that a value being inserted or updated does not satisfy a defined data integrity constraint.
29
+ */
15
30
  export class EntityDatabaseAdapterCheckConstraintError extends EntityDatabaseAdapterError {
16
31
  public readonly state = EntityErrorState.PERMANENT;
17
32
  public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_CHECK_CONSTRAINT;
18
33
  }
19
34
 
35
+ /**
36
+ * Thrown when an exclusion constraint is violated within the database adapter.
37
+ * This indicates that a value being inserted or updated conflicts with an existing value based on a defined exclusion constraint.
38
+ */
20
39
  export class EntityDatabaseAdapterExclusionConstraintError extends EntityDatabaseAdapterError {
21
40
  public readonly state = EntityErrorState.PERMANENT;
22
41
  public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_EXCLUSION_CONSTRAINT;
23
42
  }
24
43
 
44
+ /**
45
+ * Thrown when a foreign key constraint is violated within the database adapter.
46
+ * This indicates that a value being inserted, updated, or deleted references a non-existent value in a related table
47
+ * or is referenced in a related table.
48
+ */
25
49
  export class EntityDatabaseAdapterForeignKeyConstraintError extends EntityDatabaseAdapterError {
26
50
  public readonly state = EntityErrorState.PERMANENT;
27
51
  public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_FOREIGN_KEY_CONSTRAINT;
28
52
  }
29
53
 
54
+ /**
55
+ * Thrown when a not-null constraint is violated within the database adapter.
56
+ * This indicates that a null value is being inserted or updated into a column that does not allow null values.
57
+ */
30
58
  export class EntityDatabaseAdapterNotNullConstraintError extends EntityDatabaseAdapterError {
31
59
  public readonly state = EntityErrorState.PERMANENT;
32
60
  public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_NOT_NULL_CONSTRAINT;
33
61
  }
34
62
 
63
+ /**
64
+ * Thrown when a unique constraint is violated within the database adapter.
65
+ * This indicates that a value being inserted or updated duplicates an existing value in a column or set of columns
66
+ * that require unique values.
67
+ */
35
68
  export class EntityDatabaseAdapterUniqueConstraintError extends EntityDatabaseAdapterError {
36
69
  public readonly state = EntityErrorState.PERMANENT;
37
70
  public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_UNIQUE_CONSTRAINT;
38
71
  }
72
+
73
+ /**
74
+ * Thrown when an insert operation returns more results than expected. Only one row is expected.
75
+ * These should never happen with a properly implemented database adapter unless the underlying database has nonstandard
76
+ * triggers or something similar.
77
+ */
78
+ export class EntityDatabaseAdapterExcessiveInsertResultError extends EntityDatabaseAdapterError {
79
+ public readonly state = EntityErrorState.PERMANENT;
80
+ public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_INSERT_RESULT;
81
+ }
82
+
83
+ /**
84
+ * Thrown when an insert operation returns no results. One row is expected.
85
+ * These should never happen with a properly implemented database adapter unless the underlying database has nonstandard
86
+ * triggers or something similar.
87
+ */
88
+ export class EntityDatabaseAdapterEmptyInsertResultError extends EntityDatabaseAdapterError {
89
+ public readonly state = EntityErrorState.PERMANENT;
90
+ public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_EMPTY_INSERT_RESULT;
91
+ }
92
+
93
+ /**
94
+ * Thrown when an update operation returns more results than expected. Only one row is expected.
95
+ * These should never happen with a properly implemented database adapter unless the underlying table has a non-unique
96
+ * primary key column.
97
+ */
98
+ export class EntityDatabaseAdapterExcessiveUpdateResultError extends EntityDatabaseAdapterError {
99
+ public readonly state = EntityErrorState.PERMANENT;
100
+ public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_UPDATE_RESULT;
101
+ }
102
+
103
+ /**
104
+ * Thrown when an update operation returns no results. One row is expected.
105
+ * This most often happens when attempting to update a non-existent row, often indicating that the row
106
+ * was deleted by a different process between fetching and updating it in this process.
107
+ */
108
+ export class EntityDatabaseAdapterEmptyUpdateResultError extends EntityDatabaseAdapterError {
109
+ public readonly state = EntityErrorState.PERMANENT;
110
+ public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_EMPTY_UPDATE_RESULT;
111
+ }
112
+
113
+ /**
114
+ * Thrown when a delete operation returns more results than expected. Only one row is expected.
115
+ * These should never happen with a properly implemented database adapter unless the underlying table has a non-unique
116
+ * primary key column.
117
+ */
118
+ export class EntityDatabaseAdapterExcessiveDeleteResultError extends EntityDatabaseAdapterError {
119
+ public readonly state = EntityErrorState.PERMANENT;
120
+ public readonly code = EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_DELETE_RESULT;
121
+ }