Compare commits

...
Author SHA1 Message Date
Will Schurman 2e8707dd32 v0.65.0 2026-07-10 13:03:50 -07:00
Gabe Debes df75910cbd feat: accept array of parent entity classes in cascade deletion rule (#650) 2026-07-09 16:46:02 -07:00
renovate[bot] 06688ff789 chore(deps): update jest (#649) 2026-07-06 09:40:51 -07:00
renovate[bot] 957387b0d3 chore(deps): update dependency jdx/mise to v2026.7.0 (#648) 2026-07-06 09:39:34 -07:00
renovate[bot] cd353dd058 chore(deps): update dependency @types/node to v24.13.2 (#647) 2026-07-06 09:39:14 -07:00
renovate[bot] b604ff7bf2 chore(deps): update dependency uuid to v14.0.1 (#646) 2026-07-06 09:38:47 -07:00
renovate[bot] 795c231dae chore(deps): update postgres:18 docker digest to 4aabea7 (#644) 2026-06-29 08:34:29 -07:00
renovate[bot] f8eaf094b6 chore(deps): update redis docker digest to 2838d55 (#645) 2026-06-29 08:34:18 -07:00
Will Schurman 🦦 f4201afa86 chore: update renovate groups for new linter (#643) 2026-06-22 10:10:49 -07:00
renovate[bot] 4e07e761c0 chore(deps): update dependency oxfmt to v0.55.0 (#641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 09:22:40 -07:00
renovate[bot] ab0eb5525f chore(deps): update dependency pg to v8.22.0 (#642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 09:20:35 -07:00
renovate[bot] a5e1738f5f chore(deps): update ci workflows (#639) 2026-06-22 09:19:49 -07:00
renovate[bot] 9b8427a211 chore(deps): update dependency ioredis to v5.11.1 (#640) 2026-06-22 09:18:35 -07:00
renovate[bot] 4badcf8a77 chore(deps): update redis docker digest to 0a97239 (#636) 2026-06-15 08:39:47 -07:00
renovate[bot] f0c01e4414 chore(deps): update ci workflows to v2026.6.10 (#637) 2026-06-15 08:39:28 -07:00
renovate[bot] d8a6c1d8bd chore(deps): update dependency prettier to v3.8.4 (#638) 2026-06-15 08:39:07 -07:00
renovate[bot] abe20b9168 chore(deps): update postgres:18 docker digest to 29ee7bb (#635) 2026-06-15 08:38:52 -07:00
renovate[bot] 1c17dc0f37 fix(deps): update dependency koa to v3.2.1 (#634) 2026-06-08 08:44:45 -07:00
renovate[bot] 5bd968f698 chore(deps): update dependency @tsconfig/bases to v1.0.25 (#632) 2026-06-08 08:34:20 -07:00
renovate[bot] 55be94ba64 chore(deps): update dependency ctix to v2.8.1 (#633) 2026-06-08 08:33:44 -07:00
renovate[bot] 2063fc715c chore(deps): update ci workflows (#631) 2026-06-08 08:21:13 -07:00
renovate[bot] 4454602f22 chore(deps): update dependency jdx/mise to v2026.5.18 (#630) 2026-06-01 14:54:22 -07:00
renovate[bot] 42a7912dc5 chore(deps): update redis docker digest to aa049e6 (#629) 2026-06-01 14:54:13 -07:00
renovate[bot] dd8c710e82 chore(deps): update dependency @isaacs/ttlcache to v2.1.5 (#625) 2026-05-25 09:54:24 -07:00
renovate[bot] 2912764a6a chore(deps): update redis docker digest to 4d25e2f (#624) 2026-05-25 09:39:50 -07:00
renovate[bot] 6879bbde9e chore(deps): update dependency @types/koa to v3.0.3 (#626) 2026-05-25 09:39:30 -07:00
renovate[bot] e89e8f7b6d chore(deps): update dependency jdx/mise to v2026.5.15 (#627) 2026-05-25 09:38:55 -07:00
Will Schurman 🦦 d5a36d1748 chore: upgrade oxlint and add rules for previous eslint rules (#623) 2026-05-22 11:09:08 -07:00
Will Schurman 3f10a9e70e v0.64.0 2026-05-22 08:42:44 -07:00
Will Schurman 🦦 5561a6fcae fix: correctness of TSMockitoExtensions isEqualWithEntityAware for SerializableKeyMaps of different length (#614)
# Why

A bug exists where the ts-mockito extension entity aware matcher would report map equality even when the maps were of different lengths (assuming shorter map had all the same elements as first part of longer map).

# How

Fix by preemptively checking length. Also fix recursive call to use `isEqualWithEntityAware` in case nested objects need it.

# Test Plan

Run updated test.
2026-05-21 10:40:28 -07:00
Will Schurman 🦦 f300d8e55a fix: fix possible composite key collision in StubDatabaseAdapter test utility (#613)
# Why

The stub adapters were doing a simple `:` join, so it was possible for two rows to collide in the stub adapter during tests, if value has the delimiter.

# How

Fix is to use JSON.stringify so that only truly same rows map to the same thing.

# Test Plan

Run new tests.
2026-05-21 10:37:27 -07:00
Will Schurman 🦦 ecf1608f7b chore: update comment for IEntityMetricsAdapter for implementors (#612)
# Why

Just a comment update to make it clearer that IEntityMetricsAdapter implementations must not throw since it'll cause adverse effects for the library.

In a follow-up, it may be good to defend against this, but it's not a straightforward fix.

# How

Update comment.

# Test Plan

Proofread.
2026-05-21 10:34:33 -07:00
Will Schurman 🦦 0eb8989c49 fix: pick method crash when object has hasOwnProperty key (#611)
# Why

`pick` was calling `object.hasOwnProperty(prop)`, but if the object had a property `hasOwnProperty`, it'd be used.

# How

Fix is to use the Object prototype's method.

# Test Plan

Run added test.
2026-05-21 10:31:30 -07:00
Will Schurman 🦦 e8299e2db6 fix: fix behavior of AuthorizationResultBasedEntityAssociationLoader when field value is empty string (#610)
# Why

The bug is that if the column in the current entity that contains the referenced entity's `id` is an empty string, the load method would erroneously return null. This correct the behavior to correctly try to load the entity by ID, which correctly fails since the field is invalid.

# How

Fix check.

# Test Plan

Add new test.
2026-05-21 10:28:28 -07:00
Will Schurman 🦦 292d89167e feat: support numeric enums in StrictEnumField (#609)
# Why

Typescript enums come in two flavors: https://www.typescriptlang.org/docs/handbook/enums.html

Previously, the validation here only worked for string enums.

# How

Update logic to make it work for both string and numeric enums.

# Test Plan

Run new test.
2026-05-21 10:25:28 -07:00
Will Schurman 🦦 20219752b9 feat: validate GenericRedisCacheContext (#608)
# Why

#601 exposed another validation gap in assumptions made about cache keys. We need certain assumptions to be true, namely:
- delimiter is nonzero-length (otherwise issue #601 fixes still would exist)
- delimiter doesn't include escape character (otherwise escape breaks)
- ttlSecondsPositive and negative are positive integers
- cacheKeyVersionsToInvalidateFn returns > 0 versions (otherwise silently doesn't invalidate)

# How

Add validation.

# Test Plan

Add test.
2026-05-21 10:22:29 -07:00
Will Schurman 🦦 717d20367f fix: skip when AllowIfAllSubRulesAllowPrivacyPolicyRule is empty (#607)
# Why

In places where a rule list is dynamically composed, it's likely possible for this list to be empty. Due to the way it's written, it would erroneously allow in this case.

# How

Add check for case and skip.

# Test Plan

Add test.
2026-05-21 10:19:32 -07:00
Will Schurman 🦦 46072a37dd fix: correct cycle guard behavior for deletion (#606)
# Why

There is a bug in self-referential (or transitively-self-referential) cascade cycle detection.

From claude:
```
In AuthorizationResultBasedDeleteMutator:

- deleteInternalAsync (line 802) runs the authorize check, calls
processEntityDeletionForInboundEdgesAsync, runs validators/triggers, deletes the row, and schedules
invalidation callbacks and afterCommit triggers.
- The cycle guard is at the top of processEntityDeletionForInboundEdgesAsync (line 932) — if
(processedEntityIdentifiers.has(entity.getUniqueIdentifier())) return;.

Trace a cycle (A↔B cascade-delete):
1. deleteInternalAsync(A, visited=∅) → runs authorize+triggers+DB delete for A →
processEntityDeletionForInboundEdgesAsync(A, visited=∅), marks A visited, finds B, calls
deleteInternalAsync(B, visited={A}).
2. deleteInternalAsync(B, visited={A}) → runs authorize+triggers+DB delete for B →
processEntityDeletionForInboundEdgesAsync(B, visited={A}), marks B visited, finds A, calls deleteInternalAsync(A,
visited={A,B}).
3. deleteInternalAsync(A, visited={A,B}) → runs authorize+triggers+DB delete for A again → only when it gets into
processEntityDeletionForInboundEdgesAsync does the visited check finally short-circuit.

So beforeDelete/afterDelete/beforeAll/afterAll, the database delete, the post-commit invalidation, and
afterCommit triggers all fire twice for A. The DB delete is idempotent (row's already gone) but triggers
with side effects (emit events, write audit rows, etc.) double-fire.
```

# How

Fix is to move cycle detection to to of `deleteInternalAsync`, thus guarding all checks for already-run entities.

# Test Plan

- Run new test
- Existing test was passing due a stale entity being passed into deleter. The why is a bit nuanced. Here's claude:

```
The original test setup

  const categoryA = await CategoryEntity.creator(viewerContext).createAsync();
  const categoryB = await CategoryEntity.creator(viewerContext)
    .setField('parent_category_id', categoryA.getID())
    .createAsync();
  await CategoryEntity.updater(categoryA)
    .setField('parent_category_id', categoryB.getID())
    .updateAsync();

  After these three statements, the database state is:
  - A: {id: uuidA, parent_category_id: uuidB} (after update)
  - B: {id: uuidB, parent_category_id: uuidA}

  But the local variable categoryA still holds the original entity instance from line 1, where
  parent_category_id was null. updateAsync() returns a new entity, but that return value is discarded — the
  local categoryA was never rebound. So categoryA.getAllDatabaseFields() in the test returns {id: uuidA,
  parent_category_id: null}.

  Why the test passed before the fix

  When the test calls CategoryEntity.deleter(categoryA).deleteAsync(), the outermost mutator's this.entity
  is the stale categoryA (with parent_category_id = null).

  Trace through the OLD cycle behavior for CASCADE_DELETE_INVALIDATE_CACHE_ONLY (so skipDatabaseDeletion =
  true on cascade calls):

  1. Outer A mutator runs. this.entity = stale A. Traverses inbound edges of A, finds B.
  2. Middle B mutator runs (cascade). this.entity = freshly-loaded B from DB (correct fields). Traverses
  inbound edges of B, finds A → cascades to A again.
  3. Inner A mutator runs (cycle re-entry). this.entity = freshly-loaded A from DB, with parent_category_id
  = uuidB (the post-update value).

  3. Under the OLD code, the cycle guard at the top of processEntityDeletionForInboundEdgesAsync stops the
  traversal from recursing further, but the inner deleteInternalAsync still runs validators, triggers, and
  registers a post-commit invalidation callback using this freshly-loaded A's fields.

  After commit, the post-commit invalidation callbacks fire:
  - Outer A registers invalidation for {id: uuidA, parent_category_id: null} → invalidates (id, uuidA) only
  (null isn't a cache key).
  - Middle B registers invalidation for {id: uuidB, parent_category_id: uuidA} → invalidates (id, uuidB) and
   (parent_category_id, uuidA).
  - Inner A registers invalidation for {id: uuidA, parent_category_id: uuidB} → invalidates (id, uuidA) and
  (parent_category_id, uuidB).

  The test asserts that (parent_category_id, uuidB) is invalidated, and it was — because of the inner A's
  fresh-loaded fields. The OLD bug (running deletion side effects twice) was accidentally compensating for
  the stale entity reference held by the test.

  Why the test failed after the fix

  With the cycle guard moved to the top of deleteInternalAsync, the inner A call returns immediately without
   registering any invalidation. Only the outer A's invalidation runs, with the stale parent_category_id =
  null, so (parent_category_id, uuidB) is never invalidated → still HIT → assertion fails.

  This isn't a regression in real-world usage. In real code, callers don't keep stale references after
  updating, because:
  - The idiomatic pattern is const x = await Entity.updater(x)...updateAsync() (rebinding).
  - Non-cycle deletions of stale references already produced incomplete invalidation in the OLD code — the
  cascade re-load only happened to "rescue" the cycle path.

  After the fix, cycle and non-cycle paths behave consistently: the entity instance the caller hands to
  deleter() is the source of truth for what gets invalidated.

  The test edit

  const initialCategoryA = await CategoryEntity.creator(viewerContext).createAsync();
  const categoryB = await CategoryEntity.creator(viewerContext)
    .setField('parent_category_id', initialCategoryA.getID())
    .createAsync();
  const categoryA = await CategoryEntity.updater(initialCategoryA)
    .setField('parent_category_id', categoryB.getID())
    .updateAsync();

  categoryA now binds to the return value of updateAsync(), so it carries the post-update fields. The cycle
  behavior remains under test — the assertion that both (parent_category_id, uuidA) and (parent_category_id,
   uuidB) are MISS still verifies that the cascade correctly invalidated B's and A's cache keys. The fix to
  the test reflects correct API usage, and the cycle handling is independently confirmed by the new fires
  delete-side-effects exactly once per entity in a cycle test.
```
2026-05-21 10:16:27 -07:00
Will Schurman 🦦 4ac1868d98 fix: handle potential infinite recursion for canViewerDeleteAsync (#605)
# Why

In the same way that `processEntityDeletionForInboundEdgesAsync` has detection for reference cycles, this preemptive check utility method needs the same detection mechanism.

# How

Add the same detection mechanism as `processedEntityIdentifiers`

# Test Plan

Add test case for this that fails before change, succeeds after.
2026-05-21 10:13:27 -07:00
Will Schurman 🦦 b1ed31b185 feat: clean up transactional dataloader at transaction end (#604)
# Why

One way memory can grow unbounded within a single request is via transactional dataloaders. If a single request executes N transactions, each one accumulates it's own dataloader state.

Though this has the same semantics as regular dataloaders (which belong to companion provider which is scoped to request by application and discarded after response is sent), they can accumulate more.

# How

The fix is to clear the transactional dataloader at the end of the transaction (or nested transaction), no matter whether the transaction succeeded or rolled-back.

# Test Plan

Add test for condition.
2026-05-21 10:10:28 -07:00
Will Schurman 🦦 38ba558080 fix: correct negative caching behavior in GenericSecondaryEntityCache (#603)
# Why

Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests.

# How

How did you build this feature or fix this bug?

# Test Plan

Please describe how you tested this change and how a reviewer could reproduce your test, especially if this PR does not include automated tests!
2026-05-21 10:07:28 -07:00
Will Schurman 🦦 a920ed0090 fix: update cache key generation for secondary caches (#602)
# Why

Similar to #601, it was possible for a misconfigured application to have collisions in cache keys for secondary caches. Because they likely share the same redis instance at the application level, these keys need similar treatment for separating them logically from normal loader keys plus consistent delimiter escaping.

# How

Hoist final key creation from application level to library level and apply consistent delimiter escaping and namespacing.

# Test Plan

Run new test.
2026-05-21 10:04:28 -07:00
Will Schurman 🦦 df49b12a2e fix!: replace makeKeyFn with delimiter to strengthen guarantee of proper escaping (#601)
# Why

Since cache key construction function is application-supplyable, there's potential that it doesn't escape properly, causing potential cache collisions.

# How

Change the API to only allow specifying the delimiter, and hoist code into the package to handle delimiter escape.

# Test Plan

Run new test to ensure no collisions on something that would've previously collided.
2026-05-21 10:01:28 -07:00
Will Schurman 🦦 22d7298c84 fix: prevent collision in companion when entities share same name or same table name (#600)
# Why

There's nothing preventing two entity classes from sharing the same class name. While not common in practice, this preemptively defends against the issue of collisions in companion maps.

Secondarily, table data coordinators could collide, even if they have different flavors of database and cache adapters. Since those are used to construct the coordinator, include their flavors in the map key.

# How

Update key generation code.

# Test Plan

Run new tests.
2026-05-21 09:58:28 -07:00
Will Schurman 🦦 5ef16f4340 chore: remove unnecessary ViewerScopedEntityMutatorFactory (#599)
# Why

ViewerScopedEntityMutatorFactory existed to make both the loader and mutator patterns more aligned. But it doesn't make as much sense for mutators since for update and delete, the viewerContext used is the one from the entity being updated/deleted instead of the one for the viewer scoped factory. So, in practice the one from the factory was only being used for create mutations.

The simplest change is to just remove the ViewerScopedEntityMutatorFactory to reduce confusion.

# How

Remove class.

# Test Plan

CI
2026-05-21 09:56:08 -07:00
renovate[bot] d0e15580cc chore(deps): update dependency ctix to v2.8.0 (#619) 2026-05-21 08:53:55 -07:00
renovate[bot] a7368a26e2 chore(deps): update dependency @tsconfig/bases to v1.0.24 (#620) 2026-05-21 08:49:21 -07:00
renovate[bot] 7c330773bb chore(deps): update dependency oxfmt to v0.50.0 (#621) 2026-05-21 08:48:17 -07:00
renovate[bot] 0e55f8066e chore(deps): update ci workflows (#618) 2026-05-21 08:44:00 -07:00
renovate[bot] 7786c266d9 chore(deps): update postgres:18 docker digest to 8ff36f3 (#616) 2026-05-21 08:43:23 -07:00
renovate[bot] a70b27a136 chore(deps): update dependency @types/node to v24.12.4 (#617) 2026-05-21 08:37:38 -07:00
renovate[bot] 9dda1bb981 fix(deps): update dependency @apollo/server to v5.5.1 (#598) 2026-05-11 08:15:31 -07:00
renovate[bot] 9c7c0de98f chore(deps): update dependency @types/node to v24.12.3 (#597) 2026-05-11 08:15:01 -07:00
renovate[bot] 05f9bcc6ce chore(deps): update redis docker digest to 0c34149 (#596) 2026-05-11 08:14:27 -07:00
renovate[bot] 0b1fdbe207 chore(deps): update dependency ip-address to v10.1.1 [security] (#594) 2026-05-11 08:14:17 -07:00
renovate[bot] 5fc76ead20 chore(deps): update postgres:18 docker digest to 7e32e98 (#595) 2026-05-11 08:14:07 -07:00
renovate[bot] 98eaaa0298 fix(deps): update dependency uuid to v14 [security] (#593) 2026-04-27 09:11:04 -07:00
renovate[bot] 797e33bd7b chore(deps): update dependency oxfmt to v0.46.0 (#592) 2026-04-27 08:29:38 -07:00
renovate[bot] 4c42f7be8f chore(deps): update postgres:18 docker digest to 7848165 (#589) 2026-04-27 08:26:27 -07:00
renovate[bot] 6add3fb767 chore(deps): update redis docker digest to 832d778 (#590) 2026-04-27 08:26:20 -07:00
renovate[bot] 7d7457a98c chore(deps): update dependency jdx/mise to v2026.4.24 (#591) 2026-04-27 08:25:59 -07:00
renovate[bot] fccf3eb0de chore(deps): update dependency oxlint to v1.60.0 (#588) 2026-04-20 09:34:10 -07:00
renovate[bot] 6ea3dae767 chore(deps): update dependency typedoc to v0.28.19 (#585) 2026-04-20 09:29:22 -07:00
renovate[bot] 604353645d chore(deps): update dependency oxfmt to v0.45.0 (#587) 2026-04-20 08:29:49 -07:00
renovate[bot] 7a3a239313 chore(deps): update ci workflows (#583) 2026-04-20 08:27:55 -07:00
renovate[bot] 36c58cfdf9 chore(deps): update dependency typescript to v6.0.3 (#586) 2026-04-20 08:27:39 -07:00
renovate[bot] a0bb5b9874 chore(deps): update dependency prettier to v3.8.3 (#584) 2026-04-20 08:26:34 -07:00
renovate[bot] 9d851edf9f chore(deps): lock file maintenance (#581) 2026-04-14 16:57:22 -07:00
Will Schurman 🦦 4519909be8 fix: upgrade knex and remove undefined value bindings (#582) 2026-04-14 16:46:07 -07:00
renovate[bot] 1365b11ccb chore(deps): update dependency typescript to v6 (#580) 2026-04-13 14:06:33 -07:00
renovate[bot] 226e1bfb98 chore(deps): update codecov/codecov-action action to v6 (#579) 2026-04-13 13:20:24 -07:00
renovate[bot] b4ed49b289 chore(deps): update dependency oxlint-config-universe to v0.0.3 (#574) 2026-04-13 13:10:00 -07:00
renovate[bot] 5df53a0a7f chore(deps): update dependency prettier to v3.8.2 (#575) 2026-04-13 13:09:51 -07:00
renovate[bot] b8a1f97ea8 chore(deps): update dependency jdx/mise to v2026.4.11 (#573) 2026-04-13 13:09:27 -07:00
renovate[bot] fbd06e4368 chore(deps): update dependency oxfmt to v0.44.0 (#576) 2026-04-13 13:08:56 -07:00
renovate[bot] 4c569b6012 chore(deps): update dependency oxlint-tsgolint to v0.20.0 (#577) 2026-04-13 13:07:30 -07:00
renovate[bot] f039e71041 fix(deps): update dependency koa to v3.2.0 (#578) 2026-04-13 13:06:46 -07:00
renovate[bot] f60b439939 chore(deps): pin dependencies (#569) 2026-04-13 10:56:13 -07:00
renovate[bot] 570ef6f37d chore(deps): update postgres:18 docker digest to 52e6ffd (#570) 2026-04-13 10:55:56 -07:00
renovate[bot] 26a45d3489 chore(deps): update redis docker digest to 1f07381 (#571) 2026-04-13 10:55:45 -07:00
renovate[bot] 976f6e6434 chore(deps): update dependency @types/node to v24.12.2 (#572) 2026-04-13 10:55:34 -07:00
Will Schurman 🦦 9a4f08e31a feat: add count equivalent for knex loader methods (#565) 2026-04-10 17:45:57 -07:00
Will Schurman 🦦 2240e94d08 chore: use oxfmt for import ordering (#564)
# Why

Part 2 of #559.

# How

Use sortImports, and update lint to also run format for eslint parity.

# Test Plan

`yarn lint`
2026-04-08 15:02:23 -07:00
Will Schurman 🦦 a41a95b38d chore: switch to oxfmt/oxlint (#559)
# Why

oxfmt/oxlint has near parity with prettier/eslint but is about 10x faster (10s -> 1s). 

This uses `oxlint-config-universe` equivalent of `eslint-config-universe` as the base. There are known differences between eslint and oxlint, documented in the readme of `oxlint-config-universe`.

The missing rules which we can live without temporarily in exchange for the speedups:
- `naming-convention` (async suffix, PascalCase, UPPER_CASE)
- `no-restricted-properties` (.only/.skip)
- `no-restricted-exports` (no default exports)
- `tsdoc/syntax`
- object-shorthand
- no-dupe-args, no-octal, no-octal-escape
- n/no-deprecated-api

I've created a task to track: #567.

# How

Have claude convert. Use `oxlint-config-universe` and manually audit resulting config against old eslint config. Note down known differences in #567.

In stacked PR, use oxfmt for import ordering as suggested by readme in `oxlint-config-universe`

# Test Plan

`yarn lint`
2026-04-08 14:59:26 -07:00
renovate[bot] 9b642b31c8 fix(deps): update dependency @koa/router to v15.4.0 (#563) 2026-04-06 11:13:03 -07:00
renovate[bot] 64b646786b chore(deps): update dependency jdx/mise to v2026.4.5 (#561) 2026-04-06 11:11:59 -07:00
renovate[bot] 623ab6cf8a chore(deps): update yarn to v4.13.0 (#562) 2026-04-06 11:11:02 -07:00
renovate[bot] 5988712332 fix(deps): update dependency graphql to v16.13.2 (#560) 2026-04-06 11:10:45 -07:00
renovate[bot] 1ed43ddaa5 chore(deps): update dependency lodash-es to v4.18.1 [security] (#557) 2026-04-06 11:10:16 -07:00
James Ide dbd1cc8479 v0.63.0 2026-04-02 17:46:56 -07:00
Will Schurman 🦦 977b790ba7 feat!: remove deprecated loadManyByRawWhereClauseAsync (#556) 2026-04-02 08:11:41 -07:00
Will Schurman 🦦 2693fbc000 fix: remove unnecessary RETURNING * from database adapter update (#555) 2026-04-02 08:11:24 -07:00
renovate[bot] 4709f53b45 chore(deps): update redis docker digest to 009cc37 (#548) 2026-03-30 10:40:28 -07:00
renovate[bot] f4496b9cc7 chore(deps): update ci workflows (#549) 2026-03-30 10:40:09 -07:00
renovate[bot] 15ebe7d584 chore(deps): update dependency ioredis to v5.10.1 (#550) 2026-03-30 10:39:59 -07:00
renovate[bot] 0d00605a93 chore(deps): update dependency lerna to v9.0.7 (#551) 2026-03-30 10:39:38 -07:00
renovate[bot] 73d45fdca3 chore(deps): update node.js to v24.14.1 (#553) 2026-03-30 10:38:40 -07:00
renovate[bot] 3421a746be chore(deps): update dependency typedoc to v0.28.18 (#552) 2026-03-30 10:38:28 -07:00
renovate[bot] 5ae965b736 fix(deps): update dependency @apollo/server to v5.5.0 [security] (#547) 2026-03-27 10:41:59 -07:00
David Mokos bfa703e4ed fix: expose fieldName and fieldValue on EntityInvalidFieldValueError (#546) 2026-03-27 04:58:19 -07:00
Will Schurman 🦦 174d6c602b feat: expose full transaction config on EntityTransactionalQueryContext (#545) 2026-03-25 09:06:27 -07:00
renovate[bot] 0c0c6a3496 chore(deps): update redis docker digest to 315270d (#542) 2026-03-23 09:30:28 -07:00
renovate[bot] 3f103e1dbd chore(deps): update postgres:18 docker digest to a9abf42 (#541) 2026-03-23 09:29:54 -07:00
renovate[bot] d90dce72c2 chore(deps): update ci workflows (#543) 2026-03-23 09:29:21 -07:00
renovate[bot] d800024283 chore(deps): update dependency @types/koa to v3.0.2 (#544) 2026-03-23 09:29:06 -07:00
Will Schurman 🦦 b6c950900d chore: add docblocks for some things (#539) 2026-03-18 16:03:34 -07:00
Will Schurman 4965cc2388 v0.62.0 2026-03-16 11:45:34 -07:00
Will Schurman 🦦 e3cee07091 fix: only throw EntityDatabaseAdapterPaginationCursorInvalidError from cursor decoding logic (#537)
# Why

Noticed that this could throw a TypeError if the decoded JSON wasn't an object:

> TypeError: Cannot use 'in' operator to search for 'id' in blah

# How

Add check that this is an object type ahead of using `in` operator.

# Test Plan

Add new tests for this, run them.
2026-03-16 11:36:22 -07:00
Will Schurman 🦦 0a1d1b5972 fix: remove unused arg from EntityInvalidationUtils (#536)
# Why

This argument was unused, remove it.

# How

Was checking whether EntityInvalidationUtils was the same instance (or same behaviorally) across multiple entities backed by the same table (share a config, different TSelectedFields). Being able to remove this arg proves it since the other args aren't entity-type-specific and are only configuration-specitic.

# Test Plan

`yarn tsc`
2026-03-16 11:33:23 -07:00
renovate[bot] db887d3d7d chore(config): migrate Renovate config (#538) 2026-03-16 11:31:59 -07:00
Will Schurman 🦦 2214f7d19a chore: add docblocks for SQLExpression overload methods (#534)
# Why

Noticed while using these methods in the Expo server application that they were missing docblocks due to vscode preferring the docblock for the overload over the object property.

# How

This adds overload-specific docblocks to ensure they are present in vscode, and since they're overload-specific they are more accurate as well.

# Test Plan

`yarn tsc`
2026-03-16 11:29:57 -07:00
Will Schurman 🦦 4f4243d314 chore: update renovate to batch and schedule to decrease attention cost (#528) 2026-03-16 11:28:09 -07:00
renovate[bot] a505216608 chore(deps): lock file maintenance (#535) 2026-03-15 20:26:52 -07:00
renovate[bot] bc40501535 chore(deps): update dependency jdx/mise to v2026.3.9 (#532) 2026-03-15 11:34:32 -07:00
renovate[bot] 790e75160b chore(deps): update dependency lerna to v9.0.6 (#533) 2026-03-15 11:34:06 -07:00
renovate[bot] cd38019789 chore(deps): update jdx/mise-action action to v4 (#531) 2026-03-13 10:53:04 -07:00
renovate[bot] e1c47de3a7 chore(deps): update jest monorepo to v30.3.0 (#530) 2026-03-13 09:04:20 -07:00
Will Schurman a6f1dd7232 v0.61.0 2026-03-12 12:34:47 -07:00
Will Schurman 🦦 112d4dc9b3 feat: update the SQLExpression and SQLFragmentHelpers APIs (#516) 2026-03-11 16:06:29 -07:00
renovate[bot] f33d388b48 chore(deps): update dependency jdx/mise to v2026.3.8 (#529) 2026-03-11 16:05:28 -07:00
Nick Novitski f138841a59 chore: run jest without babel 2026-03-11 13:02:25 -07:00
Nick Novitski 090c046bbc chore: use combined @tsconfig/bases package 2026-03-11 08:29:23 -07:00
Nick Novitski 81906aed16 chore: Run tests with ESM 2026-03-11 08:25:40 -07:00
Nick Novitski ea9c45fade chore: replace lodash with lodash-es 2026-03-11 08:21:24 -07:00
Nick Novitski 2fc88cb204 chore!: ESM 2026-03-11 08:17:28 -07:00
Nick Novitski e4133a865e chore: generate barrel files with file endings 2026-03-11 08:14:24 -07:00
Nick Novitski f75ed7f749 chore: allow relative imports with ts file extension 2026-03-11 08:11:07 -07:00
renovate[bot] 6bfde96bc3 chore(deps): update dependency jdx/mise to v2026.3.7 (#527) 2026-03-10 18:43:42 -07:00
Nick Novitski 575d8545f7 chore: enable consistent-type-imports lint rule 2026-03-10 09:50:21 -07:00
Nick Novitski 1aae31b0bf chore: use @expo/nullthrows in tests 2026-03-09 17:37:34 -07:00
renovate[bot] 6cbe50a048 chore(deps): update dependency eslint to v9.39.4 (#525) 2026-03-09 16:59:01 -07:00
renovate[bot] 0b4385698c chore(deps): update dependency jdx/mise to v2026.3.6 (#515) 2026-03-09 14:39:22 -07:00
renovate[bot] 1a8535f983 fix(deps): update dependency graphql to v16.13.1 (#510) 2026-03-09 11:18:02 -07:00
renovate[bot] 1f66b9f8f3 chore(deps): update dependency @types/node to v24.12.0 (#512) 2026-03-09 11:17:08 -07:00
renovate[bot] 6a07e6bd66 chore(deps): update dependency pg to v8.20.0 (#511) 2026-03-09 11:16:20 -07:00
Nick Novitski 9202ecbb23 chore: remove @types/uuid package
This is outdated; `uuid` ships with types.
2026-03-09 11:06:52 -07:00
Nick Novitski a3085ed911 Fix renovate updates
Mise can manage yarn versions, but the free renovate doesn't know to use
that configuration.  After this commit, the yarn version is set by the
packageManager field in the root package.json file, and downloaded by
corepack in all environments.
2026-03-09 11:06:33 -07:00
renovate[bot] 934e5f3199 chore(deps): update dependency jdx/mise to v2026.3.5 (#509) 2026-03-08 22:20:26 -07:00
Will Schurman e36dc81bd2 v0.60.0 2026-03-06 09:24:05 -08:00
renovate[bot] 2fc9d9c196 chore(deps): update jdx/mise-action action to v3.6.3 (#508) 2026-03-06 09:22:15 -08:00
Will Schurman 🦦 535b6811e0 feat: Add SQLExpression class that supports fluent comparison method API (#506) 2026-03-06 08:58:32 -08:00
renovate[bot] 792c6c851b chore(deps): update dependency ctix to v2.7.5 (#507)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [ctix](https://imjuni.github.io/ctix) ([source](https://redirect.github.com/imjuni/ctix)) | [`2.7.2` → `2.7.5`](https://renovatebot.com/diffs/npm/ctix/2.7.2/2.7.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/ctix/2.7.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/ctix/2.7.2/2.7.5?slim=true) |

---

### Release Notes

<details>
<summary>imjuni/ctix (ctix)</summary>

### [`v2.7.5`](https://redirect.github.com/imjuni/ctix/releases/tag/v2.7.5)

[Compare Source](https://redirect.github.com/imjuni/ctix/compare/v2.7.4...v2.7.5)

#### What's Changed

- fix(creating): correct exclusion filter logic in filename processing by [@&#8203;imjuni](https://redirect.github.com/imjuni) in [#&#8203;190](https://redirect.github.com/imjuni/ctix/pull/190)
- chore(version): bump version to 2.7.5 by [@&#8203;imjuni](https://redirect.github.com/imjuni) in [#&#8203;191](https://redirect.github.com/imjuni/ctix/pull/191)

**Full Changelog**: <https://github.com/imjuni/ctix/compare/v2.7.4...v2.7.5>

### [`v2.7.4`](https://redirect.github.com/imjuni/ctix/releases/tag/v2.7.4)

#### What's Changed

- fix(creating): resolve duplicate content in index.ts files during create mode by [@&#8203;imjuni](https://redirect.github.com/imjuni) in [#&#8203;185](https://redirect.github.com/imjuni/ctix/pull/185)
- chore(version): bump version to 2.7.3 by [@&#8203;imjuni](https://redirect.github.com/imjuni) in [#&#8203;186](https://redirect.github.com/imjuni/ctix/pull/186)
- fix: resolve duplicate exports and case sensitivity issues by [@&#8203;imjuni](https://redirect.github.com/imjuni) in [#&#8203;187](https://redirect.github.com/imjuni/ctix/pull/187)
- feat(config): remove index.ts file to streamline exports by [@&#8203;imjuni](https://redirect.github.com/imjuni) in [#&#8203;188](https://redirect.github.com/imjuni/ctix/pull/188)
- chore(version): bump version to 2.7.4 by [@&#8203;imjuni](https://redirect.github.com/imjuni) in [#&#8203;189](https://redirect.github.com/imjuni/ctix/pull/189)

**Full Changelog**: <https://github.com/imjuni/ctix/compare/v2.7.2...v2.7.4>

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/expo/entity).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41Ni4wIiwidXBkYXRlZEluVmVyIjoiNDMuNTYuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->
2026-03-05 14:19:37 -08:00
renovate[bot] 038b2bff76 chore(deps): update dependency jdx/mise to v2026.3.3 (#505) 2026-03-04 14:57:09 -08:00
renovate[bot] 599fccaf73 chore(deps): update dependency jdx/mise to v2026.3.2 (#504) 2026-03-04 09:10:14 -08:00
renovate[bot] c361dfd7ad chore(deps): update dependency @tsconfig/node24 to v24.0.4 (#503) 2026-03-03 22:26:43 -08:00
renovate[bot] 41bdcf464e chore(deps): update jdx/mise-action action to v3.6.2 (#500) 2026-03-03 22:11:50 -08:00
renovate[bot] a07429fa98 chore(deps): update node.js to v24.14.0 (#501) 2026-03-03 22:11:35 -08:00
renovate[bot] a653ce137c chore(deps): update actions/setup-node action to v6.3.0 (#502) 2026-03-03 22:11:13 -08:00
Nick Novitski 7bea8fbccf chore: provide node and yarn with mise 2026-03-03 16:30:41 -08:00
Will Schurman bfe1aad035 v0.59.0 2026-03-03 14:33:29 -08:00
Will Schurman 🦦 b931daa9a0 feat: add support to SQLFragment for = ANY(?) (#498) 2026-03-03 14:15:00 -08:00
renovate[bot] 72b3c80d12 fix(deps): update dependency @koa/router to v15.3.1 (#497) 2026-03-03 08:51:43 -08:00
renovate[bot] 57e9e06601 chore(deps): update dependency lerna to v9.0.5 (#496) 2026-03-03 08:15:52 -08:00
renovate[bot] 82978f8364 chore(deps): update dependency @types/node to v24.11.0 (#495) 2026-03-03 08:14:02 -08:00
Will Schurman 7dab49a36d v0.58.0 2026-03-02 12:04:25 -08:00
Will Schurman 🦦 21f2f6f461 fix: escape all matching portions of parts of cacher cache keys (#486) 2026-03-02 10:02:17 -08:00
renovate[bot] 4126dcd8f2 chore(deps): lock file maintenance (#493) 2026-03-02 08:04:18 -08:00
renovate[bot] 6889178e60 chore(deps): update dependency ioredis to v5.10.0 (#494) 2026-03-02 07:54:41 -08:00
renovate[bot] a0d3d30265 chore(deps): update dependency @types/node to v24.10.15 (#491) 2026-03-02 07:54:05 -08:00
renovate[bot] d0825c0446 chore(deps): update redis docker digest to 1c054d5 (#492) 2026-03-02 07:51:59 -08:00
renovate[bot] e8440f11c0 chore(deps): update dependency eslint-plugin-tsdoc to v0.5.2 (#488) 2026-03-02 07:51:43 -08:00
renovate[bot] baa1674629 chore(deps): update dependency @types/supertest to v7 (#490) 2026-03-02 07:51:24 -08:00
renovate[bot] 25520c68e8 chore(deps): update dependency pg to v8.19.0 (#489) 2026-03-02 07:50:48 -08:00
renovate[bot] 905e8522b3 chore(deps): update postgres:18 docker digest to 69e8582 (#487) 2026-03-02 07:50:20 -08:00
Will Schurman 🦦 e263bd218c fix: fix some security review suggestions (#485) 2026-02-27 17:59:51 -08:00
Will Schurman e876cfb27b v0.57.0 2026-02-27 17:30:00 -08:00
Will Schurman b9db8a4717 v0.56.0 2026-02-27 17:27:31 -08:00
Will Schurman 🦦 459a5ff850 feat: add entityField SQL helper (#481)
# Why

Asked claude to do an API usability review and it noted that forcing the user to context-switch between entity fields and database fields for SQLFragments was an unfortunate pattern.

# How

This PR provides a SQLFragment helper, `entityField<TFields>('fieldName')` which does the conversion for the user so they don't need to look up the underlying database field.

Secondarily, this provides type safety to ensure SQLFragments for one entity aren't used for SQLFragments of another entity (unless types overlap via shared config).

# Test Plan

Add feature and tests and examples.
2026-02-27 15:44:23 -08:00
Will Schurman 🦦 beda973c0c chore: raw -> unsafeRaw (#480)
# Why

Asked claude to do a security review of entity-database-adapter-knex and renaming `raw` to `unsafeRaw` was one suggestion.

# How

Rename.

# Test Plan

`yarn tsc`
2026-02-27 15:40:10 -08:00
renovate[bot] b258dad2ed fix(deps): update dependency koa to v3.1.2 [security] (#484) 2026-02-27 09:28:08 -08:00
renovate[bot] 976e2c3386 fix(deps): update dependency graphql to v16.13.0 (#479) 2026-02-27 09:02:48 -08:00
Will Schurman 🦦 e32c4c2bcc fix: correct pagination behavior for standard pagination with descending ordering clauses (#477)
# Why

This bug was subtle but important, and can be demonstrated best by looking at the new test case added `performs forward pagination with descending order`.

The bug was that cursor comparison must be derived from both direction but also ASC/DESC of the order bys.

Example:
```
obj1(t=1)
obj2(t=2)
obj3(t=3)
obj4(t=4)

> loadPage(first: 2, orderBy: t DESC)
obj4
obj3

> loadPage(first: 2, orderBy: t DESC, after: obj3)
```

the issue is that the cursor comparison for this second load would be:
```
(t) > (obj3.t)
```
which yields only obj4, which is incorrect.

# How

The fix is to determine cursor comparator by both the order by direction and the pagination direction, thus making the cursor from the example above:
```
(t) < (obj3.t)
```

The second part of this is to require all orderBys to have the same ordering for pagination, otherwise this new assumption breaks since cursor comparison then would be unable to be done with a single comparator. While there are ways we can handle different order by directions, we'd have to not use a cursor which adds a massive amount of complexity.

A small other fix was to set the `id` tiebreaker column order to the same as the other orders.

# Test Plan

Add new test case.
2026-02-26 18:35:23 -08:00
Will Schurman 🦦 2c4c649a4b fix: correct precedence parentheses for SQLFragment joins (#476)
# Why

There's a bug in where clause construction, which used SQLFragmentHelpers.and: https://github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303

The bug is that given something like:
```
where: sql`account_id = ${accountId}`,
searchWhere: sql`something ILIKE term OR similarity(term, ...) OR ...`
```
it would produce
```
WHERE account_id = ${accountId} AND something ILIKE term OR similarity(term, ...) OR ...
```

which has the incorrect parentheses and therefore incorrect precedence.

This exposed more bugs in the precedence in `or`, `and`, `join`, etc methods.

# How

The fix is multifold:
- Make the public `join` method only be used for comma-separation joining. Rename it to `joinWithCommaSeparator`.
- Wrap each group in `or` and `and` in parentheses to ensure correct precedence.
    ```
    > SQLFragment.or(sql`account_id = ${accountId}`, sql`app_id = ${appId}`
    sql`(account_id = ${accountId}) OR (app_id = ${appId})`
    ```

# Test Plan

Run existing tests to ensure nothing breaks, update unit tests and inspect output SQL statements to ensure they are correct parentheses. Especially the `builds complex queries with multiple helpers` test case.
2026-02-26 18:31:23 -08:00
Will Schurman 🦦 898f71ce08 feat: add method to get pagination cursor for single entity (#475)
# Why

In a decent number of tests in the Expo server application, it's useful to test pagination from a specific point. It may also be useful for production code in an thus far unbeknownst way.

To do this, one must be able to generate the cursor given an entity. This PR adds such a method.

# How

Add the method that creates a cursor.

# Test Plan

Run the new test.
2026-02-26 18:27:23 -08:00
Will Schurman 🦦 90ae6e299b fix: correct discriminated union type for forward/backward pagination (#474)
# Why

When testing this downstream, I noticed that it was allowed in types to specify first & before / last & after which aren't supported in the pagination.

# How

Fix the types by being explicit about the other fields.

# Test Plan

`yarn tsc`
2026-02-26 18:23:23 -08:00
Will Schurman 🦦 53b0604152 feat: support nullable search fields with postgres transforms (#470)
# Why

#468 added nulls support for regular order bys, but explicitly disabled it for search pagination due to it adding quite a lot of complexity. It also fixed a bug where using a nullable field in pagination in general would break the cursor condition and omit rows (any tuple containing NULL would be dropped due to postgres behavior).

But applications still need to be able to search fields that are nullable, they just need to provide a mechanism for dictating how to cast them as non-nullable. This PR provides such a mechanism.

# How

Allow the user to specify a function to craft the search field SQLFragment from nullable fields. This is required to be a somewhat awkward shape since the library needs to retain control over the field name due to table aliasing in the cursor.

# Test Plan

Run new tests.
2026-02-26 18:19:23 -08:00
Will Schurman 🦦 018a40bc01 fix: Apply same viewer context subclass type mechanism to knexLoader and related methods (#472)
# Why

When these were installed extension functions, we could get away without the `TMViewerContext2` technique. But now that these are loader methods on the `PostgresEntity` subclass, we need this again (it's the same as Entity and ReadonlyEntity).

This type allows the typechecker to behave correctly when a subclass of ViewerContext is used.

# How

Add generic the same as ReadonlyEntity/Entity.

# Test Plan

`yarn tsc` in application that uses ViewerContext subclass and knexLoader.
2026-02-26 18:15:19 -08:00
renovate[bot] 6849468d15 chore(deps): update postgres:18 docker digest to 5558651 (#478) 2026-02-26 17:08:34 -08:00
renovate[bot] c60f4cd455 chore(deps): update dependency @types/lodash to v4.17.24 (#471) 2026-02-26 14:10:28 -08:00
renovate[bot] 820fe88217 chore(deps): update postgres:18 docker digest to a688ae5 (#473) 2026-02-26 14:10:03 -08:00
Will Schurman 🦦 6c0838dc57 feat: add support for nulls ordering in order by clauses (#468) 2026-02-25 15:15:15 -08:00
renovate[bot] 5c11b598c4 chore(deps): update redis docker digest to 5cb00b0 (#467) 2026-02-24 18:47:25 -08:00
Will Schurman 🦦 c946630a82 chore: deprecate loadManyByRawWhereClause (#460)
# Why

Now that `loadManyBySQL` supports all the features of this method (including raw order by), deprecate it.

# How

Mark as deprecated in docblock.

# Test Plan

`yarn tsc`
2026-02-24 16:16:23 -08:00
Will Schurman 🦦 15adeb88cf feat!: convert all raw order bys to use same mechanism and syntax (#459)
# Why

#457 added a new mechanism for specifying ORDER BY clauses using SQLFragment that aligns a lot better with the loader APIs than having separate `orderByRaw`/`orderByFragment` fields in the query modifiers.

# How

Replace all with the new consistent order by in order field, update code to handle that.

One neat side-effect is that we no longer need the hard-to-follow direction-specific ordering in deep methods like `buildTrigramExactMatchPriority` since now we can have those just produce a SQLFragment and reverse the ordering the same as all the other pagination methods.

# Test Plan

Run all tests.
2026-02-24 16:12:25 -08:00
Will Schurman 🦦 9940fcbed2 chore: SQLFragment.toDebugString -> getDebugString (#458)
# Why

Small change to the debug method for SQLFragment. Noticed claude made it a `get toDebugString`, which doesn't quite fit within the patterns in this repo.

# How

Rename to `getDebugString()` method for consistency.

# Test Plan

`yarn tsc`
2026-02-24 16:08:23 -08:00
Will Schurman 🦦 0615ffacff feat: Add raw sqlfragment orderBy capability for pagination (#457)
# Why

One missing piece from pagination is the ability to order by a SQL fragment. This is useful for cases like needing to apply postgres transforms or coalescing to the columns in the order by clause.

Note that this, in combination with `loadBySQL` will completely replace the need for `loadManyByRawWhereClause` (including it's orderByRaw variant), as well as `orderByFragment` in the new pagination code, which is done in a PR upstack.

# How

Make order by specifications either be able to use SQLFragment to specify the field selection but not the ASC/DESC since the framework still needs to retain control of those for pagination purposes.

# Test Plan

Run new added tests.
2026-02-24 16:04:31 -08:00
renovate[bot] 5f3f31bb7c chore(deps): update postgres:18 docker digest to 9b5bd94 (#466) 2026-02-24 16:03:20 -08:00
renovate[bot] 097ac65fc4 chore(deps): update redis docker digest to db01931 (#465) 2026-02-24 16:03:06 -08:00
Will Schurman 🦦 e71ae5fcba feat: Add authorization action to EntityPrivacyPolicyRuleEvaluationContext (#464) 2026-02-24 15:55:28 -08:00
renovate[bot] 3065f360db chore(deps): update node.js to v24.14.0 (#463) 2026-02-24 11:46:37 -08:00
renovate[bot] c3fe15e655 chore(deps): update redis docker digest to dc123d5 (#461) 2026-02-23 17:02:33 -08:00
renovate[bot] 2d41777be0 chore(deps): update dependency eslint to v9.39.3 (#456) 2026-02-23 07:40:49 -08:00
renovate[bot] 45da844a5c chore(deps): lock file maintenance (#455) 2026-02-23 07:36:40 -08:00
Will Schurman 🦦 4dc156c7a8 fix: explicitly document behavior when cursor entity no longer exists (#453)
# Why

The one downside of using id-based cursor pagination and subqueries (added in #422, #431) is that it risks the row referenced by the cursor being deleted between pagination requests. It's an edge case though to be clear. Encoding the full cursor alleviates this, but has it's own downsides since trigram similarity isn't encodable and neither are things like Date objects. It is still preferable to use ID-based cursors for all pagination.

But it becomes the library's responsibility to explicitly document what the behavior is when a row referenced by the cursor is no longer present. This PR does this.

# How

When the cursor row is no longer present, the tuple evaluates to `NULL`, which produces a empty page.

The alternative to this behavior is to run an id check query ahead of the pagination query, and throw an error if it doesn't exist, or even return the first page of data if it doesn't exist. Both of these are less optimal since they cause unexpected behavior during results consumption.

So we keep the behavior as is and explicitly document it.

# Test Plan

Run new tests.
2026-02-17 14:56:23 -08:00
Will Schurman 🦦 cc78029117 fix: eliminate some unnecessary any casts (#448)
# Why

Did a brief audit of `any` casts in the library and removed ones that are no longer needed or could be replaced with a better concrete type.

# How

Grep for `as any`, fix.

# Test Plan

`yarn tsc`
2026-02-17 14:52:24 -08:00
Will Schurman 🦦 517d57b5df fix!: change adaptor -> adapter for consistency (#452)
# Why

As part of #451, claude noticed that we used both american and british english variants of "adapter"/"adaptor". This makes them consistent by making them all "adapter".

# How

Update name. Technically this is a breaking change but the nature of the library allows `tsc` to catch it in all application code that uses the library.

# Test Plan

`yarn tsc`
2026-02-17 14:48:25 -08:00
Will Schurman 🦦 c933db1f8a chore: docblock improvements (#451)
# Why

Asked claude to audit the docblocks for incorrect or missing info. This is what it mentioned as being good improvements.

# How

Update docblocks.

# Test Plan

Proofread.
2026-02-17 14:44:24 -08:00
Will Schurman 🦦 d9377a290b fix: Reorganize constructionUtils to make non-public (#454)
# Why

In #410 we split out `EntityLoaderUtils` into two classes:
- EntityInvalidationUtils - functions for invalidating entity caches from application code when underlying data is mutated outside of the entity framework
- EntityConstructorUtils - functions for constructing and authorizing entities.

Originally, these were both public due to them being the same class. Now, with them separated, we can better restrict access to entity construction which should only be done within the framework itself.

# How

Make constructor utils private everywhere except EntityLoaderFactory, which is deep enough in the abstraction that it's not immediately usable by top-level entity APIs (static methods on Entity/ReadonlyEntity, loader classes).

# Test Plan

`yarn tsc`, CI
2026-02-17 14:40:57 -08:00
Will Schurman 🦦 457f58ad2c fix: update integration test to use BlahEntity.knexLoader syntax (#444)
# Why

Part 4 of #441. This reverts the test changes to use the better pattern, though both work equivalently and are both tested.

# How

Update tests.

# Test Plan

Run tests
2026-02-16 13:26:21 -08:00
Will Schurman 🦦 ed6b2e0553 fix: revert change to codemod now that Entity.knexLoader is supported again via subclass (#443)
# Why

Part 3 of the refactor from #441.

This reverts the codemod back to use the `BlahEntity.knexLoader(vc)` syntax.

# How

`git revert`

# Test Plan

Run the tests in codemod.
2026-02-16 13:22:22 -08:00
Will Schurman 🦦 40b409f6f8 feat: add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods (#442)
# Why

This is part 2 of the refactor described in #441.

It adds subclasses of Entity and ReadonlyEntity that supply the static `knexLoader` methods to keep the ergonomics of the extension but without the monkey-patching.

# How

Add subclasses, add test.

# Test Plan

Run test.
2026-02-16 13:18:22 -08:00
Will Schurman 🦦 51d632da16 fix: refactor install method to instead be free functions (#441)
# Why

Asked claude extended thinking to analyze the `installExtensions` monkey-patching strategy we went with in  #410 and compare it to other typescript ORM libraries.

It said essentially that it was ok, but most modern libraries choose not to do it that way, and instead recommended "free functions" (TIL about that term) to instantiate the loaders. (`knexLoader(BlahEntity, vc)`)

Working with it even more, I came up with an even better method on top of free functions to keep the `BlahEntity.knexLoader(vc)` syntax. The strategy is to:
1. Convert to free functions (this PR).
2. Add `PostgresEntity`/`ReadonlyPostgresEntity` subclasses that application entities can extend that implement the static extension methods.
3. Revert the codemod change from this PR to keep the codemod to prefer the `BlahEntity.knexLoader(vc)` syntax since most applications should use the subclasses (though it's not strictly necessary if they prefer the free function at callsites).
4. Revert the codemod in the integration tests to express better syntax pattern.

# How

Claude did most of this, but broadly speaking the strategy is to have the free functions, and then instead of patching the prototypes/classes for singleton caching, use a WeakMap where the core package singleton is the key and the value is the new knex-specific object.

# Test Plan

Run all tests.
2026-02-16 13:14:21 -08:00
Will Schurman 🦦 3d4aa0e9ef fix: Add default for TSelectedFields for application-instantiated types (#438)
# Why

When testing an upgrade to this package in the Expo server application, noticed that this was a minor annoyance since TSelectedFields has a default in all other public API methods.

# How

Add default to be consistent.

# Test Plan

`yarn tsc`
2026-02-16 13:10:22 -08:00
Will Schurman 🦦 f5c728be56 fix: pass in augmentable classes to installation (#437)
# Why

An idea proposed by @ide in https://github.com/expo/entity/pull/410#pullrequestreview-3765893898.

This makes it more testable and clearer about what objects installations are "allowed" to augment.

# How

Pass in the classes explicitly.

# Test Plan

`yarn tsc`
2026-02-16 13:06:21 -08:00
renovate[bot] 285f22c461 chore(deps): update dependency typedoc to v0.28.17 (#449) 2026-02-16 13:03:44 -08:00
Will Schurman 🦦 3ec4b5f0cd feat: Add pagination max page size configuration (#436)
# Why

Currently, the Entity pagination system allows clients to request arbitrarily large page sizes using the `first` or `last` parameters in pagination queries. This can lead to:
- Performance degradation when clients request excessively large pages
- Potential memory issues on the server
- Unpredictable query response times
- Difficulty in capacity planning and resource allocation

By adding configurable max page size limits, we can:
- Protect backend services from resource exhaustion
- Ensure predictable performance characteristics
- Provide better control over API usage patterns
- Maintain backward compatibility (no limit by default)

# How

The implementation adds a `paginationMaxPageSize` configuration option to the PostgresEntityDatabaseAdapterProvider that flows through the adapter hierarchy:

1. **Configuration Interface**: Added `PostgresEntityDatabaseAdapterConfiguration` with an optional `paginationMaxPageSize` field
2. **Provider Level**: `PostgresEntityDatabaseAdapterProvider` constructor now accepts the configuration object
3. **Adapter Level**:
   - `BasePostgresEntityDatabaseAdapter` defines a `paginationMaxPageSize` getter (defaults to `undefined`)
   - `PostgresEntityDatabaseAdapter` overrides it to return the configured value
4. **Validation**: `EntityKnexDataManager.loadPageAsync` validates `first`/`last` parameters against the max page size before executing queries

The validation throws descriptive errors when limits are exceeded:
- `"first must not exceed maximum page size of {limit}"`
- `"last must not exceed maximum page size of {limit}"`

Usage example:
```typescript
const provider = new PostgresEntityDatabaseAdapterProvider({
  paginationMaxPageSize: 100
});
```

# Test Plan

Added comprehensive unit tests in `EntityKnexDataManager-test.ts`.
2026-02-16 13:02:21 -08:00
Will Schurman 🦦 f72cbcaf5a feat: Add ilike and trigram similarity search to pagination (#431)
# Why

This PR adds search capabilities to the existing cursor-based pagination system added in #422 which only supported orderBy ordering.

This adds:
1. Case-insensitive pattern matching (ILIKE) - Useful for basic text search across entity fields
2. Trigram similarity search - Provides fuzzy matching capabilities for more advanced search use cases like handling typos or finding similar text

This enables building user-facing search features while maintaining the benefits of cursor-based pagination (stable results, efficient queries).

# How

The implementation introduces a unified PaginationSpecification that supports three strategies:

  1. Standard pagination - The existing orderBy-based pagination
  2. ILIKE search - Pattern matching with automatic wildcard escaping
  3. Trigram search - PostgreSQL trigram similarity with configurable threshold

  Key implementation details:
  - Search terms are properly parameterized to prevent SQL injection
  - ILIKE special characters (%, _, \) are escaped to prevent pattern injection
  - Results maintain stable cursor-based pagination with proper ordering:
    - ILIKE: Ordered by search fields, then ID
    - Trigram: Ordered by exact match priority, similarity score, optional extra fields, then ID

# Test Plan

  The PR includes comprehensive test coverage:

  - Unit tests for both AuthorizationResultBasedKnexEntityLoader and
  EnforcingKnexEntityLoader
  - Integration tests covering:
    - Forward/backward pagination with search
    - Multi-field search
    - Case-insensitive matching
    - Trigram similarity with various thresholds
    - Cursor stability across pages
    - Edge cases (empty results, special characters)
    - Combined with WHERE clauses
    - Security validation ensuring proper escaping and parameterization

All existing tests pass, confirming backward compatibility for standard pagination when using the new API.
2026-02-16 12:58:24 -08:00
renovate[bot] fb720611f0 chore(deps): lock file maintenance (#447) 2026-02-15 23:33:12 -08:00
renovate[bot] 7b8a49c203 chore(deps): update dependency ioredis to v5.9.3 (#445) 2026-02-15 12:09:59 -08:00
renovate[bot] d97707a213 chore(deps): update dependency @types/node to v24.10.13 (#439) 2026-02-13 13:14:27 -08:00
renovate[bot] ced36081b8 chore(deps): update dependency lerna to v9.0.4 (#440) 2026-02-13 13:13:43 -08:00
renovate[bot] b511981a69 chore(deps): update postgres:18 docker digest to b6b4d0b (#435) 2026-02-12 18:03:05 -08:00
Will Schurman 🦦 58deffd426 feat: Add paginated loader to entity-database-adapter-knex (#422) 2026-02-12 11:49:06 -08:00
Will Schurman 🦦 56ec27d5f2 fix: move EntityPrivacyUtils back into core package (#429) 2026-02-11 19:20:26 -08:00
Will Schurman 🦦 be40bcffa6 fix: Type idField in EntityConfiguration as TIDField (#425)
# Why

Noticed this was mis-typed in a later PR, hoisted into this one.

# How

Update type of EntityConfiguration.idField to be `TIDField`, which is the most correct and precise type for the field. This will help later on when adding pagination ID field stuff.

The other changes to tests are fixes since these previously were underspecified and changing the field type exposed these missing types. (claude informed me of this)

# Test Plan

`yarn tsc`
2026-02-11 17:30:23 -08:00
Will Schurman 🦦 a9d09f42b6 fix: Use TSelectedFields for knex loader order by method (#424)
# Why

Noticed while creating an upcoming pagination PR that loaders should use `TSelectedFields` for `orderBy` clauses. This was an accidental omission when the selected fields concept was added.

Succinctly, one should only be able to order entities by their fields rather than by all their underlying table's fields.

# How

Create new orderBy clause type that lives at the loader level. It is fully compatible (subset or equal to) the database orderBy clause, so calling the data manager methods directly with the arguments is by design.

# Test Plan

`yarn tsc`
2026-02-11 17:26:14 -08:00
renovate[bot] a9980b2785 chore(deps): update dependency @types/node to v24.10.12 (#434) 2026-02-11 07:39:14 -08:00
renovate[bot] 7600c7e7d8 chore(deps): update redis docker digest to 7b6fb55 (#433) 2026-02-10 14:35:24 -08:00
renovate[bot] 31e226e22a chore(deps): update node.js to v24.13.1 (#432) 2026-02-10 09:50:20 -08:00
renovate[bot] 66297676e4 chore(deps): update dependency @types/node to v24.10.11 (#428) 2026-02-09 16:14:17 -08:00
Will Schurman 🦦 273d1df8ba feat!: Add sql template and loader method (#414)
# Why

This is the first new feature afforded by the changes in #407.

It adds a feature that is fairly common amongst typescript ORMs: sql via tagged templates for safe value interpolation into SQL statements.

# How

Quite a lot of ORMs have this:
- https://github.com/drizzle-team/drizzle-orm
- https://github.com/mikro-orm/mikro-orm
- https://github.com/blakeembrey/sql-template-tag
- https://github.com/andywer/squid
- https://github.com/Seb-C/kiss-orm

This adds support for it to entity knex.

See tests for examples, especially `PostgresEntityIntegration-test.ts`.

The idea is that this will replace `loadManyByRawWhereClauseAsync` over the course of a few versions.

# Test Plan

Full test coverage.
2026-02-09 16:00:24 -08:00
Will Schurman 🦦 aba0ce5b5f feat: add codemod for upcoming v0.55.0-v0.56.0 (#413)
# Why

This adds a codemod for #407-#410 changes.

# How

Replace `.loader` calls with `.knexLoader` where applicable.

# Test Plan

Run the test.
2026-02-09 15:56:23 -08:00
Will Schurman 🦦 eeccf594c1 feat: add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter (#412)
# Why

As of moving raw and conjunction loading into the `entity-database-adapter-knex` package, the stub database adapater is no longer as useful in applications that use the package (it used to support conjunction loading synthetically for unit tests).

# How

This PR adds a new package, `entity-database-adapter-knex-testing-utils` that provides what StubDatabaseAdapter used to be (it has conjunction loading).

The idea is that applications that use `entity-database-adapter-knex` will use this package for their stub database needs and unit test companion provider.

# Test Plan

Full test coverage.
2026-02-09 15:52:23 -08:00
Will Schurman 🦦 e76264c613 chore: add CLAUDE.md and disallow test.skip (#411)
# Why

This commits the claude prompts I have been using to pair program with claude while building this stack.

I also noticed that claude loved to add `test.skip` to make things pass, which now is disallowed as of this PR.

# How

Commit the file.

# Test Plan

yarn lint
2026-02-09 15:48:23 -08:00
Will Schurman 🦦 6fce3a7604 feat!: move knex loaders into entity-database-adapter-knex package (#410)
# Why

Part 3 of #407.

# How

This is the main PR of the stack. It creates a mechanism for "installing" the extension methods on required objects, and then installs them and tests them.

The goal is to make it better self-contained about what is core in the library and guaranteed to be efficient (dataloader'd and cached) and what is not (raw SQL and SQLFragment loads in a future PR).

This is becoming more critical in a time of agentic coding where having a discrete set of loaders that can be linted separately and instruct agents to run `EXPLAIN ANALYZE` in raw cases.

# Test Plan

Full test coverage.
2026-02-09 15:44:24 -08:00
Will Schurman 🦦 567bb61172 chore: move knex-specific loader logic out of EntityDataManager (#408)
# Why

Part 2 of #407.

# How

Refactor methods into a separate class for future move out of main package.

# Test Plan

Full test coverage.
2026-02-09 15:40:23 -08:00
Will Schurman 🦦 2590b95734 feat!: Move knex-specific loader methods to knexLoader (#407)
# Why

This is a long stack, but the goal is to make a place to put postgres-specific loading logic to avoid polluting the core dataloader-based library with logic specific to knex/postgres. And potentially even postgres-specific mutation logic but that's way down the road.

The best place for this stuff is in the `entity-database-adapter-knex` library. Therefore, the strategy is (in PR order):
1. Move existing knex/postgres-specific loader methods to a new loader, `knexLoader`. I'm also open to calling this a "Postgres Loader" since the `entity-database-adapter-knex` package already uses postgres-specific queries (and more are added here since this stack eventually produces raw queries) within knex even though technically knex is database agnostic.
1. Move knex-specific logic out of EntityDataManager, move it to its own EntityKnexDataManager.
1. Move these two now-separated pieces into the `entity-database-adapter-knex` package. This requires creating a "plugin" system for database adapters, where when they are included their methods are added to the prototype so that seamless loading continues to work.
1. Add a codemod for these three pieces.
1. Add the first new feature to entity loading, load by tagged template string, which creates a better/safer way to express raw queries for loading entities.

Future other things that could be added here (after thorough thought on authorization) are:
- Pagination
- Create, update, delete by tagged template string
- Batch creation
- Upsert (maybe)

# For Reviewers

The most critical things to assess are:
- "install" method in #410
- sql tagged template API usability in #414

# How

This is part 1. It refactors the loaders by moving `loadManyByFieldEqualityConjunctionAsync`, `loadFirstByFieldEqualityConjunctionAsync`, and `loadManyByRawWhereClauseAsync` to a separate loader.

The new pattern for these is:
```
await PostgresTestEntity.knexLoader(vc1).loadManyByRawWhereClauseAsync(...)
```

# Test Plan

This has full coverage in new tests.
2026-02-09 15:36:48 -08:00
renovate[bot] bc74562bf9 chore(deps): update dependency @types/node to v24.10.11 (#426) 2026-02-08 13:15:10 -08:00
renovate[bot] e37284f54a chore(deps): update dependency @types/node to v24.10.10 (#423) 2026-02-06 12:02:32 -08:00
renovate[bot] 064431924b chore(deps): update postgres:18 docker digest to 1090bc3 (#421) 2026-02-05 09:37:35 -08:00
renovate[bot] cd81c5a5b6 fix(deps): update dependency @apollo/server to v5.4.0 [security] (#420) 2026-02-04 15:41:34 -08:00
renovate[bot] 7b6ff09c68 chore(deps): update redis docker digest to c22af04 (#418) 2026-02-03 14:29:45 -08:00
renovate[bot] ad6c81def2 chore(deps): update babel monorepo to v7.29.0 (#419) 2026-02-03 14:29:23 -08:00
renovate[bot] 2791d9a05f chore(deps): update postgres:18 docker digest to 3bb77a0 (#416) 2026-02-03 09:05:05 -08:00
renovate[bot] c28e89e15c chore(deps): update redis docker digest to e59abdb (#417) 2026-02-03 09:04:53 -08:00
renovate[bot] 9a9374b173 chore(deps): update dependency pg to v8.18.0 (#415) 2026-02-02 15:19:43 -08:00
renovate[bot] 2dda22d761 fix(deps): update dependency @koa/bodyparser to v6.1.0 (#405) 2026-01-28 07:36:17 -08:00
renovate[bot] 9e88cf6aab fix(deps): update dependency @koa/router to v15.3.0 (#406) 2026-01-27 21:36:30 -08:00
Will Schurman de7548680d v0.55.0 2026-01-26 23:18:40 -07:00
Will Schurman 🦦 5db89777c6 feat: add AllowIf[Any|All]SubRulesAllowPrivacyPolicyRule rules (#401)
# Why

One other set of rules we've found useful in the Expo application for expressing complex privacy logic is a boolean any/all rule that composes an array of rules.

This PR upstreams these rules.

# How

Add rules and tests.

# Test Plan

Run tests.
2026-01-26 22:15:24 -08:00
Will Schurman 🦦 f3b575cd00 feat: add EvaluateIfEntityFieldPredicatePrivacyPolicyRule (#400)
# Why

One other rule we've found useful in the Expo application is a simple predicate rule based on an entity field.

This PR upstreams this rule.

# How

Add rule and test.

# Test Plan

Run test.
2026-01-26 22:11:23 -08:00
Will Schurman 🦦 56ed357b92 feat: Add AllowIfInParentCascadeDeletionPrivacyPolicyRule (#399)
# Why

Found this to be useful in the Expo backend application. The use case is that one may wish to permit access for the mostly-programmatic step of nullification of a field in an entity, whether or not any of the other privacy policy rules in the policy allow.

For example, let's say a user creates a build on organization X. Their user ID may be recorded in a foreign key field on the build so that the application can query all builds for that user. But then the user loses access to organization X. Then, they want to delete their user. This will do a cascade set null on the builds initiated by the user, but the user no longer has permission to update those builds via the normal organization membership permission rules, so this explicit allow-if-cascade-rule is used.

# How

Add a generic rule for use in applications.

# Test Plan

Add test.
2026-01-26 22:07:54 -08:00
renovate[bot] b758c4d344 fix(deps): update dependency @apollo/server to v5.3.0 (#403) 2026-01-26 07:32:55 -08:00
renovate[bot] d4c68112dc chore(deps): update dependency prettier to v3.8.1 (#404) 2026-01-26 07:31:21 -08:00
renovate[bot] 23d8b25805 chore(deps): update dependency pg to v8.17.2 (#402) 2026-01-23 14:54:32 -08:00
renovate[bot] 197ebd9120 chore(deps): update dependency lodash to v4.17.23 [security] (#396) 2026-01-22 11:52:51 -08:00
renovate[bot] a8223d994a chore(deps): update actions/checkout action to v6.0.2 (#398) 2026-01-22 11:51:46 -08:00
Will Schurman 🦦 7c2b812ef2 chore: Update release instructions in README (#397) 2026-01-21 15:47:41 -08:00
Will Schurman 283c02eedb v0.54.0 2026-01-21 16:34:20 -07:00
renovate[bot] c70032dc2a chore(deps): update postgres:18 docker digest to 5773fe7 (#385) 2026-01-21 15:23:42 -08:00
renovate[bot] 6c8bdfaf97 chore(deps): update dependency prettier to v3.8.0 (#392) 2026-01-19 10:01:52 -08:00
renovate[bot] acd5d715db chore(deps): update dependency pg to v8.17.1 (#391) 2026-01-19 10:01:35 -08:00
renovate[bot] 6523f07a5b chore(deps): update dependency ioredis to v5.9.2 (#393) 2026-01-19 10:01:05 -08:00
renovate[bot] 38f451fd59 chore(deps): update dependency @types/node to v24.10.9 (#394) 2026-01-19 10:00:54 -08:00
renovate[bot] 43dd87fcba chore(deps): update babel monorepo to v7.28.6 (#389) 2026-01-16 09:34:41 -08:00
renovate[bot] b125ea3473 chore(deps): update dependency @types/node to v24.10.8 (#390) 2026-01-16 09:16:19 -08:00
renovate[bot] 7244f7b591 chore(deps): update redis docker digest to 73dad42 (#386) 2026-01-15 09:01:29 -08:00
renovate[bot] 17b2105a99 chore(deps): update dependency typedoc to v0.28.16 (#387) 2026-01-15 09:00:40 -08:00
renovate[bot] 0659e8f4a2 chore(deps): update actions/setup-node action to v6.2.0 (#388) 2026-01-15 09:00:06 -08:00
Will Schurman 🦦 8fb53f2f49 chore: validate conventional commit PR title in GHA (#384)
# Why

Since this library uses conventional commits to generate changelogs, we want to validate that the PR title is in a valid conventional commit format.

Closes ENG-15958.

# How

Add lint action.

# Test Plan

This only runs once committed to main so we'll need to commit this and then try it out.

I audited the action code at a glance.
2026-01-13 15:52:12 -08:00
renovate[bot] 757393c707 chore(deps): update dependency @types/node to v24.10.7 (#383)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)) | [`24.10.6` → `24.10.7`](https://renovatebot.com/diffs/npm/@types%2fnode/24.10.6/24.10.7) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/24.10.7?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/24.10.6/24.10.7?slim=true) |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/expo/entity).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi43NC41IiwidXBkYXRlZEluVmVyIjoiNDIuNzQuNSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->
2026-01-13 09:46:44 -08:00
renovate[bot] a75b41a515 chore(deps): update redis docker digest to eab64db (#382) 2026-01-13 09:39:50 -08:00
renovate[bot] 23f121c91e chore(deps): update node.js to v24.13.0 (#378) 2026-01-13 09:39:25 -08:00
renovate[bot] b984fa949e chore(deps): update redis docker digest to 350e485 (#380) 2026-01-13 09:32:38 -08:00
renovate[bot] cb2f4fa8ae chore(deps): update postgres:18 docker digest to fdd16e6 (#379) 2026-01-13 09:32:05 -08:00
renovate[bot] 0a70d083eb chore(deps): update dependency @types/lodash to v4.17.23 (#381) 2026-01-13 09:31:48 -08:00
James Ide 0f719432a9 Replace es6-error with native Error subclassing (1.9x faster) (#370)
Why
===
es6-error uses Object.defineProperty calls to set name, message, and stack on every error instantiation. Modern Node.js (14+) supports native Error subclassing, making this overhead unnecessary.

How
===
- Remove es6-error dependency
- Use native Error class with static blocks to set the prototype name once per class rather than per instance
- Use abstract getters for state and code in the base class, with concrete implementations in subclasses returning narrowed literal types (e.g., `get state(): EntityErrorState.PERMANENT`)
- Pass cause to Error constructor options instead of manually setting it

Abstract getters were chosen over prototype assignment because:
- They use standard TypeScript/JavaScript patterns that are self-documenting
- They don't require `declare` statements to satisfy TypeScript
- They allow narrowed return types (e.g., `EntityErrorState.PERMANENT` instead of `EntityErrorState`)

The name property is set on the prototype via static initializer because Error.prototype.name is conventionally a prototype property, and this matches how native error subclasses like TypeError work.

Benchmark shows ~1.9x faster error instantiation:
- es6-error: ~213k ops/sec
- native: ~400k ops/sec

Test Plan
===
yarn tsc passes. All tests pass
2026-01-12 17:30:52 -08:00
renovate[bot] a9a730cd83 chore(deps): update dependency @types/node to v24.10.5 (#377) 2026-01-12 17:24:29 -08:00
Will Schurman 🦦 544bb7c3f2 feat: synthetically set cascading set-null fields to null during canViewerDeleteAsync evaluation (#376) 2026-01-12 15:30:31 -08:00
Will Schurman 🦦 c38717d237 chore: require eqeqeq lint (#375) 2026-01-11 11:31:58 -08:00
James IdeandWill Schurman 🦦 ea6c128d9c fix: Inline map().filter() loops in EntityLoaderUtils (#369)
Why
===
The map().filter() pattern creates an intermediate array with null values
that are immediately discarded. For an entity with 8 fields, this allocates
an 8-element array where ~25% of values are null, only to filter them out.

Benchmarks show inlining as a for loop is 1.8x faster:
- map().filter(): 7.0M ops/sec
- for loop:       12.7M ops/sec

How
===
Replace two instances of `.map(...).filter(x => x !== null)` with for...of
loops that conditionally push to the result array. This is the same pattern
used elsewhere in the codebase (e.g., successfulResults, failedResults in
entityUtils.ts).

Test Plan
=========
All existing tests pass (362/362). Behavior is identical.

Co-authored-by: Will Schurman 🦦 <[email protected]>
2026-01-11 11:31:00 -08:00
Will Schurman 🦦 1cc57eddce fix: run tsc during tests CI job (#373) 2026-01-11 10:47:02 -08:00
James Ide 5dff512745 Optimize pick() with direct property access (#368)
Why
===
The previous implementation used Set + Object.entries + Object.fromEntries
which created intermediate data structures. Benchmarks showed this was 5.6x
slower than direct property access for typical usage (2 props from medium
entity: 1.8M vs 10.1M ops/sec).

How
===
Replace the Set-based filtering approach with a simple for...of loop that
directly accesses and copies properties. This avoids:
- Creating an intermediate Set
- Object.entries() array allocation
- Object.fromEntries() reconstruction

Use hasOwnProperty() instead of `in` operator to match the original semantics
(Object.entries only returns own properties) and avoid edge cases with
inherited properties like "toString".

Test Plan
=========
All existing tests pass. The function is semantically identical - it still
returns a new object with only the specified own properties.
2026-01-11 10:38:54 -08:00
renovate[bot] 60fed15603 chore(deps): update dependency ioredis to v5.9.1 (#374) 2026-01-11 09:50:10 -08:00
renovate[bot] fe7f7303aa chore(deps): update actions/setup-node action to v6 (#356) 2026-01-08 16:12:05 -08:00
Will Schurman 🦦 47b167e3bb chore: upgrade docker compose for tests to postgres 18 (#367) 2026-01-08 16:10:53 -08:00
Will Schurman 🦦 3997adf002 chore: upgrade lerna and run breaking change migration (#366) 2026-01-08 16:00:32 -08:00
Will Schurman 🦦 858f8715e6 chore: upgrade to uuid 13 (#365) 2026-01-08 15:45:51 -08:00
renovate[bot] 67f7516489 chore(deps): update dependency uuidv7 to v1.1.0 (#351) 2026-01-08 09:21:49 -08:00
Will Schurman 🦦 7947fe9871 chore: upgrade prettier and apply (#364) 2026-01-08 09:20:36 -08:00
renovate[bot] 5f9485627a chore(deps): update dependency ioredis to v5.9.0 (#349) 2026-01-08 09:20:16 -08:00
renovate[bot] 37fc067970 chore(deps): update yarn to v4.12.0 (#354) 2026-01-08 09:15:48 -08:00
renovate[bot] a6d2d3c55c chore(deps): update node.js to v24.12.0 (#352) 2026-01-08 09:15:15 -08:00
renovate[bot] ece647b31f chore(deps): update dependency lerna to v8.2.4 (#342) 2026-01-08 09:15:04 -08:00
renovate[bot] be72c6bf4b chore(deps): update dependency ctix to v2.7.2 (#341) 2026-01-08 09:11:26 -08:00
renovate[bot] b7f2148654 chore(deps): update actions/setup-node action to v4.4.0 (#345) 2026-01-08 09:11:16 -08:00
renovate[bot] 73f0dfa223 chore(deps): update peaceiris/actions-gh-pages action to v4 (#362) 2026-01-08 09:10:34 -08:00
renovate[bot] 35bf335a5d chore(deps): update dependency typedoc to v0.28.15 (#343) 2026-01-08 09:09:37 -08:00
renovate[bot] 971e73037d chore(deps): update codecov/codecov-action action to v5.5.2 (#346) 2026-01-08 09:09:21 -08:00
renovate[bot] 328d6ac319 chore(deps): update dependency @types/supertest to v6 (#358) 2026-01-08 09:08:08 -08:00
renovate[bot] ea21dc0f76 chore(deps): update dependency @types/jscodeshift to v17 (#357) 2026-01-08 09:07:14 -08:00
renovate[bot] 94808312eb chore(deps): update actions/checkout action to v6 (#355) 2026-01-08 09:06:00 -08:00
renovate[bot] 8998970643 chore(deps): update dependency eslint to v9.39.2 (#348) 2026-01-08 09:05:40 -08:00
renovate[bot] f9441a77a8 chore(deps): update dependency @jest/globals to v30.2.0 (#347) 2026-01-08 09:05:24 -08:00
renovate[bot] 4146afc887 chore(deps): update dependency @types/supertest to v2.0.16 (#340) 2026-01-08 08:57:18 -08:00
renovate[bot] 5b7739691f chore(deps): update dependency @isaacs/ttlcache to v2.1.4 (#333) 2026-01-08 08:56:49 -08:00
renovate[bot] 002bbd5bbf chore(deps): update dependency @tsconfig/strictest to v2.0.8 (#334) 2026-01-08 08:55:54 -08:00
renovate[bot] 9f939269cf fix(deps): pin dependency @koa/bodyparser to 6.0.0 (#339) 2026-01-08 08:55:40 -08:00
renovate[bot] ee6e57fd3b chore(deps): update dependency @types/lodash to v4.17.21 (#338) 2026-01-08 08:55:05 -08:00
Will Schurman 🦦 4cad01a940 chore: upgrade dependencies in entity-example (#332) 2026-01-08 08:51:01 -08:00
renovate[bot] 7038912ca5 fix(deps): pin dependency @apollo/server to 4.12.2 (#330) 2026-01-07 16:35:12 -08:00
renovate[bot] ce55922e3e fix(deps): pin dependency koa to 2.16.3 (#331) 2026-01-07 16:34:48 -08:00
renovate[bot] d8dccb95e4 chore(deps): update dependency koa to v2.16.2 [security] (#327) 2026-01-07 15:07:55 -08:00
renovate[bot] 5679f27209 fix(deps): pin dependencies (#328) 2026-01-07 15:06:14 -08:00
renovate[bot] bcce8ef0ae chore: Configure Renovate (#325) 2026-01-07 15:01:27 -08:00
Will Schurman 833e2e8c55 v0.53.0 2025-12-19 13:24:53 -08:00
Will Schurman 1a7dfadf64 v0.52.0 2025-12-19 13:20:50 -08:00
Will Schurman 🦦 767d9513ff chore: upgrade to node 24 LTS (#323) 2025-12-09 08:10:30 -08:00
Will Schurman 🦦 c4537262d8 feat: switch local cache to an interface (#322) 2025-12-08 17:40:30 -08:00
Will Schurman 9140938e1c v0.51.0 2025-11-21 11:36:24 -08:00
Will Schurman 🦦 019809fbe9 feat!: separate out validators into beforeCreateAndUpdate and add new beforeDelete validators (#320)
# Why

For a while in Expo application code, we've used beforeDelete triggers to approximate beforeDelete validators, but we finally found a need to separate them out. Due to the nature of validators (no side-effects) vs triggers (side-effectful), there can be a need to manually run validators but not triggers. One such need is ahead of batch deletion, to ensure that an object and it's related objects can be deleted and retain any constraints implemented in validator logic.

Closes ENG-18066.

# How

Split out definition to be more similar to triggers, update tests.

# Test Plan

Add new test for these, ensure that they are called appropriately.
2025-11-12 09:08:23 -08:00
Will Schurman 🦦 4559f8fe1b chore: add docblock for orderBy and ordering (#319)
# Why

These were missing docblocks, and are something I often have to look up.

Closes ENG-18067.

# How

Add docblocks.

# Test Plan

tsdoc
2025-11-12 09:03:23 -08:00
Will Schurman 🦦 30f4a62bfa chore: update other sibling dependencies (#318)
# Why

Part 2 of #317, just updates more dependencies. These I check the changelogs for though to be sure it isn't going to cause any issues.

# How

Read changelogs, update.

# Test Plan

CI

Changelogs:
https://github.com/redis/ioredis/blob/main/CHANGELOG.md
https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md
https://github.com/beaugunderson/ip-address/compare/v10.0.0...v10.0.1
2025-11-12 08:58:24 -08:00
Will Schurman 🦦 a1f1934fd7 chore: update dev dependencies (#317)
# Why

Ahead of some library updates, this updates the dev dependencies to their latest versions. Just a chore.

# How

Update.

# Test Plan

CI
2025-11-12 08:54:18 -08:00
Will Schurman 🦦 2f9d4873aa fix: add default for TSelectedFields in PrivacyPolicyTestUtils Case (#316) 2025-11-04 16:37:41 -08:00
Will Schurman 59e024eb19 v0.50.0 2025-10-07 16:12:16 -07:00
Will Schurman 🦦 0f53c24faf fix: make in memory and stub database adapters behave more similarly to sql (#315)
# Why

#311 updated the errors for postgres, but the conditions in the stub adapters preempted those errors in test code, so their behavior diverged. This PR makes the stub adapters behave more similarly to Postgres adapter.

# How

Return more similar results in not-found cases. See inline comments.

# Test Plan

`yarn tsc`. Also the tests in entity-example test this.
2025-10-07 16:08:43 -07:00
Will Schurman 🦦 1b9ddaa95f chore: improve test coverage (#314) 2025-10-07 13:32:23 -07:00
Will Schurman 4ab2cf1cc4 v0.49.0 2025-10-07 09:26:02 -07:00
Will Schurman 🦦 4c3fd175a9 feat: add more documentation to entity errors (#312) 2025-10-06 16:18:35 -07:00
Will Schurman 🦦 f6639f9def feat: throw specific errors for database adapter issues (#311) 2025-10-06 14:32:16 -07:00
Will Schurman 🦦 949f00d93b fix: validate that UUID field is lower-case (#310)
# Why

Technically upper-case UUIDs are valid according to https://datatracker.ietf.org/doc/html/rfc4122.

But the way entity is written expects case sensitivity due to them being treated as simple strings. For example, this would be the expected behavior:
```
const entities = await TestEntity.loader(vc).loadManyByFieldEqualingAsync('id', ['018EBFDA-DC80-782D-A891-22A0AA057D52']);
=> Map(['018EBFDA-DC80-782D-A891-22A0AA057D52', TestEntity[...]])
```

The issue is that Postgres stores them in a case-insensitive manner (which is correct according to the UUID spec). So in actuality, the above code would theoretically do something like:
```
const entities = await TestEntity.loader(vc).loadManyByFieldEqualingAsync('id', ['018EBFDA-DC80-782D-A891-22A0AA057D52']);
=> Map(['018ebfda-dc80-782d-a891-22a0aa057d52', TestEntity[...]])
```
(it actually fails due to this change of case further up in the stack at https://github.com/expo/entity/blob/077d53cd86d4c54132ea2ebcc59d1d75e2b507d0/packages/entity/src/EntityDatabaseAdapter.ts#L182, but the reason is the same: postgres transforms to lower case under-the-hood)

This results in breaking invariants the framework provides (all keys in === all keys out).

This PR solves this problem by just enforcing UUID casing in fields to match what Postgres already does to avoid this issue.

Ref: WWW-37

# How

I first tried a field normalization approach in #309, but this also had the same problem: the fields in !== fields out. Unless we were to invert the normalization on the way out, but that comes with a lot of added complexity.

So this is the solution I propose: just don't allow any upper-case characters in the UUIDs. This is technically not UUID spec compliant since we no longer accept all technically valid UUIDs.

# Test Plan

Run the test.
2025-09-30 12:52:06 -07:00
Will Schurman 🦦 02f92fc6ac chore: fix erroneous describe.only and add lint rule to prevent it (#308)
# Why

In other repositories at Expo we have this lint disallow rule to avoid accidental skips like this. This PR adds the same rule.

# How

1. Add rule.
2. Fix violation.

# Test Plan

`yarn lint`
2025-09-30 12:48:53 -07:00
Will Schurman 🦦 077d53cd86 fix: add readonly modifier to partitionArray utility method (#307) 2025-09-22 10:59:26 -07:00
Will Schurman 7b4bec8f84 v0.48.0 2025-09-19 15:59:11 -07:00
Will Schurman 4c6f340d17 v0.47.0 2025-09-19 12:03:43 -07:00
Will Schurman 🦦 4a3eda209f feat: allow manual specification of cascadingDeleteCause in delete mutator factory method (#306) 2025-09-19 11:36:24 -07:00
Will Schurman 🦦 31b52a5f75 feat: add support for Buffer fields (#305) 2025-07-29 13:26:19 -07:00
Nick Novitski 9a03fdb7ba chore: Clear dependency warnings on entity-example 2025-06-26 17:13:00 -07:00
Nick Novitski 28cbac54b4 chore: Remove unneeded files from packages 2025-06-26 17:12:21 -07:00
Will Schurman 79f61f8d7f v0.46.0 2025-06-26 13:12:05 -07:00
Nick Novitski 0038d633b3 Fix exports for ESM projects
Before this commit, for projects with `"type": "module"`,  anything
exported from a file `export default` was not resolvable from the barrel
file as a named export.

After this change, the interface of the index is unchanged, but ESM
projects can use it.

To test this, the entity-example package is given `"type": "module"`,
and changed to run with `node --experimental-transform-types`.  Its
imports from other packages do not need to change.  The other
integration tests passing are evidence that commonjs modules also do not
need to change their imports.

This commit also adds an  eslint rule to enforce this standard.
2025-06-24 10:03:22 -07:00
Nick Novitski 18a0d297d0 Unify repo-wide scripts
These are faster, and can pass arbitrary arguments to the underlying
command, which allows repo-wide "watch" workflows.
2025-06-24 09:58:54 -07:00
Nick Novitski 93d84807da chore: Use 'v8' coverage provider 2025-06-23 16:04:54 -07:00
Nick Novitski 64057b6d52 chore: Explicitly list files to collect coverage from
By default, files not loaded from any test will not effect coverage
metrics, although actually we want them to be counted as uncovered.
2025-06-23 15:45:59 -07:00
Nick Novitski 850fa2b886 chore: Use tsconfig bases (#296)
# Why

These are sensible defaults, so much so that they almost perfectly match what we already set.

# Test plan

`yarn build`
2025-06-20 12:24:42 -07:00
Nick Novitski 65d50d6cb3 chore: jest v29.7.0 -> 30.0.0 (#295)
# Why

It's faster, at least locally where we run without coverage instrumentation.

# Test Plan

CI.
2025-06-20 12:19:23 -07:00
Nick Novitski 1fd0144cc3 chore: Replace ts-jest with babel-jest (#294)
# Why

It's faster.  We can still type-check in a separate step in CI, or locally.

# Test Plan

All test scripts are still runnable, including in CI.
2025-06-20 12:14:21 -07:00
Nick Novitski 6cecd9f6b9 chore: Remove @jest/types (#293)
# Why

Jest is written in typescript and ships with its own types.

# Test Plan

Typecheck and tests running in CI.
2025-06-20 12:07:30 -07:00
Will Schurman 875cce3432 chore: update README to be more precise (#292) 2025-06-16 21:59:15 -07:00
Will Schurman fe2d246f87 v0.45.0 2025-06-06 09:06:35 -07:00
Will Schurman a3ab61bfa8 feat: add ability to define a custom cache key invalidation version supplier (#290) 2025-06-06 09:01:47 -07:00
Will Schurman 78bc264b33 feat: add createOrGetExistingAsync and createWithUniqueConstraintRecoveryAsync utility methods (#291) 2025-06-06 08:59:59 -07:00
Will Schurman b9b09933e5 v0.44.0 2025-05-29 09:45:25 -07:00
Will Schurman d3e89bdd82 fix: introduce transactional dataloader mode and fix global transaction ID generation (#289) 2025-05-28 17:09:29 -07:00
Will Schurman b6089524bb chore: add more @internal to typedoc (#287)
# Why

Noticed in this stack that a few more places could use the `@internal` typedoc annotation to prevent it from polluting the public docs.

# How

Add annotation to internal constructs.

# Test Plan

Inspect docs manually (`yarn typedoc`, open html file)
2025-05-23 11:07:21 -07:00
Will Schurman e054a0d74b feat: add transaction-scoped dataloaders (#284)
# Why

This is a retry of #98. It has become more important in Expo's server application due to more things running within transactions. When it was written previously, relatively few things were transactional.

The idea is that within a transaction, subsequent reads should be able to be cached locally (dataloader) as long as the local cache is kept consistent (not in the cache adapter though).

# How

The way this is done is to keep a dataloader map (dataloader per-entity like we do for the top-level loads) per-transaction as well. Then, when in a transaction, instead of going directly to the database, go through the transaction's dataloader before hitting the database. This way loads within the transaction are coalesced and cached locally.

Invalidation becomes more complex though. 
- We still only want to invalidate the non-transactional dataloader/cache at the end of the transaction (as late as possible for least chance of race condition) for all changed entities. This didn't change.
- As for when to invalidate the transactional dataloaders, we need to think about this like database sub-transaction visibility, but only in the sense that we don't want inconsistent reads. Put another way, any load in or out of any transaction or nested transaction needs to be consistent with what is in the database, so any time the loaded value would change if it went directly do the database we must also invalidate the cache.
   - This is further complicated by the fact that postgres doesn't have real subtransactions (see #194 for more discussion) and subtransactions weren't an entity feature for the first attempt at this. This means that it's hard to test the case where a nested transaction is isolated from the parent transaction for uncommitted reads or the inverse.
   - The way I came up with achieving this is to just to over-invalidate: clear all the dataloaders in the transaction recursively starting from the outermost transaction. And do this clearing right after the mutation and at the end of the sub-transaction and at the end of the transaction (recursively).

# Test Plan

Run new tests.
2025-05-23 11:02:22 -07:00
Will Schurman 4ddea7da77 chore: upgrade dataloader package (#286)
# Why

Noticed that this (major) dependency of entity has been updated. The [changelog](https://github.com/graphql/dataloader/blob/main/CHANGELOG.md) doesn't indicate anything breaking.

# How

Upgrade.

# Test Plan

Rely upon full test coverage for compatibility.
2025-05-23 10:55:22 -07:00
Will Schurman 8c52ace8d1 chore: upgrade dev dependencies and eslint (#285)
# Why

This upgrades some dev dependencies and eslint to v9.

# How

Upgrade packages, [update eslint config to be flat config.](https://eslint.org/docs/latest/use/configure/migration-guide)

# Test Plan

CI (lint).
2025-05-23 10:48:27 -07:00
Will Schurman 618383027d v0.43.0 2025-04-10 14:22:20 -07:00
Will Schurman 9758fd5df8 fix: fix generated tsc definition sentinel type visibility (#283) 2025-04-10 14:12:55 -07:00
Will Schurman 8414d96d94 v0.42.0 2025-04-10 12:39:21 -07:00
Will Schurman ed8528d6a6 chore: upgrade dependencies (#282) 2025-04-10 11:24:50 -07:00
Will Schurman 0ea917ad02 chore: separate out build tsconfig (#281) 2025-04-09 09:16:26 -07:00
Will Schurman 485894af16 feat: add @expo/entity-testing-utils package (#280) 2025-04-09 09:11:36 -07:00
Will Schurman 1da5cc01e1 feat: enforce explicit id field cache property (#276) 2025-04-02 10:53:25 -07:00
Will Schurman b7e524c418 feat: change entity id field generic to field name and derive type where necessary (#278) 2025-04-02 10:48:47 -07:00
Will Schurman d9c1852706 feat: invalidate n+/-1 cacheKeyVersion for entities for push safety (#275) 2025-04-02 10:31:15 -07:00
Will Schurman ca1b69e2cb chore: upgrade typescript to 5.8 and a few other packages (#274) 2025-03-30 11:32:50 -07:00
Will Schurman f0aa0dafeb feat: add composite field loading and caching (#272) 2025-03-30 11:16:38 -07:00
Will Schurman 06b3cb7f80 feat: convert batched/cached loader interface to holder pattern (#271)
# Why

Step 2 of #201.

This PR abstracts out (genericizes) the load key and load value interfaces for batched/cached loading in preparation for composite field loading.

# How

Accomplishing this requires a number of things:
1. A set of interfaces for the keys and load values to be used to load entity field objects.
2. A concrete implementation of that interface for the current use case (single fieldName, fieldValue(s)).
3. Data structure changes in the adapters and data manager to handle the new key type not being comparable via `===`. (see base PR for new map data structure for using these objects as keys).
4. Updates to the test matchers to know how to compare the serializable holder objects.

# Test Plan

Update a number of tests and add a few new tests. This has full coverage.
2025-03-28 11:36:10 -07:00
Will Schurman ad41af1c82 feat: add some utility classes and methods in preparation for composite keys (#269)
# Why

This adds the utility classes needed for composite field loading as described in #201.

# How

This adds a few concepts:
- `areSetsEqual` - simple utility for checking set equality
- `SerializableKeyMap` - a map where keys must be of a custom interface for checking equality.

# Test Plan

Add new tests, run them. Full coverage.
2025-03-28 11:32:12 -07:00
Will Schurman f3a2bf62de chore: update generic constraint for TFields everywhere (#268) 2025-03-22 09:57:08 -07:00
Will Schurman 1d95eba1f9 fix: fix metrics for dataloader waterfall logging (#267) 2025-03-22 09:41:39 -07:00
Will Schurman aa56b705db fix: move source of truth of loader docblocks to enforcing loader since it is not the default loader (#264) 2025-03-22 08:58:24 -07:00
Will Schurman ee4be736a9 v0.41.0 2025-03-07 11:43:14 -08:00
Will Schurman 4a2b61f2bd feat: Create codemod for v0.40.0 -> v0.41.0 (#260) 2025-03-07 11:39:22 -08:00
Will Schurman a976e76509 feat!: Default all loaders and mutators to enforcing (#256)
BREAKING CHANGE: Default all loaders and mutators to enforcing
2025-03-07 11:34:13 -08:00
Will Schurman 72f34f0bee chore: Fix changelogs for 0.40.0, update readme 2025-02-26 22:59:51 -08:00
Will Schurman 74c396255c v0.40.0 2025-02-26 22:50:49 -08:00
Will Schurman 5cb88ca6e7 chore: remove prepublish script 2025-02-26 22:43:23 -08:00
Will Schurman 4d070248d8 chore: fix typedoc 2025-02-26 22:29:01 -08:00
Will Schurman acd52f18ce chore: fix build setup (#263) 2025-02-26 22:23:22 -08:00
Will Schurman d4eb0a6214 chore: attempt to fix lint again 2025-02-26 21:44:08 -08:00
Will Schurman 3cd004ad55 chore: attempt to fix lint in CI 2025-02-26 21:41:34 -08:00
Will Schurman 381651d8bb chore: fix lint 2025-02-26 21:29:34 -08:00
Will Schurman 089cfbb040 chore: fix yarn v4 dev dependencies 2025-02-26 21:24:34 -08:00
Will Schurman 02f7428257 chore: upgrade to yarn 4.6.0 2025-02-26 09:55:39 -08:00
Will Schurman 26ad65d657 chore: upgrade lerna 2025-02-26 09:11:40 -08:00
Will Schurman fa78ce84e2 Revert "v0.40.0"
This reverts commit c9ab838a75.
2025-02-25 09:59:55 -08:00
Will Schurman c9ab838a75 v0.40.0 2025-02-25 09:55:05 -08:00
Will Schurman 3c2a1920f0 chore: fix typedoc again (#262) 2025-02-25 09:38:34 -08:00
Will Schurman ab34b4ae8a chore: fix typedoc (#261) 2025-02-25 07:57:08 -08:00
Will Schurman d63ddd6599 feat: Create codemod for v0.39.0 -> v0.40.0 (#259) 2025-02-24 12:09:24 -08:00
Will Schurman c6f74a14a9 feat!: Add enforcing entity association loader (#257) 2025-02-23 19:35:25 -08:00
Will Schurman be9c40e8d7 chore: add CI check for needing to run ctix (#258) 2025-02-23 19:32:25 -08:00
Will Schurman 081c3576a8 chore: switch barreling to ctix (#255) 2025-02-23 19:29:25 -08:00
Will Schurman 92bac05687 chore: internal refactor of loader to be structured like new mutation methods (#254) 2025-02-23 19:26:22 -08:00
Will Schurman 4d56065518 feat!: Make load/create/update/delete entity authorization result interfaces consistent (#253) 2025-02-23 19:23:25 -08:00
Will Schurman 275c63d277 chore: upgrade typescript and related packages (#252) 2025-02-23 19:20:47 -08:00
Will Schurman 2504aecddb chore: update pg package to latest version (#251) 2024-12-04 10:01:29 -08:00
Will Schurman 9426a08a32 v0.39.0 2024-11-21 10:46:32 -08:00
Will Schurman ae3a6c5c77 chore: upgrade typescript and lint (#250) 2024-11-21 10:45:15 -08:00
Will Schurman 27a358d259 feat: add function to get authorization results for canViewerUpdateAsync/canViewerDeleteAsync (#249) 2024-11-20 14:32:00 -08:00
Will Schurman c2ae21b5cc chore: upgrade typescript to 5.5.4 (#247) 2024-08-21 13:45:21 -07:00
Will Schurman 5ab1f7953c chore: upgrade typescript and related packages (#246) 2024-07-01 09:52:04 -07:00
Will Schurman d7cc0c23b9 v0.38.0 2024-06-27 12:45:48 -07:00
Will Schurman 162b19257a feat: add EntityEdgeDeletionAuthorizationInferenceBehavior for canViewerDeleteAsync (#243) 2024-06-27 12:33:41 -07:00
Will Schurman 15117fc85e chore: add linter for async method Async suffix (#242) 2024-06-14 14:55:10 -07:00
Will Schurman 59fc77810a v0.37.0 2024-06-12 09:56:29 -07:00
Will Schurman 8c5f81bcca fix: update barrels (#241) 2024-06-12 09:54:39 -07:00
Will Schurman 28e3afb62b chore: update CHANGELOG.md (#240) 2024-06-11 21:35:08 -07:00
Will Schurman f17a33c428 v0.36.0 2024-06-11 21:29:05 -07:00
Will Schurman 93905be81c chore: move entity loader utils into their own object (#239) 2024-06-11 21:04:20 -07:00
Will Schurman 2edc7af7e2 BREAKING CHANGE: make using non-enforcing entity loader explicit (#238) 2024-06-11 20:50:49 -07:00
Josh McFarlin 6569486ecd feat: Add global mutation trigger field to EntityCompanionProvider (#215)
* Add global mutation trigger field to EntityCompanionProvider

* Add globalMutationTrigger to additional test cases

* merge mutation triggers outside of EntityMutator

* Exclude triggers with empty arrays
2024-06-11 12:24:16 -07:00
Will Schurman 778d9e304e chore: upgrade dev dependencies and knex (#237) 2024-06-04 16:01:56 -07:00
Will Schurman 05726d45fa fix: disallow keys of JS Object prototype for safety (#236) 2024-06-04 15:41:13 -07:00
Will Schurman 7e2cea1697 fix: constrain entity fields type to string-keyed object (#235) 2024-06-04 15:35:13 -07:00
Will Schurman 7c3c9854a4 fix: always reload entity after update since cascading changes may have changed it since commit (#233) 2024-06-04 11:19:22 -07:00
Josh McFarlin 1e8ea64017 feat: Use uuid v7 in stub database adapter (#234) 2024-06-03 14:14:59 -07:00
Will Schurman af495a9a85 feat: add previousValue to privacy policy context for updates (#232) 2024-05-31 13:14:32 -07:00
Will Schurman 60fc9a4c4b feat: make canViewerDeleteAsync recursive (#224) 2024-05-31 12:41:36 -07:00
Will Schurman 14bd959e53 chore: Update README.md 2024-05-30 10:17:03 -07:00
Will Schurman 91d46ba49f chore: add test coverage for remaining code (#231) 2024-05-30 10:11:35 -07:00
Will Schurman 16e7c21b1c chore: add more tests for stub database adapter and test utils (#230) 2024-05-30 10:11:02 -07:00
Will Schurman 3ce0ac91f8 chore: add more tests for entity loader (#229) 2024-05-30 10:10:29 -07:00
Will Schurman 428cc6fc5c chore: add more tests for privacy policy rules and nested transaction query contexts (#228) 2024-05-30 10:09:57 -07:00
Will Schurman 8490ad5a19 chore: add more test coverage to postgres transaction isolation levels (#227) 2024-05-30 10:09:16 -07:00
Will Schurman 8a448b5c42 chore: Update README.md 2024-05-28 12:19:23 -07:00
Will Schurman 3fd8a92cc8 chore: Update README.md 2024-05-28 12:18:37 -07:00
Will Schurman b5998063a6 chore: set codecov default branch (#226) 2024-05-28 12:12:15 -07:00
Will Schurman 8fc0849618 chore: ignore entity-example package from codecov (#225) 2024-05-28 12:01:19 -07:00
Will Schurman 87532525d0 feat: Add StrictEnumField with better validation (#222) 2024-05-02 11:30:32 -07:00
Will Schurman 33771295c5 v0.35.0 2024-04-11 09:55:15 -07:00
Will Schurman 1bdfbd6b56 fix: Revert: require explicit query context specification (#219)
This reverts commit 8b0b31fdde.
2024-04-11 09:51:40 -07:00
Will Schurman 0fa6f554a2 v0.34.0 2024-04-11 09:13:48 -07:00
Will Schurman 8b0b31fdde feat: require explicit query context specification (#219) 2024-04-11 09:09:18 -07:00
James Ide cc6f3dc1e6 feat: Allow UUIDv6/7/8 in UUIDField validation (#221)
Why
---
Currently the UUIDField type supports only UUIDv1-5 and to use a newer UUID version we need to use StringField.

How
---
Manually implemented UUID validation, adding support for versions 6, 7, and 8. Also added support for the "max" UUID, which is now part of the spec.

Test Plan
---
Added a UUIDv7 value (from https://uuid7.com/) to test cases.
2024-04-08 18:17:24 -07:00
Will Schurman 9c43a56a39 BREAKING CHANGE: hoist the responsibility of sharing local memory cache adapter into application (#218) 2023-12-06 12:38:30 -08:00
Will Schurman ef7b452c9f docs: fix EntityMutationTrigger docblocks (#216) 2023-12-04 16:18:29 -08:00
Will Schurman 659e08386f v0.33.0 2023-10-06 16:00:57 -07:00
Will Schurman aad94aca47 feat: add loadManyByIDsNullableAsync loader method (#214) 2023-10-04 15:28:28 -07:00
Will Schurman d2a4405fa1 v0.32.0 2023-06-09 20:13:23 -05:00
Will Schurman 503a2fb6a8 fix: remove unused field from redis configuration (#213) 2023-06-09 17:08:57 -07:00
Will Schurman 3a00a43036 chore: fix lint (#212) 2023-06-09 11:38:07 -07:00
Will Schurman 05b14843a6 chore: upgrade dependencies (#211) 2023-06-09 11:33:58 -07:00
Will Schurman 6f8020fbf8 chore: refactor common cache logic into GenericEntityCacheAdapter (#209) 2023-06-09 11:32:34 -07:00
Will Schurman 4b18010d42 feat: entity companion definition static thunk (#210) 2023-06-09 11:20:24 -07:00
Will Schurman 2069a0d9fd feat: add ability to specify knex transaction config (#207) 2023-04-20 18:10:07 -07:00
James Ide 83fe9cdea5 v0.31.1 2023-03-06 22:08:09 -08:00
James Ide 1934216b70 [rfc] loadFirstByFieldEqualityConjunction, a convenience method (#206) 2023-01-24 16:41:06 -08:00
Will Schurman 92f5529eba v0.31.0 2023-01-18 21:51:04 -08:00
Will Schurman 67732d6847 chore: upgrade ioredis to 5.2.5 (#205) 2023-01-18 21:49:52 -08:00
Will Schurman bb0cd51eb5 chore: upgrade knex to 2.4.1 for CVE-2016-20018 (#204) 2023-01-18 14:02:47 -08:00
Will Schurman abd86b91d3 v0.30.0 2022-12-23 15:42:23 -07:00
Will Schurman b5f734b1e1 [www] Upgrade knex to 2.3.0 (#203) 2022-12-23 12:13:46 -08:00
Will Schurman b11bd24351 v0.29.0 2022-10-06 11:48:07 -07:00
Will Schurman 684f91b1f2 feat: add example of redis mget batching using @expo/batcher (#199) 2022-10-06 11:42:22 -07:00
Will Schurman e381f5b2ac feat: change redis cache adapter to use redis-like interface (#200) 2022-10-06 11:34:28 -07:00
James Ide 69692c3b64 docs: fix typo descripting afterAll trigger (#198)
The docblock for the afterAll trigger used to say that the triggers were run before create/update/delete operations. s/before/after/
2022-10-05 14:53:12 -07:00
Will Schurman dbf1e03039 v0.28.0 2022-09-13 11:23:26 -07:00
Will Schurman 0fb74137f2 chore: upgrade integration test postgres to v14 (#195) 2022-09-13 11:18:56 -07:00
Will Schurman a77b914ad6 feat: nested transactions (#194) 2022-09-13 10:47:57 -07:00
Will Schurman acbad826a3 feat: add BigIntField (#193) 2022-08-31 18:48:48 -07:00
Will Schurman 8bdd9f7ac2 docs: improve documentation to FieldEqualityCondition (#192) 2022-08-24 13:44:48 -07:00
Will Schurman bc6a0d4310 chore: update readme for easier release (#191) 2022-08-09 13:13:13 -07:00
Will Schurman a067a44230 v0.27.0 (#190) 2022-08-09 13:07:40 -07:00
Will Schurman bffa7ca870 ci: fix typedoc (#189) 2022-08-09 12:35:38 -07:00
Will Schurman 847c20fabe ci: fix node version in docs action (#188) 2022-08-09 12:09:39 -07:00
Will Schurman 8a98f35b91 chore: upgrade lerna (#187) 2022-08-09 11:41:15 -07:00
Will Schurman 7829d90f4b ci: fix github actions docs workflow branch (#186) 2022-08-09 11:06:36 -07:00
Juwan Wheatley 2817d78392 feat: add orderByRaw to loadManyByRawWhereClauseAsync (#185)
* feat: add orderByRaw to querySelectionModifiers

* implement PR feedback

* fix tsc

* restrict orderByRaw to loadManyByRawWhereClauseAsync

* create new type for orderbyraw param
2022-08-09 13:44:07 -04:00
Quinlan Jung af81981772 docs: update readme deployment (#184) 2022-07-29 23:05:21 +02:00
Quinlan Jung 0c6aa30cdd v0.26.1 (#183)
* v0.26.1

* manually add changelog
2022-07-29 22:47:58 +02:00
Quinlan JungandWill Schurman 8530fb0983 [fix/feat] recache values in higher layers for composed cache adapter (#182)
* [fix/feat] recache values in lower layers for composed cache adapter

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

Co-authored-by: Will Schurman <[email protected]>

* pr feedback

* Update packages/entity/src/ComposedEntityCacheAdapter.ts

Co-authored-by: Will Schurman <[email protected]>

* pr feedback

Co-authored-by: Will Schurman <[email protected]>
2022-07-28 03:46:00 +02:00
Will Schurman ce0ac6f8cb docs: add docblock for EntityAssociationLoader and fix other docs (#181) 2022-07-25 11:45:16 -07:00
Will Schurman c8f81a76c3 v0.26.0 (#180) 2022-07-14 14:46:42 -07:00
Will Schurman a0c35ddd7b feat: add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY (#178) 2022-07-14 14:15:41 -07:00
Will Schurman 25d14bf8c1 fix: fix workflow branch specification (#179) 2022-07-14 12:10:56 -07:00
James Ide 0dbf5e98bb [tests] Run tests on Node 18 instead of Node 12 (#174)
Node 16 is LTS now and 12 is unsupported. 18 is also now released and we will one day run on it so this makes sure one of our major dependencies works on Node 18.

All tests pass, no changes needed.
2022-06-28 17:24:42 -07:00
James Ide 4347d9d577 [npm][ts] Upgrade all minor npm deps incl. TS 4.7 (#176)
Why
---
There were some automated security notices about outdated npm package versions, many of which often can be addressed with minor dependency upgrades.

How
---
Deleted and regenerated yarn.lock, which affects this repo but not consumers of these packages. However, TypeScript 4.7 has a change where it considers stringifying a Symbol to be a potential error. This commit explicitly stringifies keys (entity field names).

I opted for `String(fieldName)` over constraining the field name with `N extends string & keyof TFields` because adding `string &` is "viral" in the sense that lots of files including users of Entity would need to add `string &`. Also, using Symbol field names probably would work fine, they just need to be stringified before printing them.

There were also some Koa router type changes that treat params as `string | undefined` so we need to use `!` in the Entity example router.

Test Plan
---
tsc + unit tests
2022-06-21 00:18:15 -07:00
Will Schurman 9a91b19466 chore: clean up metrics API a bit and add docblocks (#175) 2022-06-08 17:32:07 -07:00
Will Schurman cb7aaad979 docs: update docblock for EntityQueryContext classes (#173) 2022-05-11 11:22:49 -07:00
James Ide 9fcfbf746a feat: update ioredis to v5, make it a devDependency of the cache packages (#172)
Why: ioredis v5 ships with its own TypeScript declarations. The breaking changes from v4 to v5 affect the code that instantiates ioredis, not the code that uses it. As such, I believe that entity is still technically compatible with ioredis v4, but for the sake of keeping our test matrix smaller, this commit uses just ioredis v5 in tests and makes v5 a peer dependency of `entity-cache-adapter-redis`.

Additionally ioredis was a dependency of `entity-cache-adapter-redis`. I changed it to a devDependency and changed the import to `import type` since the package is used only for type checking.

ioredis v5.0.0 changelog: https://github.com/luin/ioredis/releases/tag/v5.0.0. Notable it drops support for Node 10 and for custom Promise libraries, neither of which we use.

Test Plan: building all packages and testing them with Lerna succeeds
2022-04-11 09:44:57 -07:00
James Ide 9c44e8e8d4 Upgrade TS to 4.6.x, use knex 1.0.5 during testing (#171)
Why: Expo's API server will use TS 4.6 and knex 1.0.5. This change makes it so that we run entity's CI tests against these new dependencies.

How: Upgraded the version of typescript in the root package.json. Modified yarn.lock to use knex 1.0.5 instead of updating the db adapter's package.json since knex 1.0.2 still works fine.

Test Plan: Lerna runs tsc in every package (no errors). Unit tests in CI.
2022-04-11 09:16:19 -07:00
Will Schurman d731b0ddb7 v0.25.3 2022-03-12 14:32:00 -07:00
Will Schurman 293868b1a7 fix: lock lru-cache to v6.0.0 (#170) 2022-03-12 13:29:16 -08:00
Will Schurman 02d2f24bb6 v0.25.2 (#169) 2022-03-11 17:09:48 -08:00
Will Schurman e76ca8c2f7 fix: lru-cache yarn workspace version resolution (#168) 2022-03-11 17:07:02 -08:00
Will Schurman 0736e20987 v0.25.1 (#167) 2022-03-10 15:26:07 -08:00
Will Schurman 5c82edcd9f fix: add composed cache to index (#166) 2022-03-10 15:24:09 -08:00
Will Schurman 96e1023d40 v0.25.0 (#165) 2022-03-10 15:05:15 -08:00
Will Schurman 3a81a4c059 feat: privacy policy evaluation context (#162) 2022-03-10 14:44:12 -08:00
Will Schurman 6b6e70e75f feat: composed cache adapter, composed secondary cache, local memory secondary cache (#164) 2022-03-10 14:42:01 -08:00
Quinlan Jung bc378b6c79 chore: better variable and method naming (#160) 2022-02-16 18:37:51 -08:00
Quinlan Jung fefa4100fc v0.24.0 (#159)
* v0.24.0

* changelog
2022-02-16 18:37:39 -08:00
Quinlan Jung 85d35752fa feat: add local memory cache package (#158)
* feat: add local memory cache package

* some pr feedback

* more tests
2022-02-15 18:44:20 -08:00
Quinlan Jung 09d48a617c chore: refactor cachers to use an abstract class and interface (#157)
* chore: refactor cachers to use an abstract class and interface

* fix lint

* changelog
2022-02-14 18:42:27 -08:00
Will Schurman 0adcf7238f [entity] Add docblocks for EntityFields and EntityFieldDefinition (#156) 2022-02-11 14:20:45 -08:00
Quinlan Jung 41bd0722c9 v0.23.0 (#155)
* v0.23.0

* update changelog
2022-02-09 13:59:40 -08:00
Quinlan Jung 03a1fce067 move responsibility of transformer to generic redis cacher (#153)
* will's commit

* integ tests

* fix codecov
2022-02-07 15:04:02 -08:00
James Ide a2c6dd7488 v0.22.0 (#151) 2022-02-03 18:52:25 -08:00
James Ide e1b0ee5281 feat: Upgrade knex to 1.x in @expo/entity-database-adapter-knex (#150)
Upgrades knex to 1.x internally. This changes the behavior of `returning` but not in a way that affects how the knex adapter uses it: `returning('*')` behaves the same as before.

This does, however, change the TypeScript declarations for knex, which changed from:
```
 Knex<TRecord extends {} = any, TResult = unknown[]>
```
to:
```
 Knex<TRecord extends {} = any, TResult = Record<string, any>[]>
```
2022-02-03 18:35:55 -08:00
Will Schurman 5d39fa928f v0.21.0 (#148) 2022-01-03 11:04:02 -08:00
Will Schurman f1d9847210 feat: add pre-commit callbacks on EntityQueryContext (#147) 2022-01-03 09:37:19 -08:00
Will Schurman 99657196f8 v0.20.0 (#146) 2021-12-29 10:50:58 -08:00
Will Schurman 3727191767 feat: add cascading deletion info (#145) 2021-12-29 10:39:22 -08:00
Will Schurman efa131ef29 fix: separate post-commit invalidation callbacks from trigger and user callbacks (#144) 2021-12-23 12:41:29 -08:00
Will Schurman c4df889d50 v0.19.0 (#143) 2021-10-28 14:35:19 -07:00
Will Schurman 7cf54d83c5 feat: add authorization metrics (#142) 2021-10-24 17:26:40 -07:00
kgc00 1405a9126e feat: add IP support (#138)
* Add @expo/entity-ip-address-field

- adds an entity field for IP addresses, validates both v4 and v6 type IPs
- ip address field functionality moved to its own package
- specified `@expo/entity` as a dev & peer dependency
2021-10-15 10:40:20 -07:00
Will Schurman 677d15174c v0.18.0 (#140) 2021-10-14 14:51:59 -07:00
Will Schurman 1220477aa3 feat: expose describeFieldTestCase for use in other packages (#139) 2021-10-14 14:41:26 -07:00
Will Schurman 5c7efe1678 feat: use better QueryContext types for transactional triggers and validators (#136) 2021-09-17 17:24:48 -07:00
Will Schurman 4263cb9b8b feat: enable noImplicitOverride tsc setting (#135) 2021-09-07 15:18:22 -07:00
Will Schurman 7612392dbf feat: upgrade TypeScript to 4.4 (#134) 2021-09-07 15:17:39 -07:00
Will Schurman 1201cc065c v0.17.0 (#133) 2021-08-16 11:52:43 -06:00
Hayden Daly 2f2d963cf9 feat: Add IntField and FloatField, deprecate NumberField (#131)
Why
To better represent Postgres' type system, deprecated the type NumberField and replaced with IntField and FloatField.

How
Added the new types to EntityFields.ts. To release, will need to bump minor version so breaking changes aren't propagated.

Test Plan
Update tests using the NumberField to use IntField.
2021-07-08 08:50:10 -07:00
James Ide 944f4d2e07 v0.16.0 (#132) 2021-07-06 21:33:03 -07:00
Will Schurman 2f37dc6a37 feat: allow null field values in loadManyByFieldEqualityConjunctionAsync (#130) 2021-07-01 10:58:34 -07:00
Will Schurman 5401be0ac8 docs: add better docblocks for EntityCompanionDefinition and EntityConfiguration (#129) 2021-06-11 13:08:11 -07:00
Will Schurman ee28fa0a87 v0.15.0 (#127) 2021-05-26 17:20:52 -06:00
Will Schurman 226bffe0a9 fix: upgrade some other packages to fix dependabot alerts (#126) 2021-05-26 15:00:08 -06:00
Will Schurman 7c43edf3de fix: upgrade ioredis, knex, and pg (#125) 2021-05-26 14:59:20 -06:00
Will Schurman 4cba01eb25 feat: secondary cache loader (#123) 2021-05-26 09:52:38 -06:00
Will Schurman d78f452bb3 fix: use column name instead of field name for redis cache key (#124) 2021-05-12 15:59:42 -07:00
Will Schurman eaab0b1f4c v0.14.1 (#122) 2021-03-24 15:31:19 -07:00
Will Schurman 8e8f0dc863 fix: typescript typedef of EntityMutationInfo (#121) 2021-03-24 15:28:10 -07:00
Will Schurman 1621a1c432 v0.14.0 (#120) 2021-03-24 15:05:39 -07:00
Will Schurman ef40fa5e9c feat: add mutation type to validator and trigger execute functions (#119) 2021-03-23 22:07:53 -07:00
Will Schurman b11dbc087b fix: mutation validator and trigger cyclic import structure (#118) 2021-03-23 13:08:00 -07:00
Will Schurman 3ce961d2f2 docs: upgrade typedoc (#117) 2021-02-19 15:01:01 -08:00
Will Schurman 2052838403 docs: fix docblock comment issue (#116) 2021-02-19 14:28:04 -08:00
Will Schurman 9e828f4e0b v0.13.0 (#115) 2021-02-12 09:40:01 -08:00
Will Schurman 022e0e9b88 fix: allow undefined value for all fields (#114) 2021-02-12 09:34:24 -08:00
Will Schurman 6c4d4b03b5 feat: add basic field type runtime validators (#113) 2021-02-08 09:36:29 -08:00
Will Schurman 5a8479f3d9 v0.12.0 (#112) 2021-01-22 09:28:24 -08:00
Will Schurman 045c575713 chore: upgrade more packages (#111) 2021-01-20 12:53:31 -08:00
Will Schurman a032141fbf chore: upgrade typescript to 4.1 (#110) 2021-01-20 12:37:15 -08:00
Will Schurman 72a77f8b68 fix: remove cache and database adaptor flavor enums (#109) 2021-01-20 11:49:45 -08:00
Will Schurman b7309e18a2 fix: move runInTransaction to ViewerContext (#108) 2021-01-20 10:07:13 -08:00
Will Schurman 01b3bfef7c feat: better typing on filterMap for use with predicates (#106) 2021-01-08 10:02:02 -08:00
Will Schurman 83547412a2 v0.11.0 (#105) 2020-12-23 16:49:06 -08:00
Will Schurman fe2917e6b5 fix: add type generic default in test utils (#104) 2020-12-23 16:18:57 -08:00
Will Schurman 29c1a434ab fix: maps utils invariants for collections containing undefined values (#103) 2020-12-23 14:01:20 -08:00
Will Schurman 15546aad97 feat: better cache adapter error handling (#102) 2020-12-23 13:47:22 -08:00
Will Schurman 5208aeedb0 feat: better database adapter error handling (#101) 2020-12-22 18:01:12 -08:00
Will Schurman b8e07f9ddc fix: remove any cast from EntityLoader (#100) 2020-12-21 16:28:28 -08:00
404 changed files with 61162 additions and 19818 deletions

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is Entity, a privacy-aware data layer for defining, caching, and authorizing access to application data models.
## Essential Development Commands
### Dependencies
- `yarn add` - Add a dependency. Run this only if necessary and ask before running.
### Building and Linting
- `yarn tsc` - Typecheck the code
- `yarn lint` - Run oxlint on source code
- `yarn format` - Format code with oxfmt
- `yarn format-check` - Check formatting without writing
- `yarn ctix` - Build barrel index.ts files
### Testing
- `yarn test` - Run unit tests
- `yarn integration` - Run integration tests against dockerized environment
### Commit messages
- All PRs use conventional commits for their titles: https://www.conventionalcommits.org/
## Code conventions
- Backwards compatibility is a non-goal of refactorings of this library. Consumers rely upon typescript to know what needs to be migrated, so all breaking changes should be detectable via running typescript post-entity-version-upgrade in a consumer application that makes use of this package.
- Do not use jest's `test.skip`
- Prefer not to use dynamic imports for now
- Whenever technically correct, use typescript types for something instead of `any`
- Prefer American English spellings of words over British English. For example, prefer "Adapter" over "Adaptor".
+54
View File
@@ -0,0 +1,54 @@
{
"mode": "bundle",
"fileExt": "keep",
"overwrite": true,
"backup": false,
"generationStyle": "default-alias-named-star",
"options": [
{
"project": "packages/entity/tsconfig.json",
"output": "packages/entity/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-cache-adapter-local-memory/tsconfig.json",
"output": "packages/entity-cache-adapter-local-memory/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-cache-adapter-redis/tsconfig.json",
"output": "packages/entity-cache-adapter-redis/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-database-adapter-knex/tsconfig.json",
"output": "packages/entity-database-adapter-knex/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-ip-address-field/tsconfig.json",
"output": "packages/entity-ip-address-field/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-secondary-cache-local-memory/tsconfig.json",
"output": "packages/entity-secondary-cache-local-memory/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-secondary-cache-redis/tsconfig.json",
"output": "packages/entity-secondary-cache-redis/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-testing-utils/tsconfig.json",
"output": "packages/entity-testing-utils/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-database-adapter-knex-testing-utils/tsconfig.json",
"output": "packages/entity-database-adapter-knex-testing-utils/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
}
]
}
-54
View File
@@ -1,54 +0,0 @@
{
"root": true,
"extends": [
"universe/node",
"universe/shared/typescript-analysis"
],
"plugins": [
"eslint-plugin-tsdoc"
],
"rules": {
"no-restricted-imports": ["error",{ "paths": [".", "..", "../.."] }],
"tsdoc/syntax": "warn",
"no-console": "warn"
},
"overrides": [
{
"files": [
"*.ts",
"*.tsx",
"*.d.ts"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/explicit-function-return-type": [
"warn",
{
"allowExpressions": true
}
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"no-dupe-class-members": "off",
"@typescript-eslint/no-dupe-class-members": [
"error"
]
}
}
]
}
+1
View File
@@ -0,0 +1 @@
packages/entity-codemod/**/__testfixtures__/**
+1
View File
@@ -0,0 +1 @@
yarn.lock -diff linguist-generated
-27
View File
@@ -1,27 +0,0 @@
name: docs
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
path: ${{ github.workspace }}/node_modules
key: ${{ runner.OS }}-node_modules-${{ hashFiles('yarn.lock') }}
- run: yarn --frozen-lockfile
- run: yarn typedoc
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./doc
+20
View File
@@ -0,0 +1,20 @@
name: Lint PR
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+45 -29
View File
@@ -2,37 +2,53 @@ name: tests
on:
push:
branches: [ master ]
branches: [main]
pull_request:
env:
# renovate: datasource=github-releases depName=jdx/mise
MISE_VERSION: v2026.7.0
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Cache Node.js modules
uses: actions/cache@v1
with:
path: ${{ github.workspace }}/node_modules
key: ${{ runner.OS }}-node_modules-${{ hashFiles('yarn.lock') }}
- run: yarn --frozen-lockfile
- run: yarn lint -- -- --max-warnings=0
- run: yarn test -- -- --coverage
- run: yarn integration -- -- --coverage
- uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./packages/*/coverage/clover.xml
flags: unittest
fail_ci_if_error: true
- uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./packages/*/coverage-integration/clover.xml
flags: integration
fail_ci_if_error: true
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ env.MISE_VERSION }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: yarn
- run: mise run ci
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: ./coverage
flags: unittest
fail_ci_if_error: true
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: ./coverage-integration
flags: integration
fail_ci_if_error: true
docs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ env.MISE_VERSION }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: yarn
- run: mise run typedoc
- name: Deploy
if: ${{ github.ref == 'refs/heads/main' }}
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./doc
+13 -1
View File
@@ -23,12 +23,24 @@ node_modules/
.yarn-integrity
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.pnp.*
# [yarn](https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored)
**/.yarn/*
!**/.yarn/cache
!**/.yarn/patches
!**/.yarn/plugins
!**/.yarn/releases
!**/.yarn/sdks
!**/.yarn/versions
**/.pnp.*
.nx/cache
.nx/workspace-data
# Entity-specific ignores
build/
+17
View File
@@ -0,0 +1,17 @@
{
"printWidth": 100,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"sortImports": {
"groups": [
["builtin", "external"],
["internal", "subpath"],
["parent", "sibling", "index"],
"unknown"
],
"ignoreCase": false
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"jsxBracketSameLine": true
}
+11 -2
View File
@@ -1,3 +1,12 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
"typescript.tsdk": "node_modules/typescript/lib",
"oxc.fmt.configPath": ".oxfmtrc.json",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
}
}
+7
View File
@@ -0,0 +1,7 @@
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- '@expo/*'
- 'oxlint-config-universe'
+552 -102
View File
@@ -3,166 +3,616 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
# [0.65.0](https://github.com/expo/entity/compare/v0.64.0...v0.65.0) (2026-07-10)
### Bug Fixes
* fix null handling in load paths (try 2) ([#96](https://github.com/expo/entity/issues/96)) ([d960329](https://github.com/expo/entity/commit/d9603298e0cc5c3c2b0cca959876e9195555fb57))
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
### Bug Fixes
* edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
### Bug Fixes
* edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
- **deps:** update dependency koa to v3.2.1 ([#634](https://github.com/expo/entity/issues/634)) ([1c17dc0](https://github.com/expo/entity/commit/1c17dc0f37ec68fb73aae1f92b3ce2c79062b93e))
### Features
* add ability to invalidate an entity manually ([#91](https://github.com/expo/entity/issues/91)) ([d1a6f13](https://github.com/expo/entity/commit/d1a6f1339648942f12b3fb605eb329aefe5cdd63))
- accept array of parent entity classes in cascade deletion rule ([#650](https://github.com/expo/entity/issues/650)) ([df75910](https://github.com/expo/entity/commit/df75910cbdc89df60ed2f23dbedf8862ed5c9077))
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
### Bug Fixes
- only throw EntityDatabaseAdapterPaginationCursorInvalidError from cursor decoding logic ([#537](https://github.com/expo/entity/issues/537)) ([e3cee07](https://github.com/expo/entity/commit/e3cee07091af8d1e6c5bb2d82f13e837c7658ea9))
- remove unused arg from EntityInvalidationUtils ([#536](https://github.com/expo/entity/issues/536)) ([0a1d1b5](https://github.com/expo/entity/commit/0a1d1b5972c15c932f5c31015f2519163f882f83))
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
### Bug Fixes
- **deps:** update dependency graphql to v16.13.1 ([#510](https://github.com/expo/entity/issues/510)) ([1a8535f](https://github.com/expo/entity/commit/1a8535f983f38aaa462a8d587e2f62f5fab1043c))
### Features
- update the SQLExpression and SQLFragmentHelpers APIs ([#516](https://github.com/expo/entity/issues/516)) ([112d4dc](https://github.com/expo/entity/commit/112d4dc9b379da5317ff6acaf1d2e9bd597128a6))
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
### Features
- Add SQLExpression class that supports fluent comparison method API ([#506](https://github.com/expo/entity/issues/506)) ([535b681](https://github.com/expo/entity/commit/535b6811e08578aba1aadb095f3e401cfe7eaa26))
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
### Bug Fixes
- **deps:** update dependency @koa/router to v15.3.1 ([#497](https://github.com/expo/entity/issues/497)) ([72b3c80](https://github.com/expo/entity/commit/72b3c80d12a1afa3c5593bcf6d7ad233c3524341))
### Features
- add support to SQLFragment for `= ANY(?)` ([#498](https://github.com/expo/entity/issues/498)) ([b931daa](https://github.com/expo/entity/commit/b931daa9a082cdb33f4559d6d32e1655f0459b79))
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package root
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
### Features
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
### Bug Fixes
- **deps:** update dependency @apollo/server to v5.3.0 ([#403](https://github.com/expo/entity/issues/403)) ([b758c4d](https://github.com/expo/entity/commit/b758c4d344294797430de0afb88e47f558b6fa8d))
### Features
- add AllowIf[Any|All]SubRulesAllowPrivacyPolicyRule rules ([#401](https://github.com/expo/entity/issues/401)) ([5db8977](https://github.com/expo/entity/commit/5db89777c605eda9a8393227ceeb641e460dc463))
- Add AllowIfInParentCascadeDeletionPrivacyPolicyRule ([#399](https://github.com/expo/entity/issues/399)) ([56ed357](https://github.com/expo/entity/commit/56ed357b923a8374a3c120e3eef5d487d5fb2f9c))
- add EvaluateIfEntityFieldPredicatePrivacyPolicyRule ([#400](https://github.com/expo/entity/issues/400)) ([f3b575c](https://github.com/expo/entity/commit/f3b575cd00d47c747cf45e96c6db7857592b0f93))
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
### Bug Fixes
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
- **deps:** pin dependency @apollo/server to 4.12.2 ([#330](https://github.com/expo/entity/issues/330)) ([7038912](https://github.com/expo/entity/commit/7038912ca5e5b203ebbca4b46bda5612e7660eac))
- **deps:** pin dependency @koa/bodyparser to 6.0.0 ([#339](https://github.com/expo/entity/issues/339)) ([9f93926](https://github.com/expo/entity/commit/9f939269cf843059ec0ac729d470db074730348e))
- **deps:** pin dependency koa to 2.16.3 ([#331](https://github.com/expo/entity/issues/331)) ([ce55922](https://github.com/expo/entity/commit/ce55922e3ec6692a9ea4847427dc475902174830))
- Inline map().filter() loops in EntityLoaderUtils ([#369](https://github.com/expo/entity/issues/369)) ([ea6c128](https://github.com/expo/entity/commit/ea6c128d9c8fc21484645796f79e1aa8839dd395))
- run tsc during tests CI job ([#373](https://github.com/expo/entity/issues/373)) ([1cc57ed](https://github.com/expo/entity/commit/1cc57eddce72663cff17a5102afa6c3e8248aee4))
### Features
- synthetically set cascading set-null fields to null during canViewerDeleteAsync evaluation ([#376](https://github.com/expo/entity/issues/376)) ([544bb7c](https://github.com/expo/entity/commit/544bb7c3f2f41419cb3b779f7e3d968e09455fa0))
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
**Note:** Version bump only for package root
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
### Features
- switch local cache to an interface ([#322](https://github.com/expo/entity/issues/322)) ([c453726](https://github.com/expo/entity/commit/c4537262d810ea01e519f9aec6ae5b17a54ce936))
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
### Bug Fixes
- add default for TSelectedFields in PrivacyPolicyTestUtils Case ([#316](https://github.com/expo/entity/issues/316)) ([2f9d487](https://github.com/expo/entity/commit/2f9d4873aaefe891920c25a41088f7c467da6113))
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
### Bug Fixes
- make in memory and stub database adapters behave more similarly to sql ([#315](https://github.com/expo/entity/issues/315)) ([0f53c24](https://github.com/expo/entity/commit/0f53c24faff8a994906e630d7f17d67d89ff5b0f)), closes [#311](https://github.com/expo/entity/issues/311)
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
### Bug Fixes
- add readonly modifier to partitionArray utility method ([#307](https://github.com/expo/entity/issues/307)) ([077d53c](https://github.com/expo/entity/commit/077d53cd86d4c54132ea2ebcc59d1d75e2b507d0))
- validate that UUID field is lower-case ([#310](https://github.com/expo/entity/issues/310)) ([949f00d](https://github.com/expo/entity/commit/949f00d93b52e405188b9b23e23ae59ce1c546de)), closes [/github.com/expo/entity/blob/077d53cd86d4c54132ea2ebcc59d1d75e2b507d0/packages/entity/src/EntityDatabaseAdapter.ts#L182](https://github.com//github.com/expo/entity/blob/077d53cd86d4c54132ea2ebcc59d1d75e2b507d0/packages/entity/src/EntityDatabaseAdapter.ts/issues/L182) [#309](https://github.com/expo/entity/issues/309)
### Features
- add more documentation to entity errors ([#312](https://github.com/expo/entity/issues/312)) ([4c3fd17](https://github.com/expo/entity/commit/4c3fd175a91cf3107b1ac413e39e52e051a9dc4b))
- throw specific errors for database adapter issues ([#311](https://github.com/expo/entity/issues/311)) ([f6639f9](https://github.com/expo/entity/commit/f6639f9def4ac20b628dd48fdca8d7cf61b6347b))
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
**Note:** Version bump only for package root
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
### Features
- add support for Buffer fields ([#305](https://github.com/expo/entity/issues/305)) ([31b52a5](https://github.com/expo/entity/commit/31b52a5f75842f4810f66c12ad03a10d9192f889))
- allow manual specification of cascadingDeleteCause in delete mutator factory method ([#306](https://github.com/expo/entity/issues/306)) ([4a3eda2](https://github.com/expo/entity/commit/4a3eda209fa747ee093ca3053b3196bf9a52193d))
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
**Note:** Version bump only for package root
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
### Features
- add ability to define a custom cache key invalidation version supplier ([#290](https://github.com/expo/entity/issues/290)) ([a3ab61b](https://github.com/expo/entity/commit/a3ab61bfa8d2eab3bbd9c703c5e483184bb67f13))
- add createOrGetExistingAsync and createWithUniqueConstraintRecoveryAsync utility methods ([#291](https://github.com/expo/entity/issues/291)) ([78bc264](https://github.com/expo/entity/commit/78bc264b3328692b4b6722388a57d298f02e65c5))
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
### Bug Fixes
- introduce transactional dataloader mode and fix global transaction ID generation ([#289](https://github.com/expo/entity/issues/289)) ([d3e89bd](https://github.com/expo/entity/commit/d3e89bdd82e6df5c5b35522bc3574fd9e1cfddff))
### Features
- add transaction-scoped dataloaders ([#284](https://github.com/expo/entity/issues/284)) ([e054a0d](https://github.com/expo/entity/commit/e054a0d74b3a39158ca7d73d05f59d3633113400)), closes [#98](https://github.com/expo/entity/issues/98) [#194](https://github.com/expo/entity/issues/194)
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
### Bug Fixes
- fix generated tsc definition sentinel type visibility ([#283](https://github.com/expo/entity/issues/283)) ([9758fd5](https://github.com/expo/entity/commit/9758fd5df8695a8121258cd988c0c79ce8a3e4b6))
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
### Bug Fixes
- fix metrics for dataloader waterfall logging ([#267](https://github.com/expo/entity/issues/267)) ([1d95eba](https://github.com/expo/entity/commit/1d95eba1f9efb67164c3affc917c9b2514e1529f))
- move source of truth of loader docblocks to enforcing loader since it is not the default loader ([#264](https://github.com/expo/entity/issues/264)) ([aa56b70](https://github.com/expo/entity/commit/aa56b705db115cf760125208eb2d3585a3794e58))
### Features
- add @expo/entity-testing-utils package ([#280](https://github.com/expo/entity/issues/280)) ([485894a](https://github.com/expo/entity/commit/485894af16e233d533a9480285bbda56812cbe0b))
- add composite field loading and caching ([#272](https://github.com/expo/entity/issues/272)) ([f0aa0da](https://github.com/expo/entity/commit/f0aa0dafebdb56418cbd22dda437233d850db3e7))
- add some utility classes and methods in preparation for composite keys ([#269](https://github.com/expo/entity/issues/269)) ([ad41af1](https://github.com/expo/entity/commit/ad41af1c826fb267f5e463771107c85802578a19)), closes [#201](https://github.com/expo/entity/issues/201)
- change entity id field generic to field name and derive type where necessary ([#278](https://github.com/expo/entity/issues/278)) ([b7e524c](https://github.com/expo/entity/commit/b7e524c41892797608d0b884410b8d520a80a9ef))
- convert batched/cached loader interface to holder pattern ([#271](https://github.com/expo/entity/issues/271)) ([06b3cb7](https://github.com/expo/entity/commit/06b3cb7f8076ef9e5b83cdf154da159801bd14cc)), closes [#201](https://github.com/expo/entity/issues/201)
- enforce explicit id field cache property ([#276](https://github.com/expo/entity/issues/276)) ([1da5cc0](https://github.com/expo/entity/commit/1da5cc01e1affc3b3338a0ab2050504b5853ac6f))
- invalidate n+/-1 cacheKeyVersion for entities for push safety ([#275](https://github.com/expo/entity/issues/275)) ([d9c1852](https://github.com/expo/entity/commit/d9c1852706f98f0761ff24ddd244e4ed2beca580))
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
- feat!: Default all loaders and mutators to enforcing (#256) ([a976e76](https://github.com/expo/entity/commit/a976e765098bca27049585e43a3bed5c71b9ac6b)), closes [#256](https://github.com/expo/entity/issues/256)
### Features
- Create codemod for v0.40.0 -> v0.41.0 ([#260](https://github.com/expo/entity/issues/260)) ([4a2b61f](https://github.com/expo/entity/commit/4a2b61f2bd8edfc19109ef4c8ac780eaf0f7795c))
### BREAKING CHANGES
- Default all loaders and mutators to enforcing
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
### Features
- Create codemod for v0.39.0 -> v0.40.0 ([#259](https://github.com/expo/entity/issues/259)) ([d63ddd6](https://github.com/expo/entity/commit/d63ddd65997a441a914a71b158a111339970aeab))
### Reverts
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
# [0.39.0](https://github.com/expo/entity/compare/v0.38.0...v0.39.0) (2024-11-21)
### Features
- add function to get authorization results for canViewerUpdateAsync/canViewerDeleteAsync ([#249](https://github.com/expo/entity/issues/249)) ([27a358d](https://github.com/expo/entity/commit/27a358d259503aa8d7d7413bfc1e52e0974dc626))
# [0.38.0](https://github.com/expo/entity/compare/v0.37.0...v0.38.0) (2024-06-27)
### Features
- add EntityEdgeDeletionAuthorizationInferenceBehavior for canViewerDeleteAsync ([#243](https://github.com/expo/entity/issues/243)) ([162b192](https://github.com/expo/entity/commit/162b19257ac720c94fb84d64808fb75748adcdc7))
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
- update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Breaking Changes
- make using non-enforcing entity loader explicit ([#238](https://github.com/expo/entity/issues/238)) ([2edc7af](https://github.com/expo/entity/commit/2edc7af7e20c362688ff2ee674fa2245205d6de4))
### Bug Fixes
- always reload entity after update since cascading changes may have changed it since commit ([#233](https://github.com/expo/entity/issues/233)) ([7c3c985](https://github.com/expo/entity/commit/7c3c9854a4dd91d4b73ebdb18bdeadea8b63f4c7))
- constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
- disallow keys of JS Object prototype for safety ([#236](https://github.com/expo/entity/issues/236)) ([05726d4](https://github.com/expo/entity/commit/05726d45fa9b91a6b25ddbc9c7e3e097b02097ca))
### Features
- Add global mutation trigger field to EntityCompanionProvider ([#215](https://github.com/expo/entity/issues/215)) ([6569486](https://github.com/expo/entity/commit/6569486ecddf2fd33c626f634c761e06636b1730))
- add previousValue to privacy policy context for updates ([#232](https://github.com/expo/entity/issues/232)) ([af495a9](https://github.com/expo/entity/commit/af495a9a856a5042f00cfd150348e415139cb85e))
- Add StrictEnumField with better validation ([#222](https://github.com/expo/entity/issues/222)) ([8753252](https://github.com/expo/entity/commit/87532525d090b3a67f1cedd95ac990f7dcb3ab5d))
- make canViewerDeleteAsync recursive ([#224](https://github.com/expo/entity/issues/224)) ([60fc9a4](https://github.com/expo/entity/commit/60fc9a4c4b587b866f2f5a1b3bd22145bc77e6e9))
- Use uuid v7 in stub database adapter ([#234](https://github.com/expo/entity/issues/234)) ([1e8ea64](https://github.com/expo/entity/commit/1e8ea64017430a2ce5ea197678bd78713ad77ecd))
- move entity loader utils into their own object ([#239](https://github.com/expo/entity/issues/239))([93905be](https://github.com/expo/entity/commit/93905be81cf74c5626ca19358794261c17223f95))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
### Bug Fixes
- Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
- Allow UUIDv6/7/8 in UUIDField validation ([#221](https://github.com/expo/entity/issues/221)) ([cc6f3dc](https://github.com/expo/entity/commit/cc6f3dc1e677382eec220723c37b5b883d980372))
- require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
### Features
- add loadManyByIDsNullableAsync loader method ([#214](https://github.com/expo/entity/issues/214)) ([aad94ac](https://github.com/expo/entity/commit/aad94aca479ac07cc7f76b5e1a122ff85a2d1542))
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Bug Fixes
- remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
### Features
- add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
- entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.1](https://github.com/expo/entity/compare/v0.31.0...v0.31.1) (2023-03-06)
### Features
- add loadFirstByFieldEqualityConjunction, a convenience method ([#206](https://github.com/expo/entity/pull/206)) ([1934216](https://github.com/expo/entity/commit/1934216b70572cf2b9693e0f14939f49b90427dd))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package root
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package root
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
### Features
- add example of redis mget batching using @expo/batcher ([#199](https://github.com/expo/entity/issues/199)) ([684f91b](https://github.com/expo/entity/commit/684f91b1f270851dbe331df2e2620664eea17266))
- change redis cache adapter to use redis-like interface ([#200](https://github.com/expo/entity/issues/200)) ([e381f5b](https://github.com/expo/entity/commit/e381f5b2acfc17dcade2cf6e8506fa1c1c255e73))
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
- add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
- nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
- add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
### Features
- recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Bug Fixes
- fix workflow branch specification ([#179](https://github.com/expo/entity/issues/179)) ([25d14bf](https://github.com/expo/entity/commit/25d14bf8c1688524ad488aa2b63221393a4f0b05))
### Features
- add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
- update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
- lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
- lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
### Bug Fixes
- add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
- composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
- privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
- add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
### Chores
- Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Breaking changes
- Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
- Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
- add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Bug Fixes
- separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
### Features
- add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
### Features
- add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
- add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
- enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
- expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
- upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
- use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
- Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
### Features
- allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
- upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
- upgrade some other packages to fix dependabot alerts ([#126](https://github.com/expo/entity/issues/126)) ([226bffe](https://github.com/expo/entity/commit/226bffe0a95d7b8a3e9e9d46aebdb0edf1eeec1a))
- use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
### Features
- secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
### Bug Fixes
- typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
### Bug Fixes
- mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
### Features
- add mutation type to validator and trigger execute functions ([#119](https://github.com/expo/entity/issues/119)) ([ef40fa5](https://github.com/expo/entity/commit/ef40fa5e9cdf53e25e78a9423c698e6d1c9737af))
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Bug Fixes
- allow undefined value for all fields ([#114](https://github.com/expo/entity/issues/114)) ([022e0e9](https://github.com/expo/entity/commit/022e0e9b88a5e84e0c1bc6c8810c850beb05f0cf))
### Features
- add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
- move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
- remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
### Features
- better typing on filterMap for use with predicates ([#106](https://github.com/expo/entity/issues/106)) ([01b3bfe](https://github.com/expo/entity/commit/01b3bfef7c6da55e7d89d8cccb52a0050187aee8))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Bug Fixes
- add type generic default in test utils ([#104](https://github.com/expo/entity/issues/104)) ([fe2917e](https://github.com/expo/entity/commit/fe2917e6b5af808f166aa354d476e168c41e6d21))
- maps utils invariants for collections containing undefined values ([#103](https://github.com/expo/entity/issues/103)) ([29c1a43](https://github.com/expo/entity/commit/29c1a434ab6cd8d30e9f0e7c742065c8d46d8d8a))
- remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
### Features
- better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
- better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
### Bug Fixes
- fix null handling in load paths (try 2) ([#96](https://github.com/expo/entity/issues/96)) ([d960329](https://github.com/expo/entity/commit/d9603298e0cc5c3c2b0cca959876e9195555fb57))
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
### Bug Fixes
- edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
### Bug Fixes
- edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
### Features
- add ability to invalidate an entity manually ([#91](https://github.com/expo/entity/issues/91)) ([d1a6f13](https://github.com/expo/entity/commit/d1a6f1339648942f12b3fb605eb329aefe5cdd63))
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package root
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
### Features
* add entityClassName to metrics adapter events ([#83](https://github.com/expo/entity/issues/83)) ([f4cdc01](https://github.com/expo/entity/commit/f4cdc01c6cb8868c24ce3da2274f0901687e05bd))
- add entityClassName to metrics adapter events ([#83](https://github.com/expo/entity/issues/83)) ([f4cdc01](https://github.com/expo/entity/commit/f4cdc01c6cb8868c24ce3da2274f0901687e05bd))
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
### Bug Fixes
* correct type of runInTransactionAsync ([#81](https://github.com/expo/entity/issues/81)) ([f98f7d1](https://github.com/expo/entity/commit/f98f7d14b6149a496a7a3e97dd63d89b2842cc26))
- correct type of runInTransactionAsync ([#81](https://github.com/expo/entity/issues/81)) ([f98f7d1](https://github.com/expo/entity/commit/f98f7d14b6149a496a7a3e97dd63d89b2842cc26))
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* fix incorrect loader types for field selecitons ([#72](https://github.com/expo/entity/issues/72)) ([cd2df8d](https://github.com/expo/entity/commit/cd2df8da4b81bcd4769e950d3124c18886df2816))
* fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
- fix incorrect loader types for field selecitons ([#72](https://github.com/expo/entity/issues/72)) ([cd2df8d](https://github.com/expo/entity/commit/cd2df8da4b81bcd4769e950d3124c18886df2816))
- fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
- move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
- upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
### Bug Fixes
* ensure transitive deletion triggers are run for CASCADE_DELETE_INVALIDATE_CACHE ([#66](https://github.com/expo/entity/issues/66)) ([5744c51](https://github.com/expo/entity/commit/5744c51e6b3178bd9f229f3f465b683bb4474c8a))
* separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
* update setField to correct typing ([#62](https://github.com/expo/entity/issues/62)) ([7a77afc](https://github.com/expo/entity/commit/7a77afc83ea732c9b062a5d6865eff9d3131d015))
- ensure transitive deletion triggers are run for CASCADE_DELETE_INVALIDATE_CACHE ([#66](https://github.com/expo/entity/issues/66)) ([5744c51](https://github.com/expo/entity/commit/5744c51e6b3178bd9f229f3f465b683bb4474c8a))
- separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
- update setField to correct typing ([#62](https://github.com/expo/entity/issues/62)) ([7a77afc](https://github.com/expo/entity/commit/7a77afc83ea732c9b062a5d6865eff9d3131d015))
### Features
* entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
* mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
- entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
- mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
### Bug Fixes
* fix stub cache key generation for global cache ([#59](https://github.com/expo/entity/issues/59)) ([7c180e8](https://github.com/expo/entity/commit/7c180e84d9a8cdfd9380fac0eab2d02a96ec18f3))
- fix stub cache key generation for global cache ([#59](https://github.com/expo/entity/issues/59)) ([7c180e8](https://github.com/expo/entity/commit/7c180e84d9a8cdfd9380fac0eab2d02a96ec18f3))
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
### Bug Fixes
* correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
- correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
# 0.5.0 (2020-07-01)
### Bug Fixes
* support number ID field generation in StubDatabaseAdapter ([#43](https://github.com/expo/entity/issues/43)) ([60cc0fb](https://github.com/expo/entity/commit/60cc0fbda460c3ada1f5244a2780f90382fe3ebd))
* upgrade Apollo server for security advisory ([#38](https://github.com/expo/entity/issues/38)) ([d25c1e1](https://github.com/expo/entity/commit/d25c1e142d52385725593f4f155d1795697ab490))
- support number ID field generation in StubDatabaseAdapter ([#43](https://github.com/expo/entity/issues/43)) ([60cc0fb](https://github.com/expo/entity/commit/60cc0fbda460c3ada1f5244a2780f90382fe3ebd))
- upgrade Apollo server for security advisory ([#38](https://github.com/expo/entity/issues/38)) ([d25c1e1](https://github.com/expo/entity/commit/d25c1e142d52385725593f4f155d1795697ab490))
### Features
* add two fan out methods to association loader ([#52](https://github.com/expo/entity/issues/52)) ([89cfb3d](https://github.com/expo/entity/commit/89cfb3d5a01d4da90f64acc52ae1b839ba348a35))
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
- add two fan out methods to association loader ([#52](https://github.com/expo/entity/issues/52)) ([89cfb3d](https://github.com/expo/entity/commit/89cfb3d5a01d4da90f64acc52ae1b839ba348a35))
- support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
# 0.4.0 (2020-06-03)
* All Packages
* Documentation Improvements
* CI & Coverage
* TypeScript 3.9 ([#18](https://github.com/expo/entity/pull/18))
* `@expo/entity`
* Enforcing mutator convenience functions ([#5](https://github.com/expo/entity/pull/5))
* `loadByIDNullable` loader method ([#28](https://github.com/expo/entity/pull/28))
* Add ability to check update and delete policies outside of mutation ([#30](https://github.com/expo/entity/pull/30))
* `@expo/entity-cache-adapter-redis`
* `@expo/entity-database-adapter-knex`
* Throw when database adapter receives unexpected mutation results ([#31](https://github.com/expo/entity/pull/31))
* `@expo/entity-example`
* Initial Version
- All Packages
- Documentation Improvements
- CI & Coverage
- TypeScript 3.9 ([#18](https://github.com/expo/entity/pull/18))
- `@expo/entity`
- Enforcing mutator convenience functions ([#5](https://github.com/expo/entity/pull/5))
- `loadByIDNullable` loader method ([#28](https://github.com/expo/entity/pull/28))
- Add ability to check update and delete policies outside of mutation ([#30](https://github.com/expo/entity/pull/30))
- `@expo/entity-cache-adapter-redis`
- `@expo/entity-database-adapter-knex`
- Throw when database adapter receives unexpected mutation results ([#31](https://github.com/expo/entity/pull/31))
- `@expo/entity-example`
- Initial Version
# 0.3.0 (2020-04-28)
* `@expo/entity`
* Initial Version
* `@expo/entity-cache-adapter-redis`
* Initial Version
* `@expo/entity-database-adapter-knex`
* Initial Version
- `@expo/entity`
- Initial Version
- `@expo/entity-cache-adapter-redis`
- Initial Version
- `@expo/entity-database-adapter-knex`
- Initial Version
+24 -23
View File
@@ -4,24 +4,23 @@ Entity is a privacy-aware data layer for defining, caching, and authorizing acce
[![tests](https://github.com/expo/entity/workflows/tests/badge.svg)](https://github.com/expo/entity/actions?query=workflow%3Atests)
[![docs](https://github.com/expo/entity/workflows/docs/badge.svg)](https://expo.github.io/entity/)
[![codecov](https://codecov.io/gh/expo/entity/branch/master/graph/badge.svg)](https://codecov.io/gh/expo/entity)
[![codecov](https://codecov.io/gh/expo/entity/graph/badge.svg?token=4cFAAdINbk)](https://codecov.io/gh/expo/entity)
[![npm](https://img.shields.io/npm/v/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
[![NPM](https://img.shields.io/npm/l/@expo/entity)](https://www.npmjs.com/package/@expo/entity)
## Core Features
- Declarative actor authorization using Privacy Policies
- Configurable data storage using Database Adapters
- Configurable, optional full-object caching using Cache Adapters
- [Dataloader](https://github.com/graphql/dataloader) in-memory caching
- Well-typed model declaration
- Typescript ORM.
- Declarative actor pre-authorization of all CRUD operations using Privacy Policies.
- Configurable data storage using Database Adapters.
- Configurable, optional, full-object read-through caching using Cache Adapters.
- [Dataloader](https://github.com/graphql/dataloader) in-memory batching and read-through caching.
## Getting Started
- [Example application](packages/entity-example)
- [Example application](https://github.com/expo/entity/tree/main/packages/entity-example)
- [Documentation](https://expo.github.io/entity/)
## Background
Authorization is the process of determining whether a user has access to a piece of data or a feature.
@@ -55,10 +54,7 @@ The Entity framework solves this by adding an additional property to the system:
```typescript
class PhotoPrivacyPolicy {
const readRules = [
new AllowIfOwnerRule(),
new AllowIfOrganizationPermissionRule(),
];
readRules = [new AllowIfOwnerRule(), new AllowIfOrganizationPermissionRule()];
}
// in the view, for example
@@ -71,6 +67,7 @@ async function get_photo_page(viewer: ViewerContext): string {
## Use Case
Entity is not limited in where it can or should be used, but was designed for use in a [Koa](https://koajs.com/)-like environment with a request and response. At Expo, we use Entity in the following manner:
1. A request comes into Koa router
1. Middleware initializes the Entity framework for the request
1. A `ViewerContext` is created identifying the individual making the request.
@@ -78,20 +75,24 @@ Entity is not limited in where it can or should be used, but was designed for us
Note: The entity framework instance should not be shared across multiple requests since it contains a unique memoized [Dataloader](https://github.com/graphql/dataloader#class-dataloader). A long-lived instance is prone to data synchronization issues, especially when the application is scaled horizontally and multiple shared caches would exist for the same data.
## Contributing
We control development tooling versions using [mise-en-place](https://mise.jdx.dev). If you have it installed, then:
- Running `mise run ci` will run all tests that our CI environment will run.
- Running `mise t` will show a list of mise tasks you can run with `mise run <name>`.
- Running `mise en .` will enter a shell environment which includes node and yarn.
If you don't have `mise` installed, [you can install it](https://mise.jdx.dev/installing-mise.html#https-mise-run) as easily as running `curl https://mise.run/zsh | sh`.
## Releasing
To release a new version, let's say `v1.0.0` for example:
1. `git checkout -b bump-v1.0.0`
1. `yarn lerna version minor -- --no-push --conventional-commits`
1. `git push -u origin bump-v1.0.0`
1. Create a PR from the `bump-v1.0.0` branch, wait for tests, and commit the PR using GitHub interface.
1. `git checkout master && git pull`
1. `git tag -d v1.0.0`
1. `git tag v1.0.0`
1. `git push origin v1.0.0`
To release a new version:
1. `git checkout main`
1. `npm login` (to refresh token)
1. `yarn lerna publish [patch|minor|major] -- --conventional-commits --force-publish`
1. In GitHub release interface, create a new release from the tag, copy changelog changes to release description.
1. `yarn clean && yarn tsc`
1. In each public subpackage, run `npm publish` and enter 2FA token.
## License
+3
View File
@@ -0,0 +1,3 @@
export default {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
};
+7
View File
@@ -0,0 +1,7 @@
import unitConfig from './jest.config.js';
export default {
...unitConfig,
coverageDirectory: 'coverage-integration',
testMatch: ['**/__integration-tests__/**/*-test.ts'],
};
+11
View File
@@ -0,0 +1,11 @@
import { stripTypeScriptTypes } from 'node:module';
export function process(sourceText, sourcePath) {
return {
code: stripTypeScriptTypes(sourceText, {
mode: 'transform',
sourceMap: true,
sourceUrl: sourcePath,
}),
};
}
+16
View File
@@ -0,0 +1,16 @@
export default {
// This is an absolute path determined at runtime so that running `yarn test`
// both from each package's directory and from root works correctly.
transform: { '\\.[jt]sx?$': new URL('./jest-transform.js', import.meta.url).toString() },
extensionsToTreatAsEsm: ['.ts'],
collectCoverageFrom: [
'packages/*/src/**',
'!packages/*/src/index.ts',
'!packages/entity-example/**',
'!packages/entity-codemod/**',
'!packages/entity-testing-utils/**',
'!**/__*test*__/**',
],
coverageProvider: 'v8',
testMatch: ['**/__tests__/**/*-test.ts'],
};
+3 -5
View File
@@ -1,8 +1,6 @@
{
"packages": [
"packages/*"
],
"packages": ["packages/*"],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.10.0"
"version": "0.65.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+52
View File
@@ -0,0 +1,52 @@
[tools]
node = "24.14.1"
[tasks.yarn]
run = "yarn install --immutable"
[tasks.build]
depends = "yarn"
run = "yarn build"
[tasks.typedoc]
depends = "build"
run = "yarn typedoc"
[tasks."checks:tsc"]
depends = "yarn"
run = "yarn tsc"
[tasks."checks:lint"]
depends = "yarn"
run = "yarn lint"
[tasks."checks:format"]
depends = "yarn"
run = "yarn format-check"
[tasks."checks:barrel"]
description = "Check if barrel files are up-to-date"
depends = "yarn"
run = ["yarn ctix", '[ -z "$(git status --porcelain)" ]']
[tasks."checks:unit"]
depends = "yarn"
run = "yarn test --coverage"
[tasks."checks:integration"]
depends = "yarn"
run = "yarn integration --coverage"
[tasks.ci]
run = { task = "checks:*" }
[settings]
experimental = true # needed for postinstall hook
[hooks]
postinstall = [
# Enable yarn shim. Version controlled by package.json#packageManager
# NOTE: mise can install yarn directly, but renovate does not use mise, so we
# control yarn versions using the packageManager field.
"corepack enable yarn",
]
+16
View File
@@ -0,0 +1,16 @@
{
"targetDefaults": {
"tsc": {
"dependsOn": ["^clean", "clean", "^tsc"]
},
"build": {
"dependsOn": ["^clean", "clean", "^build"]
},
"test": {
"dependsOn": ["^tsc"]
},
"integration": {
"dependsOn": ["^tsc"]
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import { defineConfig } from 'oxlint';
import node from 'oxlint-config-universe/node';
import typescriptAnalysis from 'oxlint-config-universe/typescript-analysis';
export default defineConfig({
extends: [node, typescriptAnalysis],
plugins: ['jest'],
options: {
typeAware: true,
},
rules: {
// Overrides from oxlint-config-universe defaults
eqeqeq: ['warn', 'always'],
'no-restricted-properties': [
'warn',
{
object: 'it',
property: 'only',
message: 'it.only should not be committed to main.',
},
{
object: 'test',
property: 'only',
message: 'test.only should not be committed to main.',
},
{
object: 'describe',
property: 'only',
message: 'describe.only should not be committed to main.',
},
{
object: 'it',
property: 'skip',
message: 'it.skip should not be committed to main.',
},
{
object: 'test',
property: 'skip',
message: 'test.skip should not be committed to main.',
},
{
object: 'describe',
property: 'skip',
message: 'describe.skip should not be committed to main.',
},
],
'no-console': 'warn',
'no-void': ['warn', { allowAsStatement: true }],
'no-restricted-imports': ['error', { paths: ['.', '..', '../..'] }],
// TypeScript rules
'typescript/ban-ts-comment': 'warn',
'typescript/unbound-method': 'warn',
'typescript/restrict-template-expressions': 'off',
'typescript/no-base-to-string': 'off',
'typescript/no-redundant-type-constituents': 'off',
'typescript/require-array-sort-compare': 'off',
'typescript/explicit-function-return-type': ['warn', { allowExpressions: true }],
// Jest rules
'jest/valid-title': 'off',
'jest/expect-expect': 'off',
'jest/no-conditional-expect': 'off',
'jest/no-export': 'off',
'jest/require-to-throw-message': 'off',
},
overrides: [
{
files: ['*.config.js'],
env: { node: true },
globals: { URL: 'readonly' },
},
{
files: ['**/__tests__/**/*.ts', '**/__integration-tests__/**/*.ts'],
rules: {
'typescript/no-confusing-void-expression': 'off',
'typescript/unbound-method': 'off',
},
},
{
files: ['packages/**/*.ts'],
excludeFiles: ['**/*tests__/**', 'packages/entity-codemod/src/transforms/**'],
rules: {
'no-restricted-exports': ['error', { restrictDefaultExports: { direct: true } }],
},
},
],
ignorePatterns: [
'packages/entity-codemod/**/__testfixtures__/**',
'packages/*/build',
'coverage',
'coverage-integration',
'doc',
],
});
+34 -37
View File
@@ -1,45 +1,42 @@
{
"name": "root",
"private": true,
"workspaces": ["packages/*"],
"workspaces": [
"packages/*"
],
"type": "module",
"scripts": {
"tsc": "lerna run tsc",
"clean": "lerna run clean",
"lint": "lerna run lint",
"test": "lerna run test",
"integration": "./resources/run-with-docker lerna run integration-no-setup",
"prepublish": "lerna link && lerna run clean",
"prepare": "lerna run tsc",
"barrelsby": "lerna run barrelsby"
"tsc": "tsc --build",
"build": "tsc --build",
"clean": "yarn build --clean",
"lint": "oxlint --type-aware . --max-warnings=0 && yarn format-check",
"lint-fix": "oxlint --type-aware --fix . && yarn format",
"format": "oxfmt --write --ignore-path .fmtignore .",
"format-check": "oxfmt --check --ignore-path .fmtignore .",
"test": "yarn build --noCheck && NODE_OPTIONS='--experimental-vm-modules --disable-warning=ExperimentalWarning' jest",
"test:all": "yarn test",
"integration": "./resources/run-with-docker yarn test --config jest-integration.config.js --runInBand",
"integration:all": "yarn integration",
"ctix": "ctix build && resources/prepend-barrels.sh",
"package-files": "yarn workspaces foreach --all --no-private --parallel --topological pack --dry-run",
"typedoc": "yarn build && typedoc"
},
"devDependencies": {
"@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0",
"@types/invariant": "^2.2.33",
"@types/ioredis": "^4.17.3",
"@types/jest": "^26.0.10",
"@types/node": "^14.6.0",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^3.10.1",
"@typescript-eslint/parser": "^3.10.1",
"barrelsby": "^2.2.0",
"eslint": "^7.7.0",
"eslint-config-universe": "^4.0.0",
"eslint-plugin-tsdoc": "^0.2.6",
"jest": "^26.4.2",
"lerna": "^3.22.1",
"pg": "^8.3.2",
"prettier": "^2.1.0",
"ts-jest": "~26.2.0",
"ts-mockito": "^2.6.1",
"typedoc": "^0.17.6",
"typedoc-plugin-lerna-packages": "^0.3.0",
"typescript": "^4.0.2",
"uuid": "^8.3.0"
"@tsconfig/bases": "1.0.25",
"@types/node": "24.13.2",
"ctix": "2.8.1",
"jest": "30.4.2",
"lerna": "9.0.7",
"oxfmt": "0.55.0",
"oxlint": "1.66.0",
"oxlint-config-universe": "0.0.3",
"oxlint-tsgolint": "0.23.0",
"prettier": "3.8.4",
"typedoc": "0.28.19",
"typescript": "6.0.3"
},
"dependencies": {
"@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex"
}
"resolutions": {
"collect-v8-coverage": "^1.0.3"
},
"packageManager": "[email protected]+sha512.5c20ba010c99815433e5c8453112165e673f1c7948d8d2b267f4b5e52097538658388ebc9f9580656d9b75c5cc996f990f611f99304a2197d4c56d21eea370e7"
}
@@ -0,0 +1,216 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.65.0](https://github.com/expo/entity/compare/v0.64.0...v0.65.0) (2026-07-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
### Bug Fixes
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
### Features
- switch local cache to an interface ([#322](https://github.com/expo/entity/issues/322)) ([c453726](https://github.com/expo/entity/commit/c4537262d810ea01e519f9aec6ae5b17a54ce936))
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
### Features
- add @expo/entity-testing-utils package ([#280](https://github.com/expo/entity/issues/280)) ([485894a](https://github.com/expo/entity/commit/485894af16e233d533a9480285bbda56812cbe0b))
- add composite field loading and caching ([#272](https://github.com/expo/entity/issues/272)) ([f0aa0da](https://github.com/expo/entity/commit/f0aa0dafebdb56418cbd22dda437233d850db3e7))
- change entity id field generic to field name and derive type where necessary ([#278](https://github.com/expo/entity/issues/278)) ([b7e524c](https://github.com/expo/entity/commit/b7e524c41892797608d0b884410b8d520a80a9ef))
- convert batched/cached loader interface to holder pattern ([#271](https://github.com/expo/entity/issues/271)) ([06b3cb7](https://github.com/expo/entity/commit/06b3cb7f8076ef9e5b83cdf154da159801bd14cc)), closes [#201](https://github.com/expo/entity/issues/201)
- invalidate n+/-1 cacheKeyVersion for entities for push safety ([#275](https://github.com/expo/entity/issues/275)) ([d9c1852](https://github.com/expo/entity/commit/d9c1852706f98f0761ff24ddd244e4ed2beca580))
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
- feat!: Default all loaders and mutators to enforcing (#256) ([a976e76](https://github.com/expo/entity/commit/a976e765098bca27049585e43a3bed5c71b9ac6b)), closes [#256](https://github.com/expo/entity/issues/256)
### BREAKING CHANGES
- Default all loaders and mutators to enforcing
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
### Reverts
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
# [0.39.0](https://github.com/expo/entity/compare/v0.38.0...v0.39.0) (2024-11-21)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.38.0](https://github.com/expo/entity/compare/v0.37.0...v0.38.0) (2024-06-27)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
- update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
- constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
### Bug Fixes
- Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
- require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Features
- entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
### Bug Fixes
- lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
### Bug Fixes
- lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
### Features
- composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Features
- add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
@@ -0,0 +1,49 @@
# `@expo/entity-cache-adapter-local-memory`
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
Note: This uses version 6.0.0 of `node-lru-cache` since it the most tuned version for our use case (low TTL + LRU). Upgrading
to 7.x will cause high memory usage for the entity cache adapter use case since it allocates fixed-size data structures up front to tune for the non-TTL use case: https://github.com/isaacs/node-lru-cache/issues/208.
[Documentation](https://expo.github.io/entity/modules/_expo_cache_adapter_local_memory.html)
## Why NOT use this cache
Because this is an in-memory cache, cross-machine invalidation is not possible. Do not use this cache
if you have the following use cases:
- The objects stored are mutable
- Reading a stale object from the cache is not acceptable in your application
- Cross-machine invalidation is not possible
## Typical use cases
If your application sees many requests fetching the same objects, you can save a trip to your cache
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
- The objects stored are mostly immutable, and reading a stale object for a short TTL is acceptable
- You have a low TTL setting in your cache
## Usage
During `EntityCompanionProvider` instantiation:
```typescript
export const createDefaultEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
{
...
},
{
['local-memory']: {
cacheAdapterProvider: new LocalMemoryCacheAdapterProvider.getProvider(),
},
}
);
};
```
@@ -0,0 +1,40 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.65.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"keywords": [
"entity"
],
"license": "MIT",
"author": "Expo",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"type": "module",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level oxlint --type-aware src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
},
"dependencies": {
"@expo/entity": "workspace:^"
},
"devDependencies": {
"@expo/entity-testing-utils": "workspace:^",
"@isaacs/ttlcache": "2.1.5",
"@jest/globals": "30.4.1",
"typescript": "6.0.3"
},
"engines": {
"node": ">=18"
}
}
@@ -0,0 +1,139 @@
import type {
CacheLoadResult,
EntityConfiguration,
IEntityGenericCacher,
IEntityLoadKey,
IEntityLoadValue,
} from '@expo/entity';
import { CacheStatus } from '@expo/entity';
// Sentinel value we store in local memory to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
/**
* Type of value stored in the local memory cache. This is either the cached fields, or the
* DOES_NOT_EXIST_LOCAL_MEMORY_CACHE sentinel value.
*/
export type LocalMemoryCacheValue<TFields extends Record<string, any>> =
| Readonly<TFields>
| typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
/**
* Interface for a local memory cache used by GenericLocalMemoryCacher. Most often, this is something like
* a TTLCache from the `@isaacs/ttlcache` package or an lru-cache.
*/
export interface ILocalMemoryCache<TFields extends Record<string, any>> {
/**
* Gets a value from the cache for specified key.
* @param key - key to get
* @returns the cached value, or undefined if not present
*/
get(key: string): LocalMemoryCacheValue<TFields> | undefined;
/**
* Sets a value in the cache for specified key.
* @param key - key to set
* @param value - value to set
*/
set(key: string, value: LocalMemoryCacheValue<TFields>): void;
/**
* Deletes a value from the cache for specified key.
* @param key - key to delete
*/
delete(key: string): void;
}
export class GenericLocalMemoryCacher<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> implements IEntityGenericCacher<TFields, TIDField> {
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
private readonly localMemoryCache: ILocalMemoryCache<TFields>,
) {}
static createNoOpCache<TFields extends Record<string, any>>(): ILocalMemoryCache<TFields> {
return {
get(_key: string): LocalMemoryCacheValue<TFields> | undefined {
return undefined;
},
set(_key: string, _value: LocalMemoryCacheValue<TFields>): void {},
delete(_key: string): void {},
};
}
public async loadManyAsync(
keys: readonly string[],
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
for (const key of keys) {
const cacheResult = this.localMemoryCache.get(key);
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
cacheResults.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (cacheResult) {
cacheResults.set(key, {
status: CacheStatus.HIT,
item: cacheResult as unknown as TFields,
});
} else {
cacheResults.set(key, {
status: CacheStatus.MISS,
});
}
}
return cacheResults;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
for (const [key, item] of objectMap) {
this.localMemoryCache.set(key, item);
}
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.set(key, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
}
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.delete(key);
}
}
public makeCacheKeyForStorage<
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
TSerializedLoadValue,
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
>(key: TLoadKey, value: TLoadValue): string {
const cacheKeyType = key.getLoadMethodType();
const keyAndValueParts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
const parts = [
this.entityConfiguration.tableName,
cacheKeyType,
`${this.entityConfiguration.cacheKeyVersion}`,
...keyAndValueParts,
];
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
}
public makeCacheKeysForInvalidation<
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
TSerializedLoadValue,
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
>(key: TLoadKey, value: TLoadValue): readonly string[] {
// for local memory caches, we don't need to invalidate old versions of the cache keys
// since they are not persisted across deploys
return [this.makeCacheKeyForStorage(key, value)];
}
}
@@ -0,0 +1,54 @@
import type {
EntityConfiguration,
IEntityCacheAdapter,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import { computeIfAbsent, GenericEntityCacheAdapter } from '@expo/entity';
import type { ILocalMemoryCache } from './GenericLocalMemoryCacher.ts';
import { GenericLocalMemoryCacher } from './GenericLocalMemoryCacher.ts';
export type LocalMemoryCacheCreator = <
TFields extends Record<string, any>,
>() => ILocalMemoryCache<TFields>;
/**
* Vends local memory cache adapters. An instance of this class may be shared across requests to
* share the local memory cache.
*/
export class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
/**
* @returns a no-op local memory cache adapter provider, or one that doesn't cache at all.
*/
static createNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields extends Record<string, any>>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>(),
);
}
/**
* @returns a local memory cache adapter provider configured with the supplied local memory cache creator function.
*/
static createProviderWithCacheCreator(
cacheCreator: LocalMemoryCacheCreator,
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(cacheCreator);
}
private readonly localMemoryCacheAdapterMap = new Map<
string,
GenericEntityCacheAdapter<any, any>
>();
private constructor(private readonly localMemoryCacheCreator: LocalMemoryCacheCreator) {}
public getCacheAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
): IEntityCacheAdapter<TFields, TIDField> {
return computeIfAbsent(this.localMemoryCacheAdapterMap, entityConfiguration.tableName, () => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache),
);
});
}
}
@@ -0,0 +1,99 @@
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
AlwaysAllowPrivacyPolicyRule,
DateField,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
StringField,
UUIDField,
} from '@expo/entity';
export type LocalMemoryTestEntityFields = {
id: string;
name: string;
dateField: Date | null;
};
export class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
'id',
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
'id',
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return {
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
};
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
'id',
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
'id',
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
'id',
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
'id',
ViewerContext,
LocalMemoryTestEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
'id',
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration = new EntityConfiguration<
LocalMemoryTestEntityFields,
'id'
>({
idField: 'id',
tableName: 'local_memory_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
cache: true,
}),
dateField: new DateField({
columnName: 'date_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
@@ -0,0 +1,63 @@
import type { IEntityMetricsAdapter } from '@expo/entity';
import { EntityCompanionProvider, NoOpEntityMetricsAdapter } from '@expo/entity';
import { StubDatabaseAdapterProvider, StubQueryContextProvider } from '@expo/entity-testing-utils';
import { TTLCache } from '@isaacs/ttlcache';
import type { ILocalMemoryCache, LocalMemoryCacheValue } from '../GenericLocalMemoryCacher.ts';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider.ts';
const queryContextProvider = new StubQueryContextProvider();
function createTTLCache<TFields extends Record<string, any>>(
options: { maxSize?: number; ttlSeconds?: number } = {},
): ILocalMemoryCache<TFields> {
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
const DEFAULT_LRU_CACHE_SIZE = 10000;
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
return new TTLCache<string, LocalMemoryCacheValue<TFields>>({
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
ttl: maxAgeSeconds * 1000, // convert to ms
updateAgeOnGet: true,
});
}
export const createLocalMemoryTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
const localMemoryCacheAdapterProvider =
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
? LocalMemoryCacheAdapterProvider.createNoOpProvider()
: LocalMemoryCacheAdapterProvider.createProviderWithCacheCreator(() =>
createTTLCache(localMemoryOptions),
);
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: localMemoryCacheAdapterProvider,
},
],
]),
);
};
export const createNoOpLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return createLocalMemoryTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter,
);
};
@@ -0,0 +1,178 @@
import type { GenericEntityCacheAdapter, IEntityGenericCacher } from '@expo/entity';
import {
CacheStatus,
SingleFieldHolder,
SingleFieldValueHolder,
ViewerContext,
} from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { v4 as uuidv4 } from 'uuid';
import { GenericLocalMemoryCacher } from '../GenericLocalMemoryCacher.ts';
import type { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider.ts';
import type { LocalMemoryTestEntityFields } from '../__testfixtures__/LocalMemoryTestEntity.ts';
import { LocalMemoryTestEntity } from '../__testfixtures__/LocalMemoryTestEntity.ts';
import {
createLocalMemoryTestEntityCompanionProvider,
createNoOpLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../__testfixtures__/createLocalMemoryTestEntityCompanionProvider.ts';
describe(GenericLocalMemoryCacher, () => {
it('has correct caching behavior', async () => {
const entityCompanionProvider = createLocalMemoryTestEntityCompanionProvider();
const viewerContext = new ViewerContext(entityCompanionProvider);
const genericCacher = (
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
'tableDataCoordinator'
]['cacheAdapter'] as GenericEntityCacheAdapter<LocalMemoryTestEntityFields, 'id'>
)['genericCacher'];
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.createAsync();
// loading an entity should put it in cache
const entity1 = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
entity1Created.getID(),
);
const localMemoryCacheAdapterProvider = entityCompanionProvider['cacheAdapterFlavors'].get(
'local-memory',
)!.cacheAdapterProvider as LocalMemoryCacheAdapterProvider;
const entitySpecificGenericCacher = localMemoryCacheAdapterProvider[
'localMemoryCacheAdapterMap'
].get(
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
.entityCompanionDefinition.entityConfiguration.tableName,
)!['genericCacher'] as IEntityGenericCacher<LocalMemoryTestEntityFields, 'id'>;
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(entity1.getID()),
),
]);
const cachedValue = cachedResult.get(
genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(entity1.getID()),
),
)!;
expect(cachedValue).toMatchObject({
status: CacheStatus.HIT,
item: {
id: entity1.getID(),
name: 'blah',
dateField: date,
},
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult =
await LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
nonExistentId,
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(nonExistentId),
),
]);
expect(
nonExistentCachedResult.get(
genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(nonExistentId),
),
),
).toMatchObject({
status: CacheStatus.NEGATIVE,
});
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 =
await LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
nonExistentId,
);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
entity1.getAllFields(),
);
const keys = genericCacher.makeCacheKeysForInvalidation(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(entity1.getID()),
);
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync(keys);
expect(Array.from(cachedResultMiss.values()).every((v) => v.status === CacheStatus.MISS)).toBe(
true,
);
});
it('respects the parameters of a noop cache', async () => {
const entityCompanionProvider = createNoOpLocalMemoryIntegrationTestEntityCompanionProvider();
const viewerContext = new ViewerContext(entityCompanionProvider);
const genericCacher = (
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
'tableDataCoordinator'
]['cacheAdapter'] as GenericEntityCacheAdapter<LocalMemoryTestEntityFields, 'id'>
)['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKeyForStorage'].bind(genericCacher);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.createAsync();
// loading an entity will try to put it in cache but it's a noop cache, so it should be a miss
const entity1 = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
entity1Created.getID(),
);
const localMemoryCacheAdapterProvider = entityCompanionProvider['cacheAdapterFlavors'].get(
'local-memory',
)!.cacheAdapterProvider as LocalMemoryCacheAdapterProvider;
const entitySpecificGenericCacher = localMemoryCacheAdapterProvider[
'localMemoryCacheAdapterMap'
].get(
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
.entityCompanionDefinition.entityConfiguration.tableName,
)!['genericCacher'] as IEntityGenericCacher<LocalMemoryTestEntityFields, 'id'>;
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker(new SingleFieldHolder('id'), new SingleFieldValueHolder(entity1.getID())),
]);
const cachedValue = cachedResult.get(
cacheKeyMaker(new SingleFieldHolder('id'), new SingleFieldValueHolder(entity1.getID())),
)!;
expect(cachedValue).toMatchObject({
status: CacheStatus.MISS,
});
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
const nonExistentId = uuidv4();
const entityNonExistentResult =
await LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
nonExistentId,
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker(new SingleFieldHolder('id'), new SingleFieldValueHolder(nonExistentId)),
]);
expect(
nonExistentCachedResult.get(
cacheKeyMaker(new SingleFieldHolder('id'), new SingleFieldValueHolder(nonExistentId)),
),
).toMatchObject({
status: CacheStatus.MISS,
});
});
});
@@ -0,0 +1,170 @@
import {
CacheStatus,
EntityConfiguration,
GenericEntityCacheAdapter,
SingleFieldHolder,
SingleFieldValueHolder,
SingleFieldValueHolderMap,
UUIDField,
} from '@expo/entity';
import { TTLCache } from '@isaacs/ttlcache';
import { describe, expect, it } from '@jest/globals';
import type { ILocalMemoryCache, LocalMemoryCacheValue } from '../GenericLocalMemoryCacher.ts';
import {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
GenericLocalMemoryCacher,
} from '../GenericLocalMemoryCacher.ts';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields, 'id'>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
function createTTLCache<TFields extends Record<string, any>>(): ILocalMemoryCache<TFields> {
return new TTLCache<string, LocalMemoryCacheValue<TFields>>({
max: 10000,
ttl: 10 * 1000, // convert to ms
updateAgeOnGet: true,
});
}
describe('Use within GenericEntityCacheAdapter', () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, createTTLCache()),
);
const cacheHits = new Map<SingleFieldValueHolder<BlahFields, 'id'>, Readonly<BlahFields>>([
[new SingleFieldValueHolder('test-id-1'), { id: 'test-id-1' }],
]);
await cacheAdapter.cacheManyAsync(
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
cacheHits,
);
await cacheAdapter.cacheDBMissesAsync(new SingleFieldHolder('id'), [
new SingleFieldValueHolder('test-id-2'),
]);
const results = await cacheAdapter.loadManyAsync(new SingleFieldHolder('id'), [
new SingleFieldValueHolder('test-id-1'),
new SingleFieldValueHolder('test-id-2'),
new SingleFieldValueHolder('test-id-3'),
]);
expect(results.get(new SingleFieldValueHolder('test-id-1'))).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get(new SingleFieldValueHolder('test-id-2'))).toMatchObject({
status: CacheStatus.NEGATIVE,
});
expect(results.get(new SingleFieldValueHolder('test-id-3'))).toMatchObject({
status: CacheStatus.MISS,
});
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of values', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, createTTLCache()),
);
const results = await cacheAdapter.loadManyAsync(
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
[] as SingleFieldValueHolder<BlahFields, 'id'>[],
);
expect(results).toEqual(new SingleFieldValueHolderMap(new Map()));
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = createTTLCache<BlahFields>();
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheManyAsync(
new SingleFieldHolder('id'),
new Map([[new SingleFieldValueHolder('test-id-1'), { id: 'test-id-1' }]]),
);
const cacheKey = localMemoryCacher['makeCacheKeyForStorage'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('test-id-1'),
);
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = createTTLCache<BlahFields>();
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
await cacheAdapter.cacheDBMissesAsync(new SingleFieldHolder('id'), [
new SingleFieldValueHolder('test-id-1'),
]);
const cacheKey = localMemoryCacher['makeCacheKeyForStorage'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('test-id-1'),
);
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = createTTLCache<BlahFields>();
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache),
);
await cacheAdapter.cacheManyAsync(
new SingleFieldHolder('id'),
new Map([[new SingleFieldValueHolder('test-id-1'), { id: 'test-id-1' }]]),
);
await cacheAdapter.cacheDBMissesAsync(new SingleFieldHolder('id'), [
new SingleFieldValueHolder('test-id-2'),
]);
await cacheAdapter.invalidateManyAsync(new SingleFieldHolder('id'), [
new SingleFieldValueHolder('test-id-1'),
new SingleFieldValueHolder('test-id-2'),
]);
const results = await cacheAdapter.loadManyAsync(new SingleFieldHolder('id'), [
new SingleFieldValueHolder('test-id-1'),
new SingleFieldValueHolder('test-id-2'),
]);
expect(results.get(new SingleFieldValueHolder('test-id-1'))).toMatchObject({
status: CacheStatus.MISS,
});
expect(results.get(new SingleFieldValueHolder('test-id-2'))).toMatchObject({
status: CacheStatus.MISS,
});
});
it('returns when passed empty array of values', async () => {
const cacheAdapter = new GenericEntityCacheAdapter(
new GenericLocalMemoryCacher(entityConfiguration, createTTLCache<BlahFields>()),
);
await cacheAdapter.invalidateManyAsync(
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
[] as SingleFieldValueHolder<BlahFields, 'id'>[],
);
});
});
});
@@ -0,0 +1,8 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-cache-adapter-local-memory
*/
export * from './GenericLocalMemoryCacher.ts';
export * from './LocalMemoryCacheAdapterProvider.ts';
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"references": [{ "path": "../entity" }, { "path": "../entity-testing-utils" }]
}
+290 -41
View File
@@ -3,92 +3,341 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
# [0.65.0](https://github.com/expo/entity/compare/v0.64.0...v0.65.0) (2026-07-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
### Bug Fixes
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
### Features
- add support for Buffer fields ([#305](https://github.com/expo/entity/issues/305)) ([31b52a5](https://github.com/expo/entity/commit/31b52a5f75842f4810f66c12ad03a10d9192f889))
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
### Features
- add ability to define a custom cache key invalidation version supplier ([#290](https://github.com/expo/entity/issues/290)) ([a3ab61b](https://github.com/expo/entity/commit/a3ab61bfa8d2eab3bbd9c703c5e483184bb67f13))
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
### Features
- add @expo/entity-testing-utils package ([#280](https://github.com/expo/entity/issues/280)) ([485894a](https://github.com/expo/entity/commit/485894af16e233d533a9480285bbda56812cbe0b))
- add composite field loading and caching ([#272](https://github.com/expo/entity/issues/272)) ([f0aa0da](https://github.com/expo/entity/commit/f0aa0dafebdb56418cbd22dda437233d850db3e7))
- change entity id field generic to field name and derive type where necessary ([#278](https://github.com/expo/entity/issues/278)) ([b7e524c](https://github.com/expo/entity/commit/b7e524c41892797608d0b884410b8d520a80a9ef))
- convert batched/cached loader interface to holder pattern ([#271](https://github.com/expo/entity/issues/271)) ([06b3cb7](https://github.com/expo/entity/commit/06b3cb7f8076ef9e5b83cdf154da159801bd14cc)), closes [#201](https://github.com/expo/entity/issues/201)
- invalidate n+/-1 cacheKeyVersion for entities for push safety ([#275](https://github.com/expo/entity/issues/275)) ([d9c1852](https://github.com/expo/entity/commit/d9c1852706f98f0761ff24ddd244e4ed2beca580))
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
- feat!: Default all loaders and mutators to enforcing (#256) ([a976e76](https://github.com/expo/entity/commit/a976e765098bca27049585e43a3bed5c71b9ac6b)), closes [#256](https://github.com/expo/entity/issues/256)
### BREAKING CHANGES
- Default all loaders and mutators to enforcing
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
### Reverts
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
# [0.39.0](https://github.com/expo/entity/compare/v0.38.0...v0.39.0) (2024-11-21)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.38.0](https://github.com/expo/entity/compare/v0.37.0...v0.38.0) (2024-06-27)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
- update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
- constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
### Bug Fixes
- Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
- require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Bug Fixes
- remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
### Features
- entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
### Features
- add example of redis mget batching using @expo/batcher ([#199](https://github.com/expo/entity/issues/199)) ([684f91b](https://github.com/expo/entity/commit/684f91b1f270851dbe331df2e2620664eea17266))
- change redis cache adapter to use redis-like interface ([#200](https://github.com/expo/entity/issues/200)) ([e381f5b](https://github.com/expo/entity/commit/e381f5b2acfc17dcade2cf6e8506fa1c1c255e73))
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
### Features
- update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
### Chores
- Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
### Chores
- Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
- enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
- upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
- upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
- use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
### Features
- secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
- remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Features
- better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
- upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# 0.5.0 (2020-07-01)
### Features
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
- support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
+6 -13
View File
@@ -11,17 +11,10 @@ During `EntityCompanionProvider` instantiation:
```typescript
import Redis from 'ioredis';
const redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
const genericRedisCacherContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'ent-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
@@ -35,10 +28,10 @@ export const createDefaultEntityCompanionProvider = (
...
},
{
[CacheAdapterFlavor.REDIS]: {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
['redis']: {
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
}
);
};
```
```
@@ -1,37 +1,45 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.10.0",
"version": "0.65.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src --ext '.ts'",
"test": "jest --rootDir . --config ../../resources/jest.config.js",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
"author": "Expo",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"type": "module",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level oxlint --type-aware src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
},
"dependencies": {
"ioredis": "^4.17.3"
"@expo/entity": "workspace:^"
},
"devDependencies": {
"@expo/entity": "^0.10.0"
"@expo/batcher": "1.0.0",
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.4.1",
"ioredis": "5.11.1",
"ts-mockito": "2.6.1",
"typescript": "6.0.3"
},
"peerDependencies": {
"ioredis": ">=5"
},
"engines": {
"node": ">=18"
}
}
@@ -0,0 +1,300 @@
import type {
CacheLoadResult,
EntityConfiguration,
IEntityGenericCacher,
IEntityLoadKey,
IEntityLoadValue,
} from '@expo/entity';
import {
CacheStatus,
transformCacheObjectToFields,
transformFieldsToCacheObject,
} from '@expo/entity';
import { redisTransformerMap } from './RedisCommon.ts';
import { wrapNativeRedisCallAsync } from './errors/wrapNativeRedisCallAsync.ts';
import { getSurroundingCacheKeyVersionsForInvalidation } from './utils/getSurroundingCacheKeyVersionsForInvalidation.ts';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export interface IRedisTransaction {
set(key: string, value: string, secondsToken: 'EX', seconds: number): this;
exec(): Promise<any>;
}
export interface IRedis {
mget(...args: [...keys: string[]]): Promise<(string | null)[]>;
multi(): IRedisTransaction;
del(...args: [...keys: string[]]): Promise<any>;
}
/**
* The strategy for generating the set of cache keys to invalidate in the Redis cache after entity mutation.
*/
export enum RedisCacheInvalidationStrategy {
/**
* Invalidate just the cache key(s) for the current cacheKeyVersion of the entity.
*/
CURRENT_CACHE_KEY_VERSION = 'current-cache-key-version',
/**
* Invalidate the cache key(s) for the current cacheKeyVersion and the surrounding cache key versions
* (e.g. `1`, `2` and `3` if the current version is `2`). This can be useful for deployment safety, where
* some machines may be operating on an old version of the code and thus an old cacheKeyVersion, and some the new version.
* This strategy generates cache keys for both old and potential future new versions.
*/
SURROUNDING_CACHE_KEY_VERSIONS = 'surrounding-cache-key-versions',
/**
* Invalidate cache keys based on user-specified function from the current cacheKeyVersion to a list of cache key
* versions to invalidate.
*/
CUSTOM = 'custom',
}
export type GenericRedisCacheInvalidationConfig =
| {
/**
* Invalidation strategy for the cache.
*/
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION;
}
| {
/**
* Invalidation strategy for the cache.
*/
invalidationStrategy: RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS;
}
| {
/**
* Invalidation strategy for the cache.
*/
invalidationStrategy: RedisCacheInvalidationStrategy.CUSTOM;
/**
* Function that takes the current cache key version and returns the cache key versions to invalidate.
*/
cacheKeyVersionsToInvalidateFn: (cacheKeyVersion: number) => readonly number[];
};
function validateGenericRedisCacheContext(context: GenericRedisCacheContext): void {
if (context.cacheKeyDelimiter.length === 0) {
throw new Error('GenericRedisCacheContext.cacheKeyDelimiter must be a non-empty string');
}
if (context.cacheKeyDelimiter.includes('\\')) {
throw new Error(
`GenericRedisCacheContext.cacheKeyDelimiter must not contain the escape character "\\" (got ${JSON.stringify(context.cacheKeyDelimiter)})`,
);
}
if (!Number.isInteger(context.ttlSecondsPositive) || context.ttlSecondsPositive <= 0) {
throw new Error(
`GenericRedisCacheContext.ttlSecondsPositive must be a positive integer (got ${context.ttlSecondsPositive})`,
);
}
if (!Number.isInteger(context.ttlSecondsNegative) || context.ttlSecondsNegative <= 0) {
throw new Error(
`GenericRedisCacheContext.ttlSecondsNegative must be a positive integer (got ${context.ttlSecondsNegative})`,
);
}
}
export interface GenericRedisCacheContext {
/**
* Instance of ioredis.Redis
*/
redisClient: IRedis;
/**
* Delimiter used to join the parts of a Redis cache key (cache key prefix,
* versions, entity name, field values, etc). Typically `:`.
*
* The cacher escapes occurrences of this delimiter (and the escape character `\`)
* within each part before joining, so the encoding is injective regardless of
* what the parts contain. This prevents a value that happens to contain the
* delimiter from colliding with a different (key, value) pair.
*/
cacheKeyDelimiter: string;
/**
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
* distintion for entity keys, e.g. `ent-`
*/
cacheKeyPrefix: string;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
/**
* Configuration for cache invalidation strategy.
*/
invalidationConfig: GenericRedisCacheInvalidationConfig;
}
export class GenericRedisCacher<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> implements IEntityGenericCacher<TFields, TIDField> {
constructor(
private readonly context: GenericRedisCacheContext,
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
) {
validateGenericRedisCacheContext(context);
}
public async loadManyAsync(
keys: readonly string[],
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys),
);
const results = new Map<string, CacheLoadResult<TFields>>();
for (let i = 0; i < keys.length; i++) {
const key = keys[i]!;
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(key, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(key, {
status: CacheStatus.HIT,
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult),
),
});
} else {
results.set(key, {
status: CacheStatus.MISS,
});
}
}
return results;
}
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
if (objectMap.size === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
objectMap.forEach((object, key) => {
redisTransaction = redisTransaction.set(
key,
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object),
),
'EX',
this.context.ttlSecondsPositive,
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
keys.forEach((key) => {
redisTransaction = redisTransaction.set(
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative,
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
}
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
if (keys.length === 0) {
return;
}
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
}
private makeCacheKeyForCacheKeyVersion<
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
TSerializedLoadValue,
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
>(key: TLoadKey, value: TLoadValue, cacheKeyVersion: number): string {
const cacheKeyType = key.getLoadMethodType();
const keyAndValueParts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
const allParts = [
this.context.cacheKeyPrefix,
cacheKeyType,
this.entityConfiguration.tableName,
`v4.${cacheKeyVersion}`,
...keyAndValueParts,
];
const delimiter = this.context.cacheKeyDelimiter;
return allParts
.map((part) => part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`))
.join(delimiter);
}
public makeCacheKeyForStorage<
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
TSerializedLoadValue,
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
>(key: TLoadKey, value: TLoadValue): string {
return this.makeCacheKeyForCacheKeyVersion(
key,
value,
this.entityConfiguration.cacheKeyVersion,
);
}
public makeCacheKeysForInvalidation<
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
TSerializedLoadValue,
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
>(key: TLoadKey, value: TLoadValue): readonly string[] {
switch (this.context.invalidationConfig.invalidationStrategy) {
case RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION:
return [
this.makeCacheKeyForCacheKeyVersion(key, value, this.entityConfiguration.cacheKeyVersion),
];
case RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS:
return getSurroundingCacheKeyVersionsForInvalidation(
this.entityConfiguration.cacheKeyVersion,
).map((cacheKeyVersion) =>
this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion),
);
case RedisCacheInvalidationStrategy.CUSTOM: {
const cacheKeyVersions = this.context.invalidationConfig.cacheKeyVersionsToInvalidateFn(
this.entityConfiguration.cacheKeyVersion,
);
if (cacheKeyVersions.length === 0) {
throw new Error(
`GenericRedisCacheContext.invalidationConfig.cacheKeyVersionsToInvalidateFn returned an empty list for cacheKeyVersion ${this.entityConfiguration.cacheKeyVersion}; this would silently disable invalidation`,
);
}
return cacheKeyVersions.map((cacheKeyVersion) =>
this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion),
);
}
}
}
}
@@ -1,165 +0,0 @@
import {
EntityCacheAdapter,
EntityConfiguration,
FieldTransformerMap,
CacheLoadResult,
CacheStatus,
} from '@expo/entity';
import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
// Sentinel value we store in Redis to negatively cache a database miss.
// The sentinel value is distinct from any (positively) cached value.
const DOES_NOT_EXIST_REDIS = '';
export interface RedisCacheAdapterContext {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
/**
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
* Most commonly a simple `parts.join(':')`. See integration test for example.
*/
makeKeyFn: (...parts: string[]) => string;
/**
* Global cache version for the entity framework. Bumping this version will
* invalidate the cache for all entities at once.
*/
cacheKeyVersion: number;
/**
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
* distintion for entity keys, e.g. `ent-`
*/
cacheKeyPrefix: string;
/**
* TTL for caching database hits. Successive entity loads within this TTL
* will be read from cache (unless invalidated).
*/
ttlSecondsPositive: number;
/**
* TTL for negatively caching database misses. Successive entity loads within
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
}
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext
) {
super(entityConfiguration);
}
public getFieldTransformerMap(): FieldTransformerMap {
return redisTransformerMap;
}
public async loadManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
if (fieldValues.length === 0) {
return new Map();
}
const redisKeys = fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue));
const redisResults = await this.context.redisClient.mget(...redisKeys);
const results = new Map<NonNullable<TFields[N]>, CacheLoadResult>();
for (let i = 0; i < fieldValues.length; i++) {
const fieldValue = fieldValues[i];
const redisResult = redisResults[i];
if (redisResult === DOES_NOT_EXIST_REDIS) {
results.set(fieldValue, {
status: CacheStatus.NEGATIVE,
});
} else if (redisResult) {
results.set(fieldValue, {
status: CacheStatus.HIT,
item: JSON.parse(redisResult),
});
} else {
results.set(fieldValue, {
status: CacheStatus.MISS,
});
}
}
return results;
}
public async cacheManyAsync<N extends keyof TFields>(
fieldName: N,
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
): Promise<void> {
if (objectMap.size === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
objectMap.forEach((object, fieldValue) => {
const redisKey = this.makeCacheKey(fieldName, fieldValue);
redisTransaction = redisTransaction.set(
redisKey,
JSON.stringify(object),
'EX',
this.context.ttlSecondsPositive
);
});
await redisTransaction.exec();
}
public async cacheDBMissesAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
if (fieldValues.length === 0) {
return;
}
let redisTransaction = this.context.redisClient.multi();
fieldValues.forEach((fieldValue) => {
const redisKey = this.makeCacheKey(fieldName, fieldValue);
redisTransaction = redisTransaction.set(
redisKey,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
);
});
await redisTransaction.exec();
}
public async invalidateManyAsync<N extends keyof TFields>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[]
): Promise<void> {
if (fieldValues.length === 0) {
return;
}
const redisKeys = fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue));
await this.context.redisClient.del(...redisKeys);
}
private makeCacheKey<N extends keyof TFields>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>
): string {
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v${this.entityConfiguration.cacheKeyVersion}`,
fieldName as string,
String(fieldValue)
);
}
}
@@ -1,13 +1,19 @@
import { IEntityCacheAdapterProvider, EntityConfiguration, EntityCacheAdapter } from '@expo/entity';
import type {
EntityConfiguration,
IEntityCacheAdapter,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import { GenericEntityCacheAdapter } from '@expo/entity';
import RedisCacheAdapter, { RedisCacheAdapterContext } from './RedisCacheAdapter';
import type { GenericRedisCacheContext } from './GenericRedisCacher.ts';
import { GenericRedisCacher } from './GenericRedisCacher.ts';
export default class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
constructor(private readonly context: RedisCacheAdapterContext) {}
export class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
constructor(private readonly context: GenericRedisCacheContext) {}
getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return new RedisCacheAdapter(entityConfiguration, this.context);
getCacheAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
): IEntityCacheAdapter<TFields, TIDField> {
return new GenericEntityCacheAdapter(new GenericRedisCacher(this.context, entityConfiguration));
}
}
@@ -1,4 +1,4 @@
import { DateField } from '@expo/entity';
import { BufferField, DateField } from '@expo/entity';
export const redisTransformerMap = new Map([
[
@@ -22,4 +22,25 @@ export const redisTransformerMap = new Map([
read: (val: any) => (val ? new Date(val) : val),
},
],
[
BufferField.name,
{
/**
* All Redis fields are serialized to JSON before being written.
* In the case of buffers, they need to be explicitly reconstructed
* as JavaScript Buffer objects upon retrieval. We also explicitly
* serialize them to base64 strings to ensure no underlying Redis
* changes affect the fields.
*
* Write behavior: Value to write will always be either a Buffer or null.
* Behavior is to pass through null and convert buffers
* to base64 strings.
* Read behavior: Value will always be either null or a base64 string.
* behavior is to convert base64 strings back into Buffer
* objects and pass through null.
*/
write: (val: Buffer) => (val ? val.toString('base64') : null),
read: (val: any) => (val ? Buffer.from(val, 'base64') : val),
},
],
]);
@@ -0,0 +1,180 @@
import { Batcher } from '@expo/batcher';
import type { GenericEntityCacheAdapter } from '@expo/entity';
import { SingleFieldHolder, SingleFieldValueHolder, ViewerContext, zipToMap } from '@expo/entity';
import nullthrows from '@expo/nullthrows';
import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import type { GenericRedisCacheContext, IRedis, IRedisTransaction } from '../GenericRedisCacher.ts';
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
import type { RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity.ts';
import { RedisTestEntity } from '../__testfixtures__/RedisTestEntity.ts';
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
class BatchedRedis implements IRedis {
private readonly mgetBatcher = new Batcher<string[], (string | null)[]>(
this.batchMgetAsync.bind(this),
{
maxBatchInterval: 0,
},
);
constructor(private readonly redis: Redis) {}
private async batchMgetAsync(keySets: string[][]): Promise<(string | null)[][]> {
// ordered distinct keys to fetch
const allKeysToFetch = [...new Set(keySets.flat())];
// fetch the distinct keys
const allResults = await this.redis.mget(...allKeysToFetch);
// put them into a map for fast lookup
const keysToResults = zipToMap(allKeysToFetch, allResults);
// re-associate them with original key sets
return keySets.map((keySet) =>
keySet.map((key) => {
const result = keysToResults.get(key);
invariant(result !== undefined, 'result should not be undefined');
return result;
}),
);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
async mget(...args: string[]): Promise<(string | null)[]> {
return await this.mgetBatcher.batchAsync(args);
}
multi(): IRedisTransaction {
return this.redis.multi();
}
// eslint-disable-next-line @typescript-eslint/naming-convention
async del(...args: string[]): Promise<void> {
await this.redis.del(...args);
}
}
describe(GenericRedisCacher, () => {
const redis = new Redis(new URL(process.env['REDIS_URL']!).toString());
const redisClient = new BatchedRedis(redis);
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
genericRedisCacheContext = {
redisClient,
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'test-',
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
};
});
beforeEach(async () => {
await redis.flushdb();
});
afterAll(async () => {
redis.disconnect();
});
it('has correct caching behavior', async () => {
// simulate two requests
const viewerContext = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const mgetSpy = jest.spyOn(redis, 'mget');
const genericCacher = (
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
'tableDataCoordinator'
]['cacheAdapter'] as GenericEntityCacheAdapter<RedisTestEntityFields, 'id'>
)['genericCacher'];
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.createAsync();
// loading an entity should put it in cache. load by multiple requests and multiple fields in same tick to ensure batch works
mgetSpy.mockClear();
const viewerContext1 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const viewerContext2 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const viewerContext3 = new ViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const [entity1, entity2, entity3] = await Promise.all([
RedisTestEntity.loader(viewerContext1).loadByIDAsync(entity1Created.getID()),
RedisTestEntity.loader(viewerContext2).loadByIDAsync(entity1Created.getID()),
RedisTestEntity.loader(viewerContext3).loadByFieldEqualingAsync(
'name',
entity1Created.getField('name'),
),
]);
expect(mgetSpy).toHaveBeenCalledTimes(1);
expect(mgetSpy.mock.calls[0]).toHaveLength(2); // should dedupe the first two loads
expect(entity1.getID()).toEqual(entity2.getID());
expect(entity2.getID()).toEqual(nullthrows(entity3).getID());
const cacheKeyEntity1 = genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(entity1Created.getID()),
);
const cachedJSON = await redis.get(cacheKeyEntity1);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});
const cacheKeyEntity1NameField = genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('name'),
new SingleFieldValueHolder(entity1Created.getField('name')),
);
await RedisTestEntity.loader(viewerContext).loadByFieldEqualingAsync(
'name',
entity1Created.getField('name'),
);
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toEqual(cachedJSON);
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult =
await RedisTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
nonExistentId,
);
expect(entityNonExistentResult.ok).toBe(false);
const cacheKeyNonExistent = genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(nonExistentId),
);
const nonExistentCachedValue = await redis.get(cacheKeyNonExistent);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 =
await RedisTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
nonExistentId,
);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
entity1.getAllFields(),
);
await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
});
});
@@ -0,0 +1,228 @@
import type { GenericEntityCacheAdapter } from '@expo/entity';
import {
CompositeFieldHolder,
CompositeFieldValueHolder,
SingleFieldHolder,
SingleFieldValueHolder,
ViewerContext,
} from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { Redis } from 'ioredis';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
import type { RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity.ts';
import { RedisTestEntity } from '../__testfixtures__/RedisTestEntity.ts';
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'test-',
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
};
});
beforeEach(async () => {
await (genericRedisCacheContext.redisClient as Redis).flushdb();
});
afterAll(async () => {
(genericRedisCacheContext.redisClient as Redis).disconnect();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericCacher = (
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
'tableDataCoordinator'
]['cacheAdapter'] as GenericEntityCacheAdapter<RedisTestEntityFields, 'id'>
)['genericCacher'];
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', new Date())
.setField('bufferField', Buffer.from('hello world'))
.createAsync();
// loading an entity should put it in cache
const entity1 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
entity1Created.getID(),
);
const cachedSingleJSON = await (genericRedisCacheContext.redisClient as Redis).get(
genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(entity1.getID()),
),
);
const cachedSingleValue = JSON.parse(cachedSingleJSON!);
expect(cachedSingleValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});
// loading an entity by composite field should put it in cache
const entity2 = await RedisTestEntity.loader(viewerContext).loadByCompositeFieldEqualingAsync(
['name', 'id'],
{ name: 'blah', id: entity1.getID() },
);
const cachedCompositeJSON = await (genericRedisCacheContext.redisClient as Redis).get(
genericCacher.makeCacheKeyForStorage(
new CompositeFieldHolder<RedisTestEntityFields, 'id'>(['id', 'name']),
new CompositeFieldValueHolder({ id: entity2!.getID(), name: 'blah' }),
),
);
const cachedCompositeValue = JSON.parse(cachedCompositeJSON!);
expect(cachedCompositeValue).toMatchObject({
id: entity2!.getID(),
name: 'blah',
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult =
await RedisTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
nonExistentId,
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedValue = await (genericRedisCacheContext.redisClient as Redis).get(
genericCacher.makeCacheKeyForStorage(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(nonExistentId),
),
);
expect(nonExistentCachedValue).toEqual('');
const entityNonExistentCompositeResult = await RedisTestEntity.loader(
viewerContext,
).loadByCompositeFieldEqualingAsync(['name', 'id'], { name: 'blah', id: nonExistentId });
expect(entityNonExistentCompositeResult).toBe(null);
const nonExistentCompositeCachedValue = await (
genericRedisCacheContext.redisClient as Redis
).get(
genericCacher.makeCacheKeyForStorage(
new CompositeFieldHolder<RedisTestEntityFields, 'id'>(['id', 'name']),
new CompositeFieldValueHolder({ id: nonExistentId, name: 'blah' }),
),
);
expect(nonExistentCompositeCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 =
await RedisTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
nonExistentId,
);
expect(entityNonExistentResult2.ok).toBe(false);
const entityNonExistentCompositeResult2 = await RedisTestEntity.loader(
viewerContext,
).loadByCompositeFieldEqualingAsync(['name', 'id'], { name: 'blah', id: nonExistentId });
expect(entityNonExistentCompositeResult2).toBe(null);
// invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
entity1.getAllFields(),
);
const cachedValueNullKeys = genericCacher.makeCacheKeysForInvalidation(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(entity1.getID()),
);
const cachedValueNull = await (genericRedisCacheContext.redisClient as Redis).mget(
...cachedValueNullKeys,
);
expect(cachedValueNull.every((c) => c === null)).toBe(true);
const cachedValueNullCompositeKeys = genericCacher.makeCacheKeysForInvalidation(
new CompositeFieldHolder<RedisTestEntityFields, 'id'>(['id', 'name']),
new CompositeFieldValueHolder({ id: entity1.getID(), name: 'blah' }),
);
const cachedValueNullComposite = await (genericRedisCacheContext.redisClient as Redis).mget(
...cachedValueNullCompositeKeys,
);
expect(cachedValueNullComposite.every((c) => c === null)).toBe(true);
});
it('caches and restores date fields', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const date = new Date();
const entity1 = await enforceAsyncResult(
RedisTestEntity.creatorWithAuthorizationResults(viewerContext)
.setField('dateField', date)
.createAsync(),
);
expect(entity1.getField('dateField')).toEqual(date);
const entity2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(entity1.getID());
expect(entity2.getField('dateField')).toEqual(date);
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const entity3 = await RedisTestEntity.loader(vc2).loadByIDAsync(entity1.getID());
expect(entity3.getField('dateField')).toEqual(date);
});
it('caches and restores empty string field keys', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const entity1 = await enforceAsyncResult(
RedisTestEntity.creatorWithAuthorizationResults(viewerContext)
.setField('name', '')
.createAsync(),
);
const entity2 = await RedisTestEntity.loader(viewerContext).loadByFieldEqualingAsync(
'name',
'',
);
expect(entity2?.getID()).toEqual(entity1.getID());
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const entity3 = await RedisTestEntity.loader(vc2).loadByFieldEqualingAsync('name', '');
expect(entity3?.getID()).toEqual(entity1.getID());
});
it('caches and restores buffer fields', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const buffer = Buffer.from('hello world');
const entity1 = await enforceAsyncResult(
RedisTestEntity.creatorWithAuthorizationResults(viewerContext)
.setField('bufferField', buffer)
.createAsync(),
);
expect(entity1.getField('bufferField')).toEqual(buffer);
const entity2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(entity1.getID());
expect(entity2.getField('bufferField')).toEqual(buffer);
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const entity3 = await RedisTestEntity.loader(vc2).loadByIDAsync(entity1.getID());
expect(entity3.getField('bufferField')).toEqual(buffer);
});
});
@@ -0,0 +1,117 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { Redis } from 'ioredis';
import { URL } from 'url';
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
import type { RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity.ts';
import {
RedisTestEntity,
redisTestEntityConfiguration,
} from '../__testfixtures__/RedisTestEntity.ts';
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
genericRedisCacheContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'test-',
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
};
});
beforeEach(async () => {
await (genericRedisCacheContext.redisClient as Redis).flushdb();
});
afterAll(async () => {
(genericRedisCacheContext.redisClient as Redis).disconnect();
});
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration,
);
const date = new Date();
const buffer = Buffer.from('hello world');
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.setField('bufferField', buffer)
.createAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(testKey);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1Created.getID(),
dateField: date.toISOString(),
});
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult).toMatchObject({
status: CacheStatus.HIT,
item: entity1Created.getAllFields(),
});
expect(loadedObjectMap.size).toBe(1);
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration,
);
const testKey = `test-id-key-non-existent-id`;
await genericRedisCacher.cacheDBMissesAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericRedisCacher = new GenericRedisCacher(
genericRedisCacheContext,
redisTestEntityConfiguration,
);
const date = new Date();
const buffer = Buffer.from('hello world');
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.setField('bufferField', buffer)
.createAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
]);
await genericRedisCacher.cacheManyAsync(objectMap);
await genericRedisCacher.invalidateManyAsync([testKey]);
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.MISS);
});
});
@@ -1,111 +0,0 @@
import { ViewerContext } from '@expo/entity';
import { enforceAsyncResult } from '@expo/results';
import Redis from 'ioredis';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(RedisCacheAdapter, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env.REDIS_URL!).toString()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
});
it('has correct caching behavior', async () => {
const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const cacheAdapter = vc1.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const entity1Created = await RedisTestEntity.creator(vc1)
.setField('name', 'blah')
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await RedisTestEntity.loader(vc1)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cachedJSON = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', entity1.getID())
);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1.getID(),
name: 'blah',
});
// simulate non existent db fetch, should write negative result ('') to cache
const nonExistentId = uuidv4();
const entityNonExistentResult = await RedisTestEntity.loader(vc1).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedValue = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', nonExistentId)
);
expect(nonExistentCachedValue).toEqual('');
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await RedisTestEntity.loader(vc1).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.loader(vc1).invalidateFieldsAsync(entity1.getAllFields());
const cachedValueNull = await redisCacheAdapterContext.redisClient.get(
cacheKeyMaker('id', entity1.getID())
);
expect(cachedValueNull).toBe(null);
});
it('caches and restores date fields', async () => {
const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const date = new Date();
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(vc1).setField('dateField', date).createAsync()
);
expect(entity1.getField('dateField')).toEqual(date);
const entity2 = await RedisTestEntity.loader(vc1).enforcing().loadByIDAsync(entity1.getID());
expect(entity2.getField('dateField')).toEqual(date);
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const entity3 = await RedisTestEntity.loader(vc2).enforcing().loadByIDAsync(entity1.getID());
expect(entity3.getField('dateField')).toEqual(date);
});
});
@@ -0,0 +1,45 @@
import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { Redis } from 'ioredis';
import { URL } from 'url';
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
import { RedisTestEntity } from '../__testfixtures__/RedisTestEntity.ts';
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
genericRedisCacheContext = {
redisClient,
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'test-',
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
};
});
beforeEach(async () => {
await redisClient.flushdb();
});
it('throws when redis is disconnected', async () => {
redisClient.disconnect();
const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
await expect(
RedisTestEntity.creator(vc1).setField('name', 'blah').createAsync(),
).rejects.toThrow(EntityCacheAdapterTransientError);
});
});
@@ -0,0 +1,83 @@
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
AlwaysAllowPrivacyPolicyRule,
BufferField,
DateField,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
StringField,
UUIDField,
} from '@expo/entity';
export type RedisTestEntityFields = {
id: string;
name: string;
dateField: Date | null;
bufferField: Buffer | null;
};
export class RedisTestEntity extends Entity<RedisTestEntityFields, 'id', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
RedisTestEntityFields,
'id',
ViewerContext,
RedisTestEntity,
RedisTestEntityPrivacyPolicy
> {
return {
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
};
}
}
export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntityFields,
'id',
ViewerContext,
RedisTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<RedisTestEntityFields, 'id', ViewerContext, RedisTestEntity>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<RedisTestEntityFields, 'id', ViewerContext, RedisTestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<RedisTestEntityFields, 'id', ViewerContext, RedisTestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<RedisTestEntityFields, 'id', ViewerContext, RedisTestEntity>(),
];
}
export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEntityFields, 'id'>({
idField: 'id',
tableName: 'redis_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
cache: true,
}),
dateField: new DateField({
columnName: 'date_field',
}),
bufferField: new BufferField({
columnName: 'buffer_field',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
compositeFieldDefinitions: [
{ compositeField: ['id', 'name'], cache: true },
{ compositeField: ['id', 'dateField'], cache: true },
{ compositeField: ['id', 'bufferField'], cache: true },
],
});
@@ -0,0 +1,36 @@
import type { IEntityMetricsAdapter } from '@expo/entity';
import { EntityCompanionProvider, NoOpEntityMetricsAdapter } from '@expo/entity';
import { StubDatabaseAdapterProvider, StubQueryContextProvider } from '@expo/entity-testing-utils';
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
import { RedisCacheAdapterProvider } from '../RedisCacheAdapterProvider.ts';
// share across all in calls in test to simulate postgres
const adapterProvider = new StubDatabaseAdapterProvider();
const queryContextProvider = new StubQueryContextProvider();
export const createRedisIntegrationTestEntityCompanionProvider = (
genericRedisCacheContext: GenericRedisCacheContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider,
queryContextProvider,
},
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
],
]),
);
};
@@ -0,0 +1,428 @@
import {
CacheStatus,
CompositeFieldHolder,
CompositeFieldValueHolder,
EntityConfiguration,
SingleFieldHolder,
SingleFieldValueHolder,
StringField,
UUIDField,
} from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import type { Pipeline, Redis } from 'ioredis';
import { anything, instance, mock, verify, when } from 'ts-mockito';
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
type BlahFields = {
id: string;
fieldA: string;
fieldB: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields, 'id'>({
idField: 'id',
tableName: 'blah',
cacheKeyVersion: 2,
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
fieldA: new StringField({ columnName: 'field_a', cache: true }),
fieldB: new StringField({ columnName: 'field_b', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
describe(GenericRedisCacher, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null),
);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
},
entityConfiguration,
);
const cacheKeyWat = genericCacher['makeCacheKeyForStorage'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('wat'),
);
const cacheKeyWho = genericCacher['makeCacheKeyForStorage'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('who'),
);
const cacheKeyWhy = genericCacher['makeCacheKeyForStorage'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('why'),
);
redisResults.set(cacheKeyWat, JSON.stringify({ id: 'wat' }));
redisResults.set(cacheKeyWho, '');
const results = await genericCacher.loadManyAsync([cacheKeyWat, cacheKeyWho, cacheKeyWhy]);
expect(results.get(cacheKeyWat)).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat' },
});
expect(results.get(cacheKeyWho)).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get(cacheKeyWhy)).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
},
entityConfiguration,
);
const results = await genericCacher.loadManyAsync([]);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
},
);
when(mockPipeline.exec()).thenResolve([]);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
},
entityConfiguration,
);
const cacheKey = genericCacher['makeCacheKeyForStorage'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('wat'),
);
const value = { id: 'wat', fieldA: 'a', fieldB: 'b' };
await genericCacher.cacheManyAsync(new Map([[cacheKey, value]]));
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify(value),
code: 'EX',
ttl: 1,
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
},
);
when(mockPipeline.exec()).thenResolve([]);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
},
entityConfiguration,
);
const cacheKey = genericCacher['makeCacheKeyForStorage'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('wat'),
);
await genericCacher.cacheDBMissesAsync([cacheKey]);
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly with RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
},
entityConfiguration,
);
const cacheKeys = genericCacher['makeCacheKeysForInvalidation'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(1);
expect(cacheKeys[0]).toBe('hello-:single:blah:v4.2:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
});
it('does not collapse composite cache keys with delimiter-bearing parts', () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
cacheKeyPrefix: 'hello-',
cacheKeyDelimiter: ':',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
},
entityConfiguration,
);
const compositeFieldHolder = new CompositeFieldHolder<BlahFields, 'id'>(['fieldA', 'fieldB']);
const cacheKey1 = genericCacher['makeCacheKeyForStorage'](
compositeFieldHolder,
new CompositeFieldValueHolder({ fieldA: 'a:b', fieldB: 'c' }),
);
const cacheKey2 = genericCacher['makeCacheKeyForStorage'](
compositeFieldHolder,
new CompositeFieldValueHolder({ fieldA: 'a', fieldB: 'b:c' }),
);
expect(cacheKey1).not.toEqual(cacheKey2);
});
it('invalidates correctly with RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS,
},
},
entityConfiguration,
);
const cacheKeys = genericCacher['makeCacheKeysForInvalidation'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(3);
expect(cacheKeys[0]).toBe('hello-:single:blah:v4.1:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v4.2:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v4.3:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
});
it('invalidates correctly with RedisCacheInvalidationStrategy.CUSTOM', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CUSTOM,
cacheKeyVersionsToInvalidateFn(cacheKeyVersion) {
return [
cacheKeyVersion,
cacheKeyVersion + 1,
cacheKeyVersion + 2,
cacheKeyVersion + 3,
];
},
},
},
entityConfiguration,
);
const cacheKeys = genericCacher['makeCacheKeysForInvalidation'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(4);
expect(cacheKeys[0]).toBe('hello-:single:blah:v4.2:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v4.3:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v4.4:id:wat');
expect(cacheKeys[3]).toBe('hello-:single:blah:v4.5:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
});
it('returns when passed empty array of fieldValues', async () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
},
entityConfiguration,
);
await genericCacher.invalidateManyAsync([]);
});
it('throws when CUSTOM strategy returns an empty cacheKeyVersions list', () => {
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mock<Redis>()),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CUSTOM,
cacheKeyVersionsToInvalidateFn: () => [],
},
},
entityConfiguration,
);
expect(() =>
genericCacher['makeCacheKeysForInvalidation'](
new SingleFieldHolder('id'),
new SingleFieldValueHolder('wat'),
),
).toThrow(/empty list/);
});
});
describe('GenericRedisCacheContext validation', () => {
const validContext = {
redisClient: instance(mock<Redis>()),
cacheKeyDelimiter: ':',
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION as const,
},
};
it('throws when cacheKeyDelimiter is empty', () => {
expect(
() =>
new GenericRedisCacher({ ...validContext, cacheKeyDelimiter: '' }, entityConfiguration),
).toThrow(/cacheKeyDelimiter must be a non-empty string/);
});
it('throws when cacheKeyDelimiter contains the escape character', () => {
expect(
() =>
new GenericRedisCacher({ ...validContext, cacheKeyDelimiter: '\\' }, entityConfiguration),
).toThrow(/must not contain the escape character/);
});
it('throws when ttlSecondsPositive is not a positive integer', () => {
expect(
() =>
new GenericRedisCacher({ ...validContext, ttlSecondsPositive: 0 }, entityConfiguration),
).toThrow(/ttlSecondsPositive must be a positive integer/);
expect(
() =>
new GenericRedisCacher({ ...validContext, ttlSecondsPositive: -1 }, entityConfiguration),
).toThrow(/ttlSecondsPositive must be a positive integer/);
expect(
() =>
new GenericRedisCacher({ ...validContext, ttlSecondsPositive: 1.5 }, entityConfiguration),
).toThrow(/ttlSecondsPositive must be a positive integer/);
});
it('throws when ttlSecondsNegative is not a positive integer', () => {
expect(
() =>
new GenericRedisCacher({ ...validContext, ttlSecondsNegative: 0 }, entityConfiguration),
).toThrow(/ttlSecondsNegative must be a positive integer/);
expect(
() =>
new GenericRedisCacher({ ...validContext, ttlSecondsNegative: -1 }, entityConfiguration),
).toThrow(/ttlSecondsNegative must be a positive integer/);
expect(
() =>
new GenericRedisCacher({ ...validContext, ttlSecondsNegative: 1.5 }, entityConfiguration),
).toThrow(/ttlSecondsNegative must be a positive integer/);
});
it('accepts a valid context', () => {
expect(() => new GenericRedisCacher(validContext, entityConfiguration)).not.toThrow();
});
});
});
@@ -1,175 +0,0 @@
import {
CacheStatus,
UUIDField,
EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
} from '@expo/entity';
import { Redis, Pipeline } from 'ioredis';
import { mock, when, instance, anything, verify } from 'ts-mockito';
import RedisCacheAdapter from '../RedisCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
});
describe(RedisCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const redisResults = new Map();
const mockRedisClient = mock<Redis>();
// need to have one anything() for each element of spread, in this case 3
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
keys.map((k) => redisResults.get(k) ?? null)
);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
redisResults.set(cacheAdapter['makeCacheKey']('id', 'wat'), JSON.stringify({ id: 'wat' }));
redisResults.set(cacheAdapter['makeCacheKey']('id', 'who'), '');
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
expect(results.get('wat')).toMatchObject({ status: CacheStatus.HIT, item: { id: 'wat' } });
expect(results.get('who')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('why')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: JSON.stringify({ id: 'wat' }),
code: 'EX',
ttl: 1,
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const redisResults = new Map();
const mockPipeline = mock<Pipeline>();
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
(key, value, code, ttl) => {
redisResults.set(key, { value, code, ttl });
return pipeline;
}
);
when(mockPipeline.exec()).thenResolve({} as any);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
expect(redisResults.get(cacheKey)).toMatchObject({
value: '',
code: 'EX',
ttl: 2,
});
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const mockRedisClient = mock<Redis>();
when(mockRedisClient.del()).thenResolve(1);
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.invalidateManyAsync('id', ['wat']);
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
verify(mockRedisClient.del(cacheKey)).once();
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
redisClient: instance(mock<Redis>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyVersion: 1,
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
});
await cacheAdapter.invalidateManyAsync('id', []);
});
});
});
@@ -0,0 +1,40 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { wrapNativeRedisCallAsync } from '../wrapNativeRedisCallAsync.ts';
describe(wrapNativeRedisCallAsync, () => {
it('rethrows literals', async () => {
const throwingFn = async (): Promise<void> => {
// eslint-disable-next-line no-throw-literal,@typescript-eslint/only-throw-error
throw 'hello';
};
let capturedThrownThing: any;
try {
await wrapNativeRedisCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).not.toBeInstanceOf(Error);
expect(capturedThrownThing).toEqual('hello');
});
it('wraps errors with stacks', async () => {
const throwingFn = async (): Promise<void> => {
const e = new Error('hello');
e.stack = 'world';
throw e;
};
let capturedThrownThing: any;
try {
await wrapNativeRedisCallAsync(throwingFn);
} catch (e) {
capturedThrownThing = e;
}
expect(capturedThrownThing).toBeInstanceOf(EntityCacheAdapterTransientError);
expect(capturedThrownThing.message).toEqual('hello');
expect(capturedThrownThing.stack).toEqual('world');
});
});
@@ -0,0 +1,17 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
export async function wrapNativeRedisCallAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
const error = new EntityCacheAdapterTransientError(e.message, e);
if (e.stack) {
error.stack = e.stack;
}
throw error;
}
}
@@ -1,8 +1,11 @@
/* eslint-disable tsdoc/syntax */
/**
* Automatically generated by barrelsby.
* @packageDocumentation
* @module @expo/entity-cache-adapter-redis
*/
export { default as RedisCacheAdapter } from './RedisCacheAdapter';
export * from './RedisCacheAdapter';
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
export * from './RedisCommon';
export * from './GenericRedisCacher.ts';
export * from './RedisCacheAdapterProvider.ts';
export * from './RedisCommon.ts';
export * from './errors/wrapNativeRedisCallAsync.ts';
export * from './utils/getSurroundingCacheKeyVersionsForInvalidation.ts';
@@ -1,96 +0,0 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
DateField,
StringField,
EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition,
Entity,
} from '@expo/entity';
export type RedisTestEntityFields = {
id: string;
name: string;
dateField: Date | null;
};
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity,
RedisTestEntityPrivacyPolicy
> {
return redisTestEntityCompanionDefinition;
}
}
class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
> {
protected readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
}
export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEntityFields>({
idField: 'id',
tableName: 'redis_test_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
dateField: new DateField({
columnName: 'date_field',
}),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
});
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
});
@@ -1,32 +0,0 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';
export const createRedisIntegrationTestEntityCompanionProvider = (
redisCacheAdapterContext: RedisCacheAdapterContext,
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
{
[DatabaseAdapterFlavor.POSTGRES]: {
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
},
{
[CacheAdapterFlavor.REDIS]: {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
},
}
);
};
@@ -0,0 +1,11 @@
import { describe, expect, it } from '@jest/globals';
import { getSurroundingCacheKeyVersionsForInvalidation } from '../getSurroundingCacheKeyVersionsForInvalidation.ts';
describe(getSurroundingCacheKeyVersionsForInvalidation, () => {
it('returns the correct cache key versions to invalidate', () => {
expect(getSurroundingCacheKeyVersionsForInvalidation(0)).toEqual([0, 1]);
expect(getSurroundingCacheKeyVersionsForInvalidation(1)).toEqual([0, 1, 2]);
expect(getSurroundingCacheKeyVersionsForInvalidation(2)).toEqual([1, 2, 3]);
});
});
@@ -0,0 +1,9 @@
export function getSurroundingCacheKeyVersionsForInvalidation(
cacheKeyVersion: number,
): readonly number[] {
return [
...(cacheKeyVersion === 0 ? [] : [cacheKeyVersion - 1]),
cacheKeyVersion,
cacheKeyVersion + 1,
];
}
@@ -1,7 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
},
"include": ["src"]
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"references": [{ "path": "../entity" }, { "path": "../entity-testing-utils" }]
}
+117
View File
@@ -0,0 +1,117 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.65.0](https://github.com/expo/entity/compare/v0.64.0...v0.65.0) (2026-07-10)
**Note:** Version bump only for package @expo/entity-codemod
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
**Note:** Version bump only for package @expo/entity-codemod
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
**Note:** Version bump only for package @expo/entity-codemod
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
**Note:** Version bump only for package @expo/entity-codemod
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
**Note:** Version bump only for package @expo/entity-codemod
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-codemod
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-codemod
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
### Features
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-codemod
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
### Bug Fixes
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
**Note:** Version bump only for package @expo/entity-codemod
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
**Note:** Version bump only for package @expo/entity-codemod
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
**Note:** Version bump only for package @expo/entity-codemod
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
**Note:** Version bump only for package @expo/entity-codemod
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
**Note:** Version bump only for package @expo/entity-codemod
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
**Note:** Version bump only for package @expo/entity-codemod
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
**Note:** Version bump only for package @expo/entity-codemod
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
**Note:** Version bump only for package @expo/entity-codemod
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
**Note:** Version bump only for package @expo/entity-codemod
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
**Note:** Version bump only for package @expo/entity-codemod
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
**Note:** Version bump only for package @expo/entity-codemod
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
**Note:** Version bump only for package @expo/entity-codemod
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
### Features
- Create codemod for v0.40.0 -> v0.41.0 ([#260](https://github.com/expo/entity/issues/260)) ([4a2b61f](https://github.com/expo/entity/commit/4a2b61f2bd8edfc19109ef4c8ac780eaf0f7795c))
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
### Features
- Create codemod for v0.39.0 -> v0.40.0 ([#259](https://github.com/expo/entity/issues/259)) ([d63ddd6](https://github.com/expo/entity/commit/d63ddd65997a441a914a71b158a111339970aeab))
### Reverts
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
+27
View File
@@ -0,0 +1,27 @@
# @expo/entity-codemod
A package containing jscodeshift codemods for @expo/entity upgrades.
Codemods are transformations that run on your codebase programmatically. This allows for a large amount of changes to be applied without having to manually go through every file.
## Usage
These should be used via the [jscodeshift CLI](https://github.com/facebook/jscodeshift?tab=readme-ov-file#usage-cli).
For example:
```
yarn add -D @expo/entity-codemod
```
Then, in package.json scripts:
```json
"jscodeshift": "jscodeshift --extensions=ts --parser=ts"
```
And finally:
```sh
yarn jscodeshift src -t node_modules/@expo/entity-codemod/build/v0.39.0-v0.40.0.js
```
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@expo/entity-codemod",
"version": "0.65.0",
"description": "jscodeshift codemods for @expo/entity upgrades",
"keywords": [
"entity"
],
"license": "MIT",
"author": "Expo",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"type": "module",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level oxlint --type-aware src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
},
"devDependencies": {
"@jest/globals": "30.4.1",
"@types/jscodeshift": "17.3.0",
"@types/node": "24.13.2",
"jscodeshift": "17.3.0",
"typescript": "6.0.3"
},
"engines": {
"node": ">=18"
}
}
Whitespace-only changes.
@@ -0,0 +1,79 @@
/* eslint-disable */
import { ViewerContext } from '@expo/entity';
async function testWithAuthorizationResults(viewerContext: ViewerContext): Promise<void> {
// creator
const entityResult = await TestEntity.creator(viewerContext).setField('wat', 2).createAsync();
// updater
const updatedEntityResult = await TestEntity.updater(entityResult.enforceValue()).setField('wat', 3).updateAsync();
// deleter
await TestEntity.deleteAsync(updatedEntityResult.enforceValue());
}
async function testEnforcing(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext).setField('wat', 2).enforceCreateAsync();
const entity2 = await TestEntity.creator(viewerContext).setField('wat', 2).setField('who', 3).enforceCreateAsync();
const entity3 = await TestEntity.creator(viewerContext)
.setField('wat', 2)
.setField('who', 3)
.setField('who', 4)
.setField('who', 5)
.setField('who', 6)
.setField('who', 7)
.setField('who', 8)
.setField('who', 9)
.enforceCreateAsync();
// updater
const updatedEntity = await TestEntity.updater(entity).setField('wat', 3).enforceUpdateAsync();
const updatedEntity2 = await TestEntity.updater(entity2).setField('wat', 3).setField('who', 4).enforceUpdateAsync();
const updatedEntity3 = await TestEntity.updater(entity3)
.setField('wat', 3)
.setField('who', 4)
.setField('who', 5)
.setField('who', 6)
.setField('who', 7)
.setField('who', 8)
.setField('who', 9)
.enforceUpdateAsync();
// deleter
await TestEntity.enforceDeleteAsync(entity);
}
async function testNoSetField(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext).enforceCreateAsync();
// updater
const updatedEntity = await TestEntity.updater(entity).enforceUpdateAsync();
}
async function testEnforcingWithQueryContext(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext, queryContext).setField('wat', 2).enforceCreateAsync();
// updater
const updatedEntity = await TestEntity.updater(entity, queryContext).setField('wat', 3).enforceUpdateAsync();
// deleter
await TestEntity.enforceDeleteAsync(entity, queryContext);
}
async function testAssociationLoader(): Promise<void> {
await this.associationLoader().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
);
await entity.associationLoader().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
)
}
@@ -0,0 +1,79 @@
/* eslint-disable */
import { ViewerContext } from '@expo/entity';
async function testWithAuthorizationResults(viewerContext: ViewerContext): Promise<void> {
// creator
const entityResult = await TestEntity.creator(viewerContext).withAuthorizationResults().setField('wat', 2).createAsync();
// updater
const updatedEntityResult = await TestEntity.updater(entityResult.enforceValue()).withAuthorizationResults().setField('wat', 3).updateAsync();
// deleter
await TestEntity.deleter(updatedEntityResult.enforceValue()).withAuthorizationResults().deleteAsync();
}
async function testEnforcing(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext).enforcing().setField('wat', 2).createAsync();
const entity2 = await TestEntity.creator(viewerContext).enforcing().setField('wat', 2).setField('who', 3).createAsync();
const entity3 = await TestEntity.creator(viewerContext).enforcing()
.setField('wat', 2)
.setField('who', 3)
.setField('who', 4)
.setField('who', 5)
.setField('who', 6)
.setField('who', 7)
.setField('who', 8)
.setField('who', 9)
.createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity).enforcing().setField('wat', 3).updateAsync();
const updatedEntity2 = await TestEntity.updater(entity2).enforcing().setField('wat', 3).setField('who', 4).updateAsync();
const updatedEntity3 = await TestEntity.updater(entity3).enforcing()
.setField('wat', 3)
.setField('who', 4)
.setField('who', 5)
.setField('who', 6)
.setField('who', 7)
.setField('who', 8)
.setField('who', 9)
.updateAsync();
// deleter
await TestEntity.deleter(entity).enforcing().deleteAsync();
}
async function testNoSetField(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext).enforcing().createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity).enforcing().updateAsync();
}
async function testEnforcingWithQueryContext(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext, queryContext).enforcing().setField('wat', 2).createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity, queryContext).enforcing().setField('wat', 3).updateAsync();
// deleter
await TestEntity.deleter(entity, queryContext).enforcing().deleteAsync();
}
async function testAssociationLoader(): Promise<void> {
await this.associationLoader().withAuthorizationResults().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
);
await entity.associationLoader().withAuthorizationResults().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
)
}
@@ -0,0 +1,10 @@
/* eslint-disable */
async function testAsync(viewerContext: ViewerContext): Promise<void> {
const testTrigger = await TestTriggerEntity.createAsync(actorViewerContext, undefined, {
appId,
});
await TestTriggerEntity.enforceDeleteAsync(testTrigger);
return testTrigger;
};
@@ -0,0 +1,10 @@
/* eslint-disable */
async function testAsync(viewerContext: ViewerContext): Promise<void> {
const testTrigger = await TestTriggerEntity.createAsync(actorViewerContext, undefined, {
appId,
});
await TestTriggerEntity.deleter(testTrigger).enforcing().deleteAsync();
return testTrigger;
};
@@ -0,0 +1,5 @@
/* eslint-disable */
async function anotherCase(updater: any): Promise<any> {
return await updater.enforceUpdateAsync();
}
@@ -0,0 +1,5 @@
/* eslint-disable */
async function anotherCase(updater: any): Promise<any> {
return await updater.enforceUpdateAsync();
}
@@ -0,0 +1,100 @@
/* eslint-disable */
import { ViewerContext } from '@expo/entity';
async function testWithAuthorizationResults(viewerContext: ViewerContext): Promise<void> {
// creator
const entityResult = await TestEntity.creator(viewerContext).withAuthorizationResults().setField('wat', 2).createAsync();
// updater
const updatedEntityResult = await TestEntity.updater(entityResult.enforceValue()).withAuthorizationResults().setField('wat', 3).updateAsync();
// loader
const loadedEntityResult = await TestEntity.loader(viewerContext).withAuthorizationResults().loadByIDAsync('test');
// deleter
await TestEntity.deleter(updatedEntityResult.enforceValue()).withAuthorizationResults().deleteAsync();
}
async function testEnforcing(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext).enforcing().setField('wat', 2).createAsync();
const entity2 = await TestEntity.creator(viewerContext).enforcing().setField('wat', 2).setField('who', 3).createAsync();
const entity3 = await TestEntity.creator(viewerContext).enforcing()
.setField('wat', 2)
.setField('who', 3)
.setField('who', 4)
.setField('who', 5)
.setField('who', 6)
.setField('who', 7)
.setField('who', 8)
.setField('who', 9)
.createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity).enforcing().setField('wat', 3).updateAsync();
const updatedEntity2 = await TestEntity.updater(entity2).enforcing().setField('wat', 3).setField('who', 4).updateAsync();
const updatedEntity3 = await TestEntity.updater(entity3).enforcing()
.setField('wat', 3)
.setField('who', 4)
.setField('who', 5)
.setField('who', 6)
.setField('who', 7)
.setField('who', 8)
.setField('who', 9)
.updateAsync();
// loader
const loadedEntity = await TestEntity.loader(viewerContext).enforcing().loadByIDAsync('test');
// deleter
await TestEntity.deleter(entity).enforcing().deleteAsync();
}
async function testNoSetField(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext).enforcing().createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity).enforcing().updateAsync();
}
async function testEnforcingWithQueryContext(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext, queryContext).enforcing().setField('wat', 2).createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity, queryContext).enforcing().setField('wat', 3).updateAsync();
// loader
const loadedEntity = await TestEntity.loader(viewerContext, queryContext).enforcing().loadByIDAsync('test');
// deleter
await TestEntity.deleter(entity, queryContext).enforcing().deleteAsync();
}
async function testAssociationLoader(): Promise<void> {
await this.associationLoader().withAuthorizationResults().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
);
await entity.associationLoader().withAuthorizationResults().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
)
await this.associationLoader().enforcing().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
);
await entity.associationLoader().enforcing().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
)
}
@@ -0,0 +1,100 @@
/* eslint-disable */
import { ViewerContext } from '@expo/entity';
async function testWithAuthorizationResults(viewerContext: ViewerContext): Promise<void> {
// creator
const entityResult = await TestEntity.creatorWithAuthorizationResults(viewerContext).setField('wat', 2).createAsync();
// updater
const updatedEntityResult = await TestEntity.updaterWithAuthorizationResults(entityResult.enforceValue()).setField('wat', 3).updateAsync();
// loader
const loadedEntityResult = await TestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync('test');
// deleter
await TestEntity.deleterWithAuthorizationResults(updatedEntityResult.enforceValue()).deleteAsync();
}
async function testEnforcing(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext).setField('wat', 2).createAsync();
const entity2 = await TestEntity.creator(viewerContext).setField('wat', 2).setField('who', 3).createAsync();
const entity3 = await TestEntity.creator(viewerContext)
.setField('wat', 2)
.setField('who', 3)
.setField('who', 4)
.setField('who', 5)
.setField('who', 6)
.setField('who', 7)
.setField('who', 8)
.setField('who', 9)
.createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity).setField('wat', 3).updateAsync();
const updatedEntity2 = await TestEntity.updater(entity2).setField('wat', 3).setField('who', 4).updateAsync();
const updatedEntity3 = await TestEntity.updater(entity3)
.setField('wat', 3)
.setField('who', 4)
.setField('who', 5)
.setField('who', 6)
.setField('who', 7)
.setField('who', 8)
.setField('who', 9)
.updateAsync();
// loader
const loadedEntity = await TestEntity.loader(viewerContext).loadByIDAsync('test');
// deleter
await TestEntity.deleter(entity).deleteAsync();
}
async function testNoSetField(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext).createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity).updateAsync();
}
async function testEnforcingWithQueryContext(viewerContext: ViewerContext): Promise<void> {
// creator
const entity = await TestEntity.creator(viewerContext, queryContext).setField('wat', 2).createAsync();
// updater
const updatedEntity = await TestEntity.updater(entity, queryContext).setField('wat', 3).updateAsync();
// loader
const loadedEntity = await TestEntity.loader(viewerContext, queryContext).loadByIDAsync('test');
// deleter
await TestEntity.deleter(entity, queryContext).deleteAsync();
}
async function testAssociationLoader(): Promise<void> {
await this.associationLoaderWithAuthorizationResults().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
);
await entity.associationLoaderWithAuthorizationResults().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
)
await this.associationLoader().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
);
await entity.associationLoader().loadAssociatedEntityAsync(
'another_id',
AnotherEntity,
queryContext
)
}
@@ -0,0 +1,37 @@
import { ViewerContext } from '@expo/entity';
import { PostEntity } from './entities/PostEntity.ts';
import { UserEntity } from './entities/UserEntity.ts';
async function loadUser(viewerContext: ViewerContext) {
// Basic loader calls - only transformed when using knex-specific methods
const userLoader = UserEntity.loader(viewerContext);
const postLoader = PostEntity.loader(viewerContext);
// These use knex-specific methods, so they should be transformed
const posts = await postLoader.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'status', fieldValue: 'published' }
]);
const firstPost = await postLoader.loadFirstByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: '123' }
]);
// Loader with authorization results - only transformed when using knex methods
const userLoaderWithAuth = UserEntity.loaderWithAuthorizationResults(viewerContext);
const rawResults = await userLoaderWithAuth.loadManyByRawWhereClauseAsync('age > ?', [18]);
// Loader that doesn't use knex methods - should NOT be transformed
const standardLoader = PostEntity.loader(viewerContext);
const post = await standardLoader.loadByIDAsync('456');
// Should not transform instance methods or other properties
const user = await userLoader.loadByIDAsync('123');
const userLoadMethod = user.loader; // This should not be transformed
// Should not transform lowercase object methods
const customLoader = {
loader: (ctx: any) => ctx,
};
customLoader.loader(viewerContext);
return user;
}
@@ -0,0 +1,37 @@
import { ViewerContext } from '@expo/entity';
import { PostEntity } from './entities/PostEntity.ts';
import { UserEntity } from './entities/UserEntity.ts';
async function loadUser(viewerContext: ViewerContext) {
// Basic loader calls - only transformed when using knex-specific methods
const userLoader = UserEntity.loader(viewerContext);
const postLoader = PostEntity.knexLoader(viewerContext);
// These use knex-specific methods, so they should be transformed
const posts = await postLoader.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'status', fieldValue: 'published' }
]);
const firstPost = await postLoader.loadFirstByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: '123' }
]);
// Loader with authorization results - only transformed when using knex methods
const userLoaderWithAuth = UserEntity.knexLoaderWithAuthorizationResults(viewerContext);
const rawResults = await userLoaderWithAuth.loadManyByRawWhereClauseAsync('age > ?', [18]);
// Loader that doesn't use knex methods - should NOT be transformed
const standardLoader = PostEntity.loader(viewerContext);
const post = await standardLoader.loadByIDAsync('456');
// Should not transform instance methods or other properties
const user = await userLoader.loadByIDAsync('123');
const userLoadMethod = user.loader; // This should not be transformed
// Should not transform lowercase object methods
const customLoader = {
loader: (ctx: any) => ctx,
};
customLoader.loader(viewerContext);
return user;
}
@@ -0,0 +1,33 @@
import { ViewerContext } from '@expo/entity';
import { CommentEntity } from './entities/CommentEntity.ts';
// Chained calls
const loadComments = async (viewerContext: ViewerContext) => {
// Direct chaining with knex-specific method
const comments = await CommentEntity.loader(viewerContext)
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'postId', fieldValue: '123' }
]);
// Direct chaining with regular method - should NOT be transformed
const singleComment = await CommentEntity
.loader(viewerContext)
.loadByIDAsync('456');
// With authorization results and knex method
const commentsWithAuth = await CommentEntity
.loaderWithAuthorizationResults(viewerContext)
.loadManyByRawWhereClauseAsync('postId = ?', ['456']);
// Edge cases - these should NOT be transformed
const anotherEntity = {
loader: (ctx: any) => ctx, // This is not an entity class
};
anotherEntity.loader(viewerContext); // Should NOT be transformed (lowercase object)
// Complex chaining with regular method - should NOT be transformed
return CommentEntity
.loader(viewerContext)
.withAuthenticationResults()
.loadByIDAsync('789');
};
@@ -0,0 +1,33 @@
import { ViewerContext } from '@expo/entity';
import { CommentEntity } from './entities/CommentEntity.ts';
// Chained calls
const loadComments = async (viewerContext: ViewerContext) => {
// Direct chaining with knex-specific method
const comments = await CommentEntity.knexLoader(viewerContext)
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'postId', fieldValue: '123' }
]);
// Direct chaining with regular method - should NOT be transformed
const singleComment = await CommentEntity
.loader(viewerContext)
.loadByIDAsync('456');
// With authorization results and knex method
const commentsWithAuth = await CommentEntity
.knexLoaderWithAuthorizationResults(viewerContext)
.loadManyByRawWhereClauseAsync('postId = ?', ['456']);
// Edge cases - these should NOT be transformed
const anotherEntity = {
loader: (ctx: any) => ctx, // This is not an entity class
};
anotherEntity.loader(viewerContext); // Should NOT be transformed (lowercase object)
// Complex chaining with regular method - should NOT be transformed
return CommentEntity
.loader(viewerContext)
.withAuthenticationResults()
.loadByIDAsync('789');
};
@@ -0,0 +1,28 @@
import { readFile, readdir } from 'fs/promises';
import { defineInlineTest } from 'jscodeshift/src/testUtils.js';
async function readfixtureAsync(mod: string, name: string): Promise<string> {
return await readFile(new URL(`../__testfixtures__/${mod}/${name}`, import.meta.url), {
encoding: 'utf8',
});
}
function prefixes(suffix: string): (names: string[]) => string[] {
return (names) => names.filter((n) => n.endsWith(suffix)).map((n) => n.replace(suffix, ''));
}
const modules = await readdir(new URL('../', import.meta.url)).then(prefixes('.ts'));
for (const mod of modules) {
const transform = await import(`../${mod}.ts`);
const fixtureDir = new URL(`../__testfixtures__/${mod}`, import.meta.url);
const fixtures = await readdir(fixtureDir).then(prefixes('.input.ts'));
for (const fixture of fixtures) {
defineInlineTest(
transform,
{},
await readfixtureAsync(mod, `${fixture}.input.ts`),
await readfixtureAsync(mod, `${fixture}.output.ts`),
);
}
}
@@ -0,0 +1,213 @@
import type { API, Collection, FileInfo, Options } from 'jscodeshift';
function transformEnforcingEntityChainMethod(
j: API['jscodeshift'],
root: Collection<any>,
type: 'enforcing' | 'withAuthorizationResults',
crudType: 'creator' | 'updater',
methodBefore: string,
methodAfter: string,
): void {
// Find all entity creator expressions of the form
// `TestEntity.creator(viewerContext).setField('wat', 2).enforceCreateAsync()`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: methodBefore,
},
},
})
.forEach((path) => {
const enforceCreateAsyncCallExpression = path.node; // TestEntity.creator(viewerContext).setField('wat', 2).enforceCreateAsync()
const enforceCreateAsyncCallee = enforceCreateAsyncCallExpression.callee; // TestEntity.creator(viewerContext).setField('wat', 2).enforceCreateAsync
if (enforceCreateAsyncCallee.type !== 'MemberExpression') {
return;
}
if (enforceCreateAsyncCallee.property.type !== 'Identifier') {
return;
}
// traverse in until we find the first non-setField call
let lastCallee = enforceCreateAsyncCallee;
while (true) {
// TestEntity.creator(viewerContext).setField('wat', 2)
const maybeSetFieldObjectCallExpression = lastCallee.object;
if (maybeSetFieldObjectCallExpression.type !== 'CallExpression') {
break;
}
// TestEntity.creator(viewerContext).setField
const maybeSetFieldObjectCallee = maybeSetFieldObjectCallExpression.callee;
if (maybeSetFieldObjectCallee.type !== 'MemberExpression') {
break;
}
const maybeSetFieldObjectCalleeProperty = maybeSetFieldObjectCallee.property;
if (maybeSetFieldObjectCalleeProperty.type !== 'Identifier') {
break;
}
if (maybeSetFieldObjectCalleeProperty.name === 'setField') {
lastCallee = maybeSetFieldObjectCallee;
} else {
break;
}
}
// TestEntity.creator(viewerContext)
const maybeCreatorCallExpression = lastCallee.object;
if (maybeCreatorCallExpression.type !== 'CallExpression') {
return;
}
if (
maybeCreatorCallExpression.callee.type !== 'MemberExpression' ||
maybeCreatorCallExpression.callee.property.type !== 'Identifier' ||
maybeCreatorCallExpression.callee.property.name !== crudType
) {
return;
}
// TestEntity.creator(viewerContext).enforcing()
const newCreatorCallExpression = j.callExpression(
j.memberExpression(maybeCreatorCallExpression, j.identifier(type)),
[],
);
// TestEntity.creator(viewerContext).enforcing().setField('wat', 2).createAsync()
lastCallee.object = newCreatorCallExpression;
// change enforceCreateAsync to createAsync at the end so if this returns early it doesn't mutate
enforceCreateAsyncCallee.property.name = methodAfter;
});
}
function transformEnforcingEntityDeleteMethod(
j: API['jscodeshift'],
root: Collection<any>,
type: 'enforcing' | 'withAuthorizationResults',
methodBefore: string,
methodAfter: string,
): void {
// Find all entity creator expressions of the form
// `TestEntity.enforceDeleteAsync(entity)`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: methodBefore,
},
},
})
.forEach((path) => {
const enforceDeleteAsyncCallExpression = path.node; // TestEntity.enforceDeleteAsync(entity)
const args = enforceDeleteAsyncCallExpression.arguments; // [entity]
enforceDeleteAsyncCallExpression.arguments = [];
const enforceDeleteAsyncCallee = enforceDeleteAsyncCallExpression.callee; // TestEntity.enforceDeleteAsync
if (enforceDeleteAsyncCallee.type !== 'MemberExpression') {
return;
}
// change enforceDeleteAsync to deleteAsync
if (enforceDeleteAsyncCallee.property.type !== 'Identifier') {
return;
}
enforceDeleteAsyncCallee.property.name = methodAfter;
enforceDeleteAsyncCallee.object = j.callExpression(
j.memberExpression(
j.callExpression(
j.memberExpression(enforceDeleteAsyncCallee.object, j.identifier('deleter')),
args,
),
j.identifier(type),
),
[],
);
});
}
function transformAssociationLoaderMethod(j: API['jscodeshift'], root: Collection<any>): void {
// Find all entity associationLoader expressions of the form
// `this.associationLoader()`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: 'associationLoader',
},
},
})
.forEach((path) => {
const associationLoaderCallExpression = path.node; // this.associationLoader()
// replace with this.associationLoader().withAuthorizationResults()
path.replace(
j.callExpression(
j.memberExpression(
associationLoaderCallExpression,
j.identifier('withAuthorizationResults'),
),
[],
),
);
});
}
export default function transformer(file: FileInfo, api: API, _options: Options): string {
const j = api.jscodeshift;
const root = j.withParser('ts')(file.source);
// do authorization results first since it uses the same detection pattern as enforcing post-transform (i.e. it looks for createAsync)
transformEnforcingEntityChainMethod(
j,
root,
'withAuthorizationResults',
'creator',
'createAsync',
'createAsync',
);
transformEnforcingEntityChainMethod(
j,
root,
'withAuthorizationResults',
'updater',
'updateAsync',
'updateAsync',
);
transformEnforcingEntityDeleteMethod(
j,
root,
'withAuthorizationResults',
'deleteAsync',
'deleteAsync',
);
// now do enforcing
transformEnforcingEntityChainMethod(
j,
root,
'enforcing',
'creator',
'enforceCreateAsync',
'createAsync',
);
transformEnforcingEntityChainMethod(
j,
root,
'enforcing',
'updater',
'enforceUpdateAsync',
'updateAsync',
);
transformEnforcingEntityDeleteMethod(j, root, 'enforcing', 'enforceDeleteAsync', 'deleteAsync');
// association loader
transformAssociationLoaderMethod(j, root);
return root.toSource();
}
@@ -0,0 +1,81 @@
import type { API, Collection, FileInfo, Options } from 'jscodeshift';
function transformWithAuthorizationResultsEntityChainMethod(
j: API['jscodeshift'],
root: Collection<any>,
): void {
// Find all entity expressions of the form
// `TestEntity.thing(viewerContext).withAuthorizationResults()`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: 'withAuthorizationResults',
},
},
})
.forEach((path) => {
const withAuthorizationResultsCallExpression = path.node; // TestEntity.thing(viewerContext).withAuthorizationResults()
const withAuthorizationResultsCallee = withAuthorizationResultsCallExpression.callee; // TestEntity.thing(viewerContext).withAuthorizationResults
if (withAuthorizationResultsCallee.type !== 'MemberExpression') {
return;
}
const thingCallExpression = withAuthorizationResultsCallee.object; // TestEntity.thing(viewerContext)
if (thingCallExpression.type !== 'CallExpression') {
return;
}
const thingCallee = thingCallExpression.callee; // TestEntity.thing
if (thingCallee.type !== 'MemberExpression') {
return;
}
if (thingCallee.property.type !== 'Identifier') {
return;
}
const newCreatorCalleeName = thingCallee.property.name + 'WithAuthorizationResults';
thingCallee.property.name = newCreatorCalleeName;
path.replace(thingCallExpression);
});
}
function transformEnforcingEntityChainMethod(j: API['jscodeshift'], root: Collection<any>): void {
// Find all entity expressions of the form
// `TestEntity.thing(viewerContext).enforcing()`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: 'enforcing',
},
},
})
.forEach((path) => {
const enforcingCallExpression = path.node; // TestEntity.thing(viewerContext).enforcing()
const enforcingCallee = enforcingCallExpression.callee; // TestEntity.thing(viewerContext).enforcing
if (enforcingCallee.type !== 'MemberExpression') {
return;
}
const thingCallExpression = enforcingCallee.object; // TestEntity.thing(viewerContext)
if (thingCallExpression.type !== 'CallExpression') {
return;
}
path.replace(thingCallExpression);
});
}
export default function transformer(file: FileInfo, api: API, _options: Options): string {
const j = api.jscodeshift;
const root = j.withParser('ts')(file.source);
transformWithAuthorizationResultsEntityChainMethod(j, root);
transformEnforcingEntityChainMethod(j, root);
return root.toSource();
}
@@ -0,0 +1,165 @@
import type { API, Collection, FileInfo, Options } from 'jscodeshift';
const KNEX_SPECIFIC_METHODS = [
'loadFirstByFieldEqualityConjunctionAsync',
'loadManyByFieldEqualityConjunctionAsync',
'loadManyByRawWhereClauseAsync',
];
function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
// Check if this loader call is followed by a knex-specific method
// We need to traverse the AST to find usages of the loader result
const parent = node.parent;
// Check if the loader call is directly chained with a knex method
if (parent?.value.type === 'MemberExpression' && parent.value.object === node.value) {
const grandParent = parent.parent;
if (grandParent?.value.type === 'CallExpression' && grandParent.value.callee === parent.value) {
if (
parent.value.property.type === 'Identifier' &&
KNEX_SPECIFIC_METHODS.includes(parent.value.property.name)
) {
return true;
}
}
}
// Check if the loader is assigned to a variable and then used with knex methods
if (parent?.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
const variableName = parent.value.id.name;
const scope = parent.scope;
// Find all references to this variable in the same scope
const references = j(scope.path)
.find(j.Identifier, { name: variableName })
.filter((path) => {
// Check if this identifier is used as object in member expression
const parentNode = path.parent.value;
if (parentNode.type === 'MemberExpression' && parentNode.object === path.value) {
const prop = parentNode.property;
if (prop.type === 'Identifier' && KNEX_SPECIFIC_METHODS.includes(prop.name)) {
return true;
}
}
return false;
});
if (references.size() > 0) {
return true;
}
}
// Check await expressions
if (parent?.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
const awaitParent = parent.parent;
if (awaitParent?.value.type === 'VariableDeclarator') {
const variableName = awaitParent.value.id.name;
const scope = awaitParent.scope;
// Find all references to this variable in the same scope
const references = j(scope.path)
.find(j.Identifier, { name: variableName })
.filter((path) => {
const parentNode = path.parent.value;
if (parentNode.type === 'MemberExpression' && parentNode.object === path.value) {
const prop = parentNode.property;
if (prop.type === 'Identifier' && KNEX_SPECIFIC_METHODS.includes(prop.name)) {
return true;
}
}
return false;
});
if (references.size() > 0) {
return true;
}
}
}
return false;
}
function transformLoaderToKnexLoader(j: API['jscodeshift'], root: Collection<any>): void {
// Find all entity expressions of the form `Entity.loader(viewerContext)`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: 'loader',
},
},
})
.forEach((path) => {
const loaderCallExpression = path.node; // Entity.loader(viewerContext)
const loaderCallee = loaderCallExpression.callee; // Entity.loader
if (loaderCallee.type !== 'MemberExpression') {
return;
}
// Make sure this is a static method call on an entity (not on an instance)
// Typically entity names start with uppercase letter
if (loaderCallee.object.type === 'Identifier') {
const firstChar = loaderCallee.object.name[0];
if (firstChar === firstChar?.toUpperCase()) {
// Check if this loader uses knex-specific methods
if (isKnexSpecificMethodUsed(j, path)) {
// Rename loader to knexLoader
if (loaderCallee.property.type === 'Identifier') {
loaderCallee.property.name = 'knexLoader';
}
}
}
}
});
}
function transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationResults(
j: API['jscodeshift'],
root: Collection<any>,
): void {
// Find all entity expressions of the form `Entity.loaderWithAuthorizationResults(viewerContext)`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: 'loaderWithAuthorizationResults',
},
},
})
.forEach((path) => {
const loaderCallExpression = path.node; // Entity.loaderWithAuthorizationResults(viewerContext)
const loaderCallee = loaderCallExpression.callee; // Entity.loaderWithAuthorizationResults
if (loaderCallee.type !== 'MemberExpression') {
return;
}
// Make sure this is a static method call on an entity (not on an instance)
// Typically entity names start with uppercase letter
if (loaderCallee.object.type === 'Identifier') {
const firstChar = loaderCallee.object.name[0];
if (firstChar === firstChar?.toUpperCase()) {
// Check if this loader uses knex-specific methods
if (isKnexSpecificMethodUsed(j, path)) {
// Rename loaderWithAuthorizationResults to knexLoaderWithAuthorizationResults
if (loaderCallee.property.type === 'Identifier') {
loaderCallee.property.name = 'knexLoaderWithAuthorizationResults';
}
}
}
}
});
}
export default function transformer(file: FileInfo, api: API, _options: Options): string {
const j = api.jscodeshift;
const root = j.withParser('ts')(file.source);
transformLoaderToKnexLoader(j, root);
transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationResults(j, root);
return root.toSource();
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"exclude": ["**/__testfixtures__/**"]
}
@@ -0,0 +1,47 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.65.0](https://github.com/expo/entity/compare/v0.64.0...v0.65.0) (2026-07-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
### Features
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
@@ -0,0 +1,3 @@
# @expo/entity-database-adapter-knex-testing-utils
Testing utilities for applications using Entity with Knex database adapter.
@@ -0,0 +1,45 @@
{
"name": "@expo/entity-database-adapter-knex-testing-utils",
"version": "0.65.0",
"description": "Testing utilities for applications using Entity with Knex database adapter",
"keywords": [
"entity"
],
"license": "MIT",
"author": "Expo",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"type": "module",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level oxlint --type-aware src",
"lint-fix": "yarn run lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
},
"dependencies": {
"@expo/entity": "workspace:^",
"@expo/entity-database-adapter-knex": "workspace:^",
"invariant": "^2.2.4",
"uuid": "^14.0.0"
},
"devDependencies": {
"@jest/globals": "30.4.1",
"@types/invariant": "2.2.37",
"@types/node": "24.13.2",
"typescript": "6.0.3"
},
"peerDependencies": {
"@jest/globals": "*"
},
"engines": {
"node": ">=18"
}
}
@@ -0,0 +1,316 @@
import type { EntityConfiguration, FieldTransformerMap } from '@expo/entity';
import {
computeIfAbsent,
getDatabaseFieldForEntityField,
IntField,
mapMap,
StringField,
transformFieldsToDatabaseObject,
} from '@expo/entity';
import type {
SQLFragment,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableOrderByClause,
TableQuerySelectionModifiers,
} from '@expo/entity-database-adapter-knex';
import {
BasePostgresEntityDatabaseAdapter,
NullsOrdering,
OrderByOrdering,
} from '@expo/entity-database-adapter-knex';
import invariant from 'invariant';
import { v7 as uuidv7 } from 'uuid';
export class StubPostgresDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
constructor(
private readonly entityConfiguration2: EntityConfiguration<TFields, TIDField>,
private readonly dataStore: Map<string, Readonly<{ [key: string]: any }>[]>,
) {
super(entityConfiguration2);
}
public static convertFieldObjectsToDataStore<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
dataStore: Map<string, Readonly<TFields>[]>,
): Map<string, Readonly<{ [key: string]: any }>[]> {
return mapMap(dataStore, (objectsForTable) =>
objectsForTable.map((objectForTable) =>
transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable),
),
);
}
public getObjectCollectionForTable(tableName: string): { [key: string]: any }[] {
return computeIfAbsent(this.dataStore, tableName, () => []);
}
protected getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
private static uniqBy<T>(a: T[], keyExtractor: (k: T) => string): T[] {
const seen = new Set();
return a.filter((item) => {
const k = keyExtractor(item);
if (seen.has(k)) {
return false;
}
seen.add(k);
return true;
});
}
protected async fetchManyWhereInternalAsync(
_queryInterface: any,
tableName: string,
tableColumns: readonly string[],
tableTuples: (readonly any[])[],
): Promise<object[]> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const results = StubPostgresDatabaseAdapter.uniqBy(tableTuples, (tuple) =>
JSON.stringify(tuple),
).reduce(
(acc, tableTuple) => {
return acc.concat(
objectCollection.filter((obj) => {
return tableColumns.every((tableColumn, index) => {
return obj[tableColumn] === tableTuple[index];
});
}),
);
},
[] as { [key: string]: any }[],
);
return [...results];
}
protected async fetchOneWhereInternalAsync(
queryInterface: any,
tableName: string,
tableColumns: readonly string[],
tableTuple: readonly any[],
): Promise<object | null> {
const results = await this.fetchManyWhereInternalAsync(
queryInterface,
tableName,
tableColumns,
[tableTuple],
);
return results[0] ?? null;
}
private static compareByOrderBys<TFields extends Record<string, any>>(
orderBys: TableOrderByClause<TFields>[],
objectA: { [key: string]: any },
objectB: { [key: string]: any },
): 0 | 1 | -1 {
if (orderBys.length === 0) {
return 0;
}
const currentOrderBy = orderBys[0]!;
if (!('columnName' in currentOrderBy)) {
throw new Error('SQL fragment order by not supported for StubDatabaseAdapter');
}
const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName];
// Determine effective nulls ordering:
// - If explicitly set, use that
// - Otherwise use PostgreSQL defaults: NULLS LAST for ASC, NULLS FIRST for DESC
const nullsFirst =
currentOrderBy.nulls !== undefined
? currentOrderBy.nulls === NullsOrdering.FIRST
: currentOrderBy.order === OrderByOrdering.DESCENDING;
if (aField === null && bField === null) {
return this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
} else if (aField === null) {
return nullsFirst ? -1 : 1;
} else if (bField === null) {
return nullsFirst ? 1 : -1;
}
switch (currentOrderBy.order) {
case OrderByOrdering.DESCENDING: {
return aField > bField
? -1
: aField < bField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
case OrderByOrdering.ASCENDING: {
return bField > aField
? -1
: bField < aField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
}
}
protected async fetchManyByFieldEqualityConjunctionInternalAsync(
_queryInterface: any,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
let filteredObjects = this.getObjectCollectionForTable(tableName);
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue);
}
for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField]));
}
const orderBy = querySelectionModifiers.orderBy;
if (orderBy !== undefined) {
filteredObjects = filteredObjects.sort((a, b) =>
StubPostgresDatabaseAdapter.compareByOrderBys(orderBy, a, b),
);
}
const offset = querySelectionModifiers.offset;
if (offset !== undefined) {
filteredObjects = filteredObjects.slice(offset);
}
const limit = querySelectionModifiers.limit;
if (limit !== undefined) {
filteredObjects = filteredObjects.slice(0, 0 + limit);
}
return filteredObjects;
}
protected fetchManyBySQLFragmentInternalAsync(
_queryInterface: any,
_tableName: string,
_sqlFragment: SQLFragment<TFields>,
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
}
protected async countByFieldEqualityConjunctionInternalAsync(
queryInterface: any,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
): Promise<number> {
const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync(
queryInterface,
tableName,
tableFieldSingleValueEqualityOperands,
tableFieldMultiValueEqualityOperands,
{ orderBy: undefined, offset: undefined, limit: undefined },
);
return results.length;
}
protected countBySQLFragmentInternalAsync(
_queryInterface: any,
_tableName: string,
_sqlFragment: SQLFragment<TFields>,
): Promise<number> {
throw new Error('SQL fragment count not supported for StubDatabaseAdapter');
}
private generateRandomID(): any {
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
invariant(
idSchemaField,
`No schema field found for ${String(this.entityConfiguration2.idField)}`,
);
if (idSchemaField instanceof StringField) {
return uuidv7();
} else if (idSchemaField instanceof IntField) {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
} else {
throw new Error(
`Unsupported ID type for StubPostgresDatabaseAdapter: ${idSchemaField.constructor.name}`,
);
}
}
protected async insertInternalAsync(
_queryInterface: any,
tableName: string,
object: object,
): Promise<object[]> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration2,
this.entityConfiguration2.idField,
);
const objectToInsert = {
[idField]: this.generateRandomID(),
...object,
};
objectCollection.push(objectToInsert);
return [objectToInsert];
}
protected async updateInternalAsync(
_queryInterface: any,
tableName: string,
tableIdField: string,
id: any,
object: object,
): Promise<{ updatedRowCount: number }> {
// SQL does not support empty updates, mirror behavior here for better test simulation
if (Object.keys(object).length === 0) {
throw new Error(`Empty update (${tableIdField} = ${id})`);
}
const objectCollection = this.getObjectCollectionForTable(tableName);
const objectIndex = objectCollection.findIndex((obj) => {
return obj[tableIdField] === id;
});
// SQL updates to a nonexistent row succeed but affect 0 rows,
// mirror that behavior here for better test simulation
if (objectIndex < 0) {
return { updatedRowCount: 0 };
}
objectCollection[objectIndex] = {
...objectCollection[objectIndex],
...object,
};
return { updatedRowCount: 1 };
}
protected async deleteInternalAsync(
_queryInterface: any,
tableName: string,
tableIdField: string,
id: any,
): Promise<number> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const objectIndex = objectCollection.findIndex((obj) => {
return obj[tableIdField] === id;
});
// SQL deletes to a nonexistent row succeed and affect 0 rows,
// mirror that behavior here for better test simulation
if (objectIndex < 0) {
return 0;
}
objectCollection.splice(objectIndex, 1);
return 1;
}
}
@@ -0,0 +1,17 @@
import type {
EntityConfiguration,
EntityDatabaseAdapter,
IEntityDatabaseAdapterProvider,
} from '@expo/entity';
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter.ts';
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
private readonly objectCollection = new Map();
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
): EntityDatabaseAdapter<TFields, TIDField> {
return new StubPostgresDatabaseAdapter(entityConfiguration, this.objectCollection);
}
}
@@ -0,0 +1,61 @@
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
AlwaysAllowPrivacyPolicyRule,
DateField,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
} from '@expo/entity';
export type DateIDTestFields = {
id: Date;
};
export const dateIDTestEntityConfiguration = new EntityConfiguration<DateIDTestFields, 'id'>({
idField: 'id',
tableName: 'simple_test_entity_should_not_write_to_db',
schema: {
id: new DateField({
columnName: 'custom_id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export class DateIDTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
DateIDTestFields,
'id',
ViewerContext,
DateIDTestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
];
}
export class DateIDTestEntity extends Entity<DateIDTestFields, 'id', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
DateIDTestFields,
'id',
ViewerContext,
DateIDTestEntity,
DateIDTestEntityPrivacyPolicy
> {
return {
entityClass: DateIDTestEntity,
entityConfiguration: dateIDTestEntityConfiguration,
privacyPolicyClass: DateIDTestEntityPrivacyPolicy,
};
}
}
@@ -1,14 +1,11 @@
import Entity from '../Entity';
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition,
} from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
UUIDField,
} from '@expo/entity';
export type SimpleTestFields = {
id: string;
@@ -16,56 +13,57 @@ export type SimpleTestFields = {
export type SimpleTestFieldSelection = keyof SimpleTestFields;
export const simpleTestEntityConfiguration = new EntityConfiguration<SimpleTestFields>({
export const simpleTestEntityConfiguration = new EntityConfiguration<SimpleTestFields, 'id'>({
idField: 'id',
tableName: 'simple_test_entity_should_not_write_to_db',
schema: {
id: new UUIDField({
columnName: 'custom_id',
cache: true,
}),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
> {
protected readonly readRules = [
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
>(),
];
protected readonly createRules = [
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
>(),
];
protected readonly updateRules = [
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
>(),
];
protected readonly deleteRules = [
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
@@ -73,26 +71,24 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
];
}
export default class SimpleTestEntity extends Entity<
export class SimpleTestEntity extends Entity<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestFieldSelection
> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestEntityPrivacyPolicy,
SimpleTestFieldSelection
> {
return testEntityCompanion;
return {
entityClass: SimpleTestEntity,
entityConfiguration: simpleTestEntityConfiguration,
privacyPolicyClass: SimpleTestEntityPrivacyPolicy,
};
}
}
export const testEntityCompanion = new EntityCompanionDefinition({
entityClass: SimpleTestEntity,
entityConfiguration: simpleTestEntityConfiguration,
privacyPolicyClass: SimpleTestEntityPrivacyPolicy,
});
@@ -0,0 +1,130 @@
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
AlwaysAllowPrivacyPolicyRule,
DateField,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
IntField,
StringField,
UUIDField,
} from '@expo/entity';
import type { Result } from '@expo/results';
import { result } from '@expo/results';
export type TestFields = {
customIdField: string;
testIndexedField: string;
stringField: string;
intField: number;
dateField: Date;
nullableField: string | null;
};
export const testEntityConfiguration = new EntityConfiguration<TestFields, 'customIdField'>({
idField: 'customIdField',
tableName: 'test_entity_should_not_write_to_db',
schema: {
customIdField: new UUIDField({
columnName: 'custom_id',
cache: true,
}),
testIndexedField: new StringField({
columnName: 'test_index',
cache: true,
}),
stringField: new StringField({
columnName: 'string_field',
}),
intField: new IntField({
columnName: 'number_field',
}),
dateField: new DateField({
columnName: 'date_field',
}),
nullableField: new StringField({
columnName: 'nullable_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
compositeFieldDefinitions: [
{ compositeField: ['stringField', 'intField'], cache: false },
{ compositeField: ['stringField', 'testIndexedField'], cache: true },
{ compositeField: ['nullableField', 'testIndexedField'], cache: true },
],
});
export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
'customIdField',
ViewerContext,
TestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
}
export class TestEntity extends Entity<TestFields, 'customIdField', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestFields,
'customIdField',
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy
> {
return {
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
};
}
getBlah(): string {
return 'Hello World!';
}
static async helloAsync(
viewerContext: ViewerContext,
testValue: string,
): Promise<Result<TestEntity>> {
const fields = {
customIdField: testValue,
testIndexedField: 'hello',
stringField: 'hello',
intField: 1,
dateField: new Date(),
nullableField: null,
};
return result(
new TestEntity({
viewerContext,
id: testValue,
databaseFields: fields,
selectedFields: fields,
}),
);
}
static async returnErrorAsync(_viewerContext: ViewerContext): Promise<Result<TestEntity>> {
return result(new Error('return entity'));
}
static async throwErrorAsync(_viewerContext: ViewerContext): Promise<Result<TestEntity>> {
throw new Error('threw entity');
}
static async nonResultAsync(_viewerContext: ViewerContext, testValue: string): Promise<string> {
return testValue;
}
}
@@ -0,0 +1,61 @@
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
IntField,
} from '@expo/entity';
export type NumberKeyFields = {
id: number;
};
export const numberKeyEntityConfiguration = new EntityConfiguration<NumberKeyFields, 'id'>({
idField: 'id',
tableName: 'simple_test_entity_should_not_write_to_db',
schema: {
id: new IntField({
columnName: 'custom_id',
cache: false,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export class NumberKeyPrivacyPolicy extends EntityPrivacyPolicy<
NumberKeyFields,
'id',
ViewerContext,
NumberKeyEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
];
}
export class NumberKeyEntity extends Entity<NumberKeyFields, 'id', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
NumberKeyFields,
'id',
ViewerContext,
NumberKeyEntity,
NumberKeyPrivacyPolicy
> {
return {
entityClass: NumberKeyEntity,
entityConfiguration: numberKeyEntityConfiguration,
privacyPolicyClass: NumberKeyPrivacyPolicy,
};
}
}
@@ -0,0 +1,32 @@
import { expect, it } from '@jest/globals';
import { readFile } from 'fs/promises';
it.each([
'StubPostgresDatabaseAdapter',
'StubPostgresDatabaseAdapterProvider',
'createUnitTestPostgresEntityCompanionProvider',
])('%s is the same as in @expo/entity-database-adapter-knex', async (fileName) => {
// These stub adapters need to be shared for testing, but we can't have them in the main
// entity-database-adapter-knex package since they would be exposed in production.
// Therefore, we duplicate them and ensure they stay in sync.
const fileContentsFromEntityDatabaseAdapterKnex = await readFile(
new URL(
`../../../entity-database-adapter-knex/src/__tests__/fixtures/${fileName}.ts`,
import.meta.url,
),
'utf-8',
);
const fileContentsFromTestingUtils = await readFile(
new URL(`../${fileName}.ts`, import.meta.url),
'utf-8',
);
const trimmedFiles = [
fileContentsFromEntityDatabaseAdapterKnex,
fileContentsFromTestingUtils,
].map((file) => file.substring(file.indexOf('export')));
expect(trimmedFiles[0]?.length).toBeGreaterThan(0);
expect(trimmedFiles[0]).toEqual(trimmedFiles[1]);
});
@@ -0,0 +1,16 @@
import { EntityCompanionProvider, ViewerContext } from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { TestEntity } from '../__testfixtures__/TestEntity.ts';
import { createUnitTestPostgresEntityCompanionProvider } from '../createUnitTestPostgresEntityCompanionProvider.ts';
describe(createUnitTestPostgresEntityCompanionProvider, () => {
it('creates a new EntityCompanionProvider', async () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
expect(companionProvider).toBeInstanceOf(EntityCompanionProvider);
const viewerContext = new ViewerContext(companionProvider);
await expect(TestEntity.creator(viewerContext).createAsync()).resolves.toBeInstanceOf(
TestEntity,
);
});
});
@@ -0,0 +1,39 @@
import type { IEntityMetricsAdapter } from '@expo/entity';
import { EntityCompanionProvider, NoOpEntityMetricsAdapter } from '@expo/entity';
import {
InMemoryFullCacheStubCacheAdapterProvider,
StubQueryContextProvider,
} from '@expo/entity-testing-utils';
import { StubPostgresDatabaseAdapterProvider } from './StubPostgresDatabaseAdapterProvider.ts';
const queryContextProvider = new StubQueryContextProvider();
/**
* Entity companion provider for use in knex unit tests. All database and cache implementations
* are replaced with in-memory simulations.
*/
export const createUnitTestPostgresEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubPostgresDatabaseAdapterProvider(),
queryContextProvider,
},
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
],
]),
);
};
@@ -0,0 +1,9 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-database-adapter-knex-testing-utils
*/
export * from './createUnitTestPostgresEntityCompanionProvider.ts';
export * from './StubPostgresDatabaseAdapter.ts';
export * from './StubPostgresDatabaseAdapterProvider.ts';
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"references": [{ "path": "../entity" }, { "path": "../entity-database-adapter-knex" }]
}
@@ -3,106 +3,402 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
# [0.65.0](https://github.com/expo/entity/compare/v0.64.0...v0.65.0) (2026-07-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
### Bug Fixes
- only throw EntityDatabaseAdapterPaginationCursorInvalidError from cursor decoding logic ([#537](https://github.com/expo/entity/issues/537)) ([e3cee07](https://github.com/expo/entity/commit/e3cee07091af8d1e6c5bb2d82f13e837c7658ea9))
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
### Features
- update the SQLExpression and SQLFragmentHelpers APIs ([#516](https://github.com/expo/entity/issues/516)) ([112d4dc](https://github.com/expo/entity/commit/112d4dc9b379da5317ff6acaf1d2e9bd597128a6))
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
### Features
- Add SQLExpression class that supports fluent comparison method API ([#506](https://github.com/expo/entity/issues/506)) ([535b681](https://github.com/expo/entity/commit/535b6811e08578aba1aadb095f3e401cfe7eaa26))
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
### Features
- add support to SQLFragment for `= ANY(?)` ([#498](https://github.com/expo/entity/issues/498)) ([b931daa](https://github.com/expo/entity/commit/b931daa9a082cdb33f4559d6d32e1655f0459b79))
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
### Features
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
### Bug Fixes
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
### Features
- throw specific errors for database adapter issues ([#311](https://github.com/expo/entity/issues/311)) ([f6639f9](https://github.com/expo/entity/commit/f6639f9def4ac20b628dd48fdca8d7cf61b6347b))
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
### Features
- add support for Buffer fields ([#305](https://github.com/expo/entity/issues/305)) ([31b52a5](https://github.com/expo/entity/commit/31b52a5f75842f4810f66c12ad03a10d9192f889))
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
### Features
- add createOrGetExistingAsync and createWithUniqueConstraintRecoveryAsync utility methods ([#291](https://github.com/expo/entity/issues/291)) ([78bc264](https://github.com/expo/entity/commit/78bc264b3328692b4b6722388a57d298f02e65c5))
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
### Bug Fixes
- introduce transactional dataloader mode and fix global transaction ID generation ([#289](https://github.com/expo/entity/issues/289)) ([d3e89bd](https://github.com/expo/entity/commit/d3e89bdd82e6df5c5b35522bc3574fd9e1cfddff))
### Features
- add transaction-scoped dataloaders ([#284](https://github.com/expo/entity/issues/284)) ([e054a0d](https://github.com/expo/entity/commit/e054a0d74b3a39158ca7d73d05f59d3633113400)), closes [#98](https://github.com/expo/entity/issues/98) [#194](https://github.com/expo/entity/issues/194)
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
### Features
- add @expo/entity-testing-utils package ([#280](https://github.com/expo/entity/issues/280)) ([485894a](https://github.com/expo/entity/commit/485894af16e233d533a9480285bbda56812cbe0b))
- add composite field loading and caching ([#272](https://github.com/expo/entity/issues/272)) ([f0aa0da](https://github.com/expo/entity/commit/f0aa0dafebdb56418cbd22dda437233d850db3e7))
- change entity id field generic to field name and derive type where necessary ([#278](https://github.com/expo/entity/issues/278)) ([b7e524c](https://github.com/expo/entity/commit/b7e524c41892797608d0b884410b8d520a80a9ef))
- convert batched/cached loader interface to holder pattern ([#271](https://github.com/expo/entity/issues/271)) ([06b3cb7](https://github.com/expo/entity/commit/06b3cb7f8076ef9e5b83cdf154da159801bd14cc)), closes [#201](https://github.com/expo/entity/issues/201)
- enforce explicit id field cache property ([#276](https://github.com/expo/entity/issues/276)) ([1da5cc0](https://github.com/expo/entity/commit/1da5cc01e1affc3b3338a0ab2050504b5853ac6f))
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
- feat!: Default all loaders and mutators to enforcing (#256) ([a976e76](https://github.com/expo/entity/commit/a976e765098bca27049585e43a3bed5c71b9ac6b)), closes [#256](https://github.com/expo/entity/issues/256)
### BREAKING CHANGES
- Default all loaders and mutators to enforcing
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
### Reverts
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
# [0.39.0](https://github.com/expo/entity/compare/v0.38.0...v0.39.0) (2024-11-21)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.38.0](https://github.com/expo/entity/compare/v0.37.0...v0.38.0) (2024-06-27)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
### Bug Fixes
- update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
### Bug Fixes
- constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
### Bug Fixes
- Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
### Features
- require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
### Features
- add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
- entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
### Features
- add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
- nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
### Features
- add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
### Features
- Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
### Features
- add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
### Features
- add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
### Features
- enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
- upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
### Features
- Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
### Features
- allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
### Bug Fixes
- upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
### Features
- secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
### Bug Fixes
- typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
### Bug Fixes
- mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
### Features
- add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
### Bug Fixes
- move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
- remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
### Features
- better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
- better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
### Bug Fixes
* fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
- fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
- move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
- upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
### Bug Fixes
* separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
- separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
### Features
* entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
* mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
- entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
- mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
### Bug Fixes
* correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
- correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
# 0.5.0 (2020-07-01)
### Features
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
- support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
@@ -9,16 +9,16 @@
During `EntityCompanionProvider` instantiation:
```typescript
import Knex from 'knex';
import { knex, Knex } from 'knex';
const knexInstance = Knex({
const knexInstance = knex({
client: 'pg',
connection: {
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT!, 10),
database: process.env.PGDATABASE,
user: process.env['PGUSER'],
password: process.env['PGPASSWORD'],
host: process.env['PGHOST'],
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: process.env['PGDATABASE'],
},
});
@@ -29,7 +29,7 @@ export const createDefaultEntityCompanionProvider = (
metricsAdapter,
{
// add the knex database adapter flavor
[DatabaseAdapterFlavor.POSTGRES]: {
['postgres']: {
adapter: PostgresEntityDatabaseAdapter,
queryContextProvider: new PostgresEntityQueryContextProvider(knexInstance),
},
@@ -39,4 +39,4 @@ export const createDefaultEntityCompanionProvider = (
}
);
};
```
```
@@ -1,37 +1,42 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.10.0",
"version": "0.65.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src --ext '.ts'",
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
"integration": "../../resources/run-with-docker yarn integration-no-setup",
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
},
"engines": {
"node": ">=12"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
"author": "Expo",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"type": "module",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level oxlint --type-aware src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
},
"dependencies": {
"knex": "^0.21.5"
"@expo/entity": "workspace:^",
"knex": "^3.2.9"
},
"devDependencies": {
"@expo/entity": "^0.10.0"
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.4.1",
"pg": "8.22.0",
"ts-mockito": "2.6.1",
"typescript": "6.0.3"
},
"engines": {
"node": ">=18"
}
}
@@ -0,0 +1,555 @@
import type {
EntityConstructionUtils,
EntityPrivacyPolicy,
EntityQueryContext,
IEntityMetricsAdapter,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import type { Result } from '@expo/results';
import type {
FieldEqualityCondition,
NullsOrdering,
OrderByOrdering,
} from './BasePostgresEntityDatabaseAdapter.ts';
import { isSingleValueFieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts';
import type { PaginationStrategy } from './PaginationStrategy.ts';
import type { SQLFragment } from './SQLOperator.ts';
import type {
Connection,
EntityKnexDataManager,
PageInfo,
} from './internal/EntityKnexDataManager.ts';
import type { NonNullableKeys } from './internal/utilityTypes.ts';
export type EntityLoaderBaseOrderByClause = {
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
/**
* Optional nulls ordering for this order by clause. If not provided, no specific nulls ordering is applied.
*/
nulls?: NullsOrdering | undefined;
};
export type EntityLoaderFieldNameOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> = EntityLoaderBaseOrderByClause & {
/**
* The field name to order by.
*/
fieldName: TSelectedFields;
};
export type EntityLoaderFieldFragmentOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> = EntityLoaderBaseOrderByClause & {
/**
* The SQL fragment to order by, which can reference selected fields. Example: `COALESCE(NULLIF(display_name, ''), split_part(full_name, '/', 2))`.
* May not contain ASC or DESC, as ordering direction is controlled by the `order` property.
*/
fieldFragment: SQLFragment<Pick<TFields, TSelectedFields>>;
};
export type EntityLoaderOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| EntityLoaderFieldNameOrderByClause<TFields, TSelectedFields>
| EntityLoaderFieldFragmentOrderByClause<TFields, TSelectedFields>;
/**
* SQL modifiers that only affect the selection but not the projection.
*/
export interface EntityLoaderQuerySelectionModifiers<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* Order the entities by specified columns and orders.
*/
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
/**
* Skip the specified number of entities queried before returning.
*/
offset?: number;
/**
* Limit the number of entities returned.
*/
limit?: number;
}
/**
* Function to be used for constructing search fields based on field names.
* The function takes a field name and returns a SQLFragment that can be used in the search field portion.
*/
export type EntityLoaderFieldNameConstructorFn<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> = (fieldName: TSelectedFields) => SQLFragment<TFields>;
/**
* Specification for a search field that is a manually constructed SQLFragment. Useful for complex search fields that require
* transformations to make nullable fields non-null or to make combinations of multiple fields.
*/
export type EntityLoaderSearchFieldSQLFragmentFnSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> = {
/**
* Function that constructs the SQLFragment for the search field.
*
* @param getFragmentForFieldName - A helper function that takes a field name and returns a SQLFragment for that field, which should be used
* to construct the final SQLFragment for the search field.
* @returns a SQLFragment representing the search field, which must reference selected fields through the provided helper function.
*
* @example
* For example, to construct a search field that searches by display name (nullable column) with fallback to full name,
* the fieldConstructor function could be implemented as follows:
* ```
* fieldConstructor: (getFragmentForFieldName) => {
* const displayNameFragment = getFragmentForFieldName('display_name');
* const fullNameFragment = getFragmentForFieldName('full_name');
* return sql`COALESCE(NULLIF(${displayNameFragment}, ''), split_part(${fullNameFragment}, '/', 2))`;
* }
* ```
*/
fieldConstructor: (
/**
* Helper function to get a SQLFragment for a given field name, which should be used to construct the final SQLFragment for the search field.
*/
getFragmentForFieldName: EntityLoaderFieldNameConstructorFn<TFields, TSelectedFields>,
) => SQLFragment<Pick<TFields, TSelectedFields>>;
};
/**
* Set of selected fields that are non-nullable, which can be used for search fields that require non-nullable fields.
* To use a nullable field as a search field, use an EntityLoaderSearchFieldSQLFragmentFnSpecification with appropriate SQL
* to handle null values, such as `COALESCE(field_name, '')` to treat nulls as empty strings for searching.
*/
export type NonNullableSelectedFields<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
> = TSelectedFields & NonNullableKeys<TFields>;
/**
* Specification for a search field that can be either a simple selected field name (which must be non-nullable) or a manually constructed
* SQLFragment using EntityLoaderSearchFieldSQLFragmentFnSpecification.
*/
export type EntityLoaderSearchFieldSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| NonNullableSelectedFields<TFields, TSelectedFields>
| EntityLoaderSearchFieldSQLFragmentFnSpecification<TFields, TSelectedFields>;
interface SearchSpecificationBase<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* The search term to search for. Must be a non-empty string.
*/
term: string;
/**
* The fields to search within.
*/
fields: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
}
interface ILikeSearchSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends SearchSpecificationBase<TFields, TSelectedFields> {
/**
* Case-insensitive pattern matching search using SQL ILIKE operator.
* Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination.
*/
strategy: PaginationStrategy.ILIKE_SEARCH;
}
interface TrigramSearchSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends SearchSpecificationBase<TFields, TSelectedFields> {
/**
* Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination.
* Note that trigram similarity search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes, and results may not be as relevant as more advanced full-text search solutions.
* It is recommended to use this strategy only when ILIKE search does not meet the application's needs and to ensure appropriate database indexing for performance.
*/
strategy: PaginationStrategy.TRIGRAM_SEARCH;
/**
* Similarity threshold for trigram matching.
* Must be between 0 and 1, where:
* - 0 matches everything
* - 1 requires exact match
*
* Recommended threshold values:
* - 0.3: Loose matching, allows more variation (default PostgreSQL similarity threshold)
* - 0.4-0.5: Moderate matching, good balance for most use cases
* - 0.6+: Strict matching, requires high similarity
*/
threshold: number;
/**
* Optional additional fields to order by after similarity score and before ID for tie-breaking.
* These fields are independent of search fields and can be used to provide meaningful
* ordering when multiple results have the same similarity score.
*/
extraOrderByFields?: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
}
interface StandardPaginationSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* Standard pagination without search. Results are ordered by the specified orderBy fields.
*/
strategy: PaginationStrategy.STANDARD;
/**
* Order the entities by specified columns and orders. If the ID field is not included, it will be automatically added for stable pagination.
*/
orderBy: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
}
/**
* Pagination specification for SQL-based pagination (with or without search).
*/
export type PaginationSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| StandardPaginationSpecification<TFields, TSelectedFields>
| ILikeSearchSpecification<TFields, TSelectedFields>
| TrigramSearchSpecification<TFields, TSelectedFields>;
/**
* Base unified pagination arguments
*/
interface EntityLoaderBaseUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* SQLFragment representing the WHERE clause to filter the entities being paginated.
*/
where?: SQLFragment<Pick<TFields, TSelectedFields>>;
/**
* Pagination specification determining how to order and paginate results.
*/
pagination: PaginationSpecification<TFields, TSelectedFields>;
}
/**
* Forward unified pagination arguments
*/
export interface EntityLoaderForwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
/**
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
*/
first: number;
/**
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
* Not allowed with forward pagination.
*/
last?: never;
/**
* The cursor to paginate after for forward pagination.
*/
after?: string;
/**
* The cursor to paginate before for backward pagination.
* Not allowed with forward pagination.
*/
before?: never;
}
/**
* Backward unified pagination arguments
*/
export interface EntityLoaderBackwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
/**
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
* Not allowed with backward pagination.
*/
first?: never;
/**
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
*/
last: number;
/**
* The cursor to paginate after for forward pagination.
* Not allowed with backward pagination.
*/
after?: never;
/**
* The cursor to paginate before for backward pagination.
*/
before?: string;
}
/**
* Load page pagination arguments, which can be either forward or backward unified pagination arguments.
*/
export type EntityLoaderLoadPageArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| EntityLoaderForwardUnifiedPaginationArgs<TFields, TSelectedFields>
| EntityLoaderBackwardUnifiedPaginationArgs<TFields, TSelectedFields>;
/**
* Authorization-result-based knex entity loader for non-data-loader-based load methods.
* All loads through this loader are results (or null for some loader methods), where an
* unsuccessful result means an authorization error or entity construction error occurred.
* Other errors are thrown.
*/
export class AuthorizationResultBasedKnexEntityLoader<
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,
> {
constructor(
private readonly queryContext: EntityQueryContext,
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
private readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
) {}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns the first entity results that matches the query, where result error can be
* UnauthorizedError
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<
EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
'limit'
> &
Required<Pick<EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>, 'orderBy'>>,
): Promise<Result<TEntity> | null> {
const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, {
...querySelectionModifiers,
limit: 1,
});
return results[0] ?? null;
}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns array of entity results that match the query, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.constructionUtils.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues);
}
const fieldObjects = await this.knexDataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext,
fieldEqualityOperands,
querySelectionModifiers,
);
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
/**
* Count entities matching the conjunction of field equality operands.
* This does not perform authorization since count does not load full entities.
* Note that this should be used with the same caution as loadManyByFieldEqualityConjunctionAsync
* regarding indexing since counts can be expensive on large datasets without appropriate indexes.
*
* @returns count of entities matching the filters
*/
async countByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
): Promise<number> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.constructionUtils.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues);
}
return await this.knexDataManager.countByFieldEqualityConjunctionAsync(
this.queryContext,
fieldEqualityOperands,
);
}
/**
* Count entities matching a SQL fragment.
* This does not perform authorization since count does not load full entities.
* Note that this should be used with the same caution as loadManyBySQL regarding indexing
* since counts can be expensive on large datasets without appropriate indexes.
*
* @returns count of entities matching the query
*/
async countBySQLAsync(fragment: SQLFragment<Pick<TFields, TSelectedFields>>): Promise<number> {
return await this.knexDataManager.countBySQLFragmentAsync(this.queryContext, fragment);
}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns SQL query builder for building and executing SQL queries that when executed returns entity results where result error can be UnauthorizedError.
*/
loadManyBySQL(
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): AuthorizationResultBasedSQLQueryBuilder<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return new AuthorizationResultBasedSQLQueryBuilder(
this.knexDataManager,
this.constructionUtils,
this.queryContext,
fragment,
modifiers,
);
}
/**
* Load a page of entities with Relay-style cursor pagination using a unified pagination specification.
* Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out.
*
* @returns Connection with only successfully authorized entities
*/
async loadPageAsync(
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
): Promise<Connection<TEntity>> {
const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args);
const edgeResults = await Promise.all(
pageResult.edges.map(async (edge) => {
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
edge.node,
);
if (!entityResult.ok) {
return null;
}
return {
...edge,
node: entityResult.value,
};
}),
);
const edges = edgeResults.filter((edge) => edge !== null);
const pageInfo: PageInfo = {
...pageResult.pageInfo,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
};
return {
edges,
pageInfo,
};
}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns The pagination cursor for the given entity.
*/
getPaginationCursorForEntity(entity: TEntity): string {
return this.knexDataManager.getCursorForEntityID(entity.getID());
}
}
/**
* SQL query builder implementation for AuthorizationResultBasedKnexEntityLoader.
*/
export class AuthorizationResultBasedSQLQueryBuilder<
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,
> extends BaseSQLQueryBuilder<TFields, TSelectedFields, Result<TEntity>> {
constructor(
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
private readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly queryContext: EntityQueryContext,
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
) {
super(sqlFragment, modifiers);
}
/**
* Execute the query and return results.
*/
async executeInternalAsync(): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.knexDataManager.loadManyBySQLFragmentAsync(
this.queryContext,
this.getSQLFragment(),
this.getModifiers(),
);
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
}
@@ -0,0 +1,348 @@
import type { EntityQueryContext } from '@expo/entity';
import {
EntityDatabaseAdapter,
getDatabaseFieldForEntityField,
transformDatabaseObjectToFields,
} from '@expo/entity';
import type { Knex } from 'knex';
import type { SQLFragment } from './SQLOperator.ts';
/**
* Equality operand that is used for selecting entities with a field with a single value.
*/
export interface SingleValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValue: TFields[N];
}
/**
* Equality operand that is used for selecting entities with a field matching one of multiple values.
*/
export interface MultiValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValues: readonly TFields[N][];
}
/**
* A single equality operand for use in a selection clause.
* See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples.
*/
export type FieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> = SingleValueFieldEqualityCondition<TFields, N> | MultiValueFieldEqualityCondition<TFields, N>;
export function isSingleValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
>(
condition: FieldEqualityCondition<TFields, N>,
): condition is SingleValueFieldEqualityCondition<TFields, N> {
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
}
export interface TableFieldSingleValueEqualityCondition {
tableField: string;
tableValue: any;
}
export interface TableFieldMultiValueEqualityCondition {
tableField: string;
tableValues: readonly any[];
}
export enum NullsOrdering {
FIRST = 'first',
LAST = 'last',
}
/**
* Ordering options for `orderBy` clauses.
*/
export enum OrderByOrdering {
/**
* Ascending order (lowest to highest).
* Ascending order puts smaller values first, where "smaller" is defined in terms of the %3C operator.
*/
ASCENDING = 'asc',
/**
* Descending order (highest to lowest).
* Descending order puts larger values first, where "larger" is defined in terms of the %3E operator.
*/
DESCENDING = 'desc',
}
export type PostgresOrderByClause<TFields extends Record<string, any>> =
| {
/**
* The field name to order by.
*/
fieldName: keyof TFields;
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
/**
* Optional nulls ordering. If not provided, the database default is used
* (NULLS LAST for ASC, NULLS FIRST for DESC in PostgreSQL).
*/
nulls?: NullsOrdering | undefined;
}
| {
/**
* A raw SQL fragment to order by. May not contain ASC or DESC, as ordering direction is determined by the `order` property.
*/
fieldFragment: SQLFragment<TFields>;
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
/**
* Optional nulls ordering. If not provided, the database default is used
* (NULLS LAST for ASC, NULLS FIRST for DESC in PostgreSQL).
*/
nulls?: NullsOrdering | undefined;
};
/**
* SQL modifiers that only affect the selection but not the projection.
*/
export interface PostgresQuerySelectionModifiers<TFields extends Record<string, any>> {
/**
* Order the entities by specified columns and orders.
*/
orderBy?: readonly PostgresOrderByClause<TFields>[];
/**
* Skip the specified number of entities queried before returning.
*/
offset?: number;
/**
* Limit the number of entities returned.
*/
limit?: number;
}
export type TableOrderByClause<TFields extends Record<string, any>> =
| {
columnName: string;
order: OrderByOrdering;
nulls: NullsOrdering | undefined;
}
| {
columnFragment: SQLFragment<TFields>;
order: OrderByOrdering;
nulls: NullsOrdering | undefined;
};
export interface TableQuerySelectionModifiers<TFields extends Record<string, any>> {
orderBy: TableOrderByClause<TFields>[] | undefined;
offset: number | undefined;
limit: number | undefined;
}
export abstract class BasePostgresEntityDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> extends EntityDatabaseAdapter<TFields, TIDField> {
/**
* Get the maximum page size for pagination.
* @returns maximum page size if configured, undefined otherwise
*/
get paginationMaxPageSize(): number | undefined {
return undefined;
}
/**
* Fetch many objects matching the conjunction of where clauses constructed from
* specified field equality operands.
*
* @param queryContext - query context with which to perform the fetch
* @param fieldEqualityOperands - list of field equality where clause operand specifications
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query
*/
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
queryContext: EntityQueryContext,
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = [];
for (const operand of fieldEqualityOperands) {
if (isSingleValueFieldEqualityCondition(operand)) {
tableFieldSingleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
tableValue: operand.fieldValue,
});
} else {
tableFieldMultipleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
tableValues: operand.fieldValues,
});
}
}
const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
tableFieldSingleValueOperands,
tableFieldMultipleValueOperands,
this.convertToTableQueryModifiers(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
protected abstract fetchManyByFieldEqualityConjunctionInternalAsync(
queryInterface: Knex,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]>;
/**
* Fetch many objects matching the SQL fragment.
*
* @param queryContext - query context with which to perform the fetch
* @param sqlFragment - SQLFragment for the WHERE clause of the query
* @param querySelectionModifiers - limit, offset, and orderByFragment for the query
* @returns array of objects matching the query
*/
async fetchManyBySQLFragmentAsync(
queryContext: EntityQueryContext,
sqlFragment: SQLFragment<TFields>,
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyBySQLFragmentInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
sqlFragment,
this.convertToTableQueryModifiers(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
protected abstract fetchManyBySQLFragmentInternalAsync(
queryInterface: Knex,
tableName: string,
sqlFragment: SQLFragment<TFields>,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]>;
/**
* Count objects matching the conjunction of where clauses constructed from
* specified field equality operands.
*
* @param queryContext - query context with which to perform the count
* @param fieldEqualityOperands - list of field equality where clause operand specifications
* @returns count of objects matching the query
*/
async countByFieldEqualityConjunctionAsync<N extends keyof TFields>(
queryContext: EntityQueryContext,
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
): Promise<number> {
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = [];
for (const operand of fieldEqualityOperands) {
if (isSingleValueFieldEqualityCondition(operand)) {
tableFieldSingleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
tableValue: operand.fieldValue,
});
} else {
tableFieldMultipleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
tableValues: operand.fieldValues,
});
}
}
return await this.countByFieldEqualityConjunctionInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
tableFieldSingleValueOperands,
tableFieldMultipleValueOperands,
);
}
protected abstract countByFieldEqualityConjunctionInternalAsync(
queryInterface: Knex,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
): Promise<number>;
/**
* Count objects matching the SQL fragment.
*
* @param queryContext - query context with which to perform the count
* @param sqlFragment - SQLFragment for the WHERE clause of the query
* @returns count of objects matching the query
*/
async countBySQLFragmentAsync(
queryContext: EntityQueryContext,
sqlFragment: SQLFragment<TFields>,
): Promise<number> {
return await this.countBySQLFragmentInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
sqlFragment,
);
}
protected abstract countBySQLFragmentInternalAsync(
queryInterface: Knex,
tableName: string,
sqlFragment: SQLFragment<TFields>,
): Promise<number>;
private convertToTableQueryModifiers(
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): TableQuerySelectionModifiers<TFields> {
const orderBy = querySelectionModifiers.orderBy;
return {
orderBy:
orderBy !== undefined
? orderBy.map((orderBySpecification): TableOrderByClause<TFields> => {
if ('fieldName' in orderBySpecification) {
return {
columnName: getDatabaseFieldForEntityField(
this.entityConfiguration,
orderBySpecification.fieldName,
),
order: orderBySpecification.order,
nulls: orderBySpecification.nulls,
};
} else {
return {
columnFragment: orderBySpecification.fieldFragment,
order: orderBySpecification.order,
nulls: orderBySpecification.nulls,
};
}
})
: undefined,
offset: querySelectionModifiers.offset,
limit: querySelectionModifiers.limit,
};
}
}
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff. Show more