Compare commits

..
Author SHA1 Message Date
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
403 changed files with 58188 additions and 21272 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# 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 ESLint on source code
- `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__/**" ]
}
]
}
-55
View File
@@ -1,55 +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",
"handle-callback-err": "off"
},
"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 @@
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.3.8
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ env.MISE_VERSION }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
cache: yarn
- run: mise run ci
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: ./coverage
flags: unittest
fail_ci_if_error: true
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ env.MISE_VERSION }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
cache: yarn
- run: mise run typedoc
- name: Deploy
if: ${{ github.ref == 'refs/heads/main' }}
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.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/
+6
View File
@@ -0,0 +1,6 @@
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- "@expo/*"
+432 -206
View File
@@ -3,373 +3,599 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
# [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
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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
+22 -18
View File
@@ -4,21 +4,22 @@ 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/)
@@ -78,20 +79,23 @@ 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'],
};
+124
View File
@@ -0,0 +1,124 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import universeNodeConfig from 'eslint-config-universe/flat/node.js';
import universeSharedTypescriptAnalysisConfig from 'eslint-config-universe/flat/shared/typescript-analysis.js';
import tsdoc from 'eslint-plugin-tsdoc';
export default defineConfig([
globalIgnores([
'packages/entity-codemod/**/__testfixtures__/**',
'packages/*/build',
'coverage',
'coverage-integration',
'doc',
]),
universeNodeConfig,
universeSharedTypescriptAnalysisConfig,
{
languageOptions: {
parserOptions: {
projectService: true,
},
},
plugins: {
tsdoc,
},
rules: {
'no-restricted-imports': ['error', { paths: ['.', '..', '../..'] }],
'tsdoc/syntax': 'warn',
'no-console': 'warn',
'handle-callback-err': 'off',
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.',
},
],
},
},
{
settings: {
jest: {
version: 29,
},
},
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.d.ts'],
rules: {
'no-void': [
'warn',
{
allowAsStatement: true,
},
],
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'typeLike',
format: ['PascalCase'],
},
{
selector: 'enumMember',
format: ['UPPER_CASE'],
},
// async functions must have names ending with 'Async'
{
selector: ['function', 'classMethod', 'typeMethod'],
format: ['camelCase'],
modifiers: ['async'],
suffix: ['Async'],
},
],
'no-dupe-class-members': 'off',
'@typescript-eslint/no-dupe-class-members': ['error'],
},
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.d.ts'],
ignores: ['**/*tests__/**', 'packages/entity-codemod/src/transforms/**'],
rules: {
'no-restricted-exports': ['error', { restrictDefaultExports: { direct: true } }],
},
},
{
files: ['**/__tests__/**/*.ts', '**/__tests__/**/*.tsx', '**/__tests__/**/*.d.ts'],
rules: {
// ts-mockito verify function needs void functions within verify which is a void function
'@typescript-eslint/no-confusing-void-expression': 'off',
},
},
]);
+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.24.0"
"version": "0.61.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+47
View File
@@ -0,0 +1,47 @@
[tools]
node = "24.14.0"
[tasks.yarn]
run = "yarn install --immutable"
[tasks.build]
depends = "yarn"
run = "yarn build"
[tasks.typedoc]
depends = "build"
run = "yarn typedoc"
[tasks."checks:lint"]
depends = "yarn"
run = "yarn lint --max-warnings=0"
[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"]
}
}
}
+29 -42
View File
@@ -5,49 +5,36 @@
"packages/*"
],
"scripts": {
"tsc": "lerna run tsc",
"clean": "lerna run clean",
"lint": "lerna run lint",
"lint-fix": "lerna run lint-fix",
"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": "eslint .",
"lint-fix": "yarn lint --fix",
"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"
},
"type": "module",
"devDependencies": {
"@expo/entity": "file:packages/entity",
"@expo/results": "^1.0.0",
"@types/lru-cache": "^5.1.1",
"@types/invariant": "^2.2.33",
"@types/ioredis": "^4.26.4",
"@types/jest": "^26.0.10",
"@types/jsbn": "^1.2.29",
"@types/node": "^14.6.0",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"barrelsby": "^2.2.0",
"eslint": "^7.7.0",
"eslint-config-universe": "^7.0.0",
"eslint-plugin-tsdoc": "^0.2.11",
"jest": "^26.6.3",
"lerna": "^4.0.0",
"nullthrows": "^1.1.1",
"pg": "^8.6.0",
"prettier": "^2.3.2",
"ts-jest": "~26.4.4",
"ts-mockito": "^2.6.1",
"typedoc": "^0.21.9",
"typescript": "^4.4.2",
"uuid": "^8.3.0"
"@tsconfig/bases": "1.0.23",
"@types/node": "24.12.0",
"ctix": "2.7.5",
"eslint": "9.39.4",
"eslint-config-universe": "15.0.3",
"eslint-plugin-tsdoc": "0.5.2",
"jest": "30.2.0",
"lerna": "9.0.5",
"prettier": "3.8.1",
"prettier-plugin-organize-imports": "4.3.0",
"typedoc": "0.28.17",
"typescript": "5.9.3"
},
"dependencies": {
"@expo/entity": "file:packages/entity",
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
}
"resolutions": {
"collect-v8-coverage": "^1.0.3"
},
"packageManager": "[email protected]+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8"
}
@@ -3,9 +3,206 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
# [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
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
- 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))
@@ -3,21 +3,26 @@
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-box invalidation is not possible. Do 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 have high mutability
- The objects stored are mutable
- Reading a stale object from the cache is not acceptable in your application
- Cross-box invalidation is not possible
- 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
- 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
@@ -1,38 +1,40 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.24.0",
"version": "0.61.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"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"
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"type": "module",
"dependencies": {
"lru-cache": "^7.3.1"
"@expo/entity": "workspace:^"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
"@expo/entity-testing-utils": "workspace:^",
"@isaacs/ttlcache": "2.1.4",
"@jest/globals": "30.2.0",
"typescript": "5.9.3"
}
}
@@ -1,109 +1,71 @@
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
import LRUCache from 'lru-cache';
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 LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
type LRUCacheOptionsV7<K, V> = {
/**
* 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>> {
/**
* the number of most recently used items to keep.
* note that we may store fewer items than this if maxSize is hit.
* Gets a value from the cache for specified key.
* @param key - key to get
* @returns the cached value, or undefined if not present
*/
max: number;
get(key: string): LocalMemoryCacheValue<TFields> | undefined;
/**
* if you wish to track item size, you must provide a maxSize
* note that we still will only keep up to max *actual items*,
* so size tracking may cause fewer than max items to be stored.
* At the extreme, a single item of maxSize size will cause everything
* else in the cache to be dropped when it is added. Use with caution!
* Note also that size tracking can negatively impact performance,
* though for most cases, only minimally.
* Sets a value in the cache for specified key.
* @param key - key to set
* @param value - value to set
*/
maxSize?: number;
set(key: string, value: LocalMemoryCacheValue<TFields>): void;
/**
* function to calculate size of items. useful if storing strings or
* buffers or other items where memory size depends on the object itself.
* also note that oversized items do NOT immediately get dropped from
* the cache, though they will cause faster turnover in the storage.
* Deletes a value from the cache for specified key.
* @param key - key to delete
*/
sizeCalculation?: (value: V, key: K) => number;
delete(key: string): void;
}
/**
* function to call when the item is removed from the cache
* Note that using this can negatively impact performance.
*/
dispose?: (value: V, key: K) => 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>,
) {}
/**
* max time to live for items before they are considered stale
* note that stale items are NOT preemptively removed by default,
* and MAY live in the cache, contributing to its LRU max, long after
* they have expired.
* Also, as this cache is optimized for LRU/MRU operations, some of
* the staleness/TTL checks will reduce performance, as they will incur
* overhead by deleting items.
* Must be a positive integer in ms, defaults to 0, which means "no TTL"
*/
ttl?: number;
/**
* return stale items from cache.get() before disposing of them
* boolean, default false
*/
allowStale?: boolean;
/**
* update the age of items on cache.get(), renewing their TTL
* boolean, default false
*/
updateAgeOnGet?: boolean;
/**
* update the age of items on cache.has(), renewing their TTL
* boolean, default false
*/
updateAgeOnHas?: boolean;
/**
* update the "recently-used"-ness of items on cache.has()
* boolean, default false
*/
updateRecencyOnHas?: boolean;
};
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
static createLRUCache<TFields>(
options: { maxSize?: number; ttlSeconds?: number } = {}
): LocalMemoryCache<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;
const lruCacheOptions: LRUCacheOptionsV7<string, TFields> = {
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
sizeCalculation: (value: LocalMemoryCacheValue<TFields>) =>
value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1,
ttl: maxAgeSeconds * 1000, // convert to ms
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 {},
};
return new LRUCache<string, LocalMemoryCacheValue<TFields>>(lruCacheOptions as any);
}
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
max: 0,
maxAge: -1,
});
}
public async loadManyAsync(
keys: readonly string[]
keys: readonly string[],
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
for (const key of keys) {
@@ -140,7 +102,38 @@ export default class GenericLocalMemoryCacher<TFields> implements IEntityGeneric
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
for (const key of keys) {
this.localMemoryCache.del(key);
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)];
}
}
@@ -1,28 +0,0 @@
import { EntityConfiguration, PartsCacheAdapter, Parts } from '@expo/entity';
import SimplePartsCacher from '@expo/entity/build/SimplePartsCacher';
import invariant from 'invariant';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
export default class LocalMemoryCacheAdapter<TFields> extends PartsCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
localMemoryCache: LocalMemoryCache<TFields>
) {
super(
entityConfiguration,
new SimplePartsCacher(new GenericLocalMemoryCacher(localMemoryCache))
);
}
getParts<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): Parts {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return [
this.entityConfiguration.tableName,
`${this.entityConfiguration.cacheKeyVersion}`,
columnName,
String(fieldValue),
];
}
}
@@ -1,45 +1,54 @@
import {
computeIfAbsent,
EntityCacheAdapter,
import type {
EntityConfiguration,
IEntityCacheAdapter,
IEntityCacheAdapterProvider,
} from '@expo/entity';
import { computeIfAbsent, GenericEntityCacheAdapter } from '@expo/entity';
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
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 {
// local memory cache adapters should be shared/reused across requests
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
static getNoOpProvider(): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createNoOpCache<TFields>()
/**
* @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>(),
);
}
static getProvider(
options: { maxSize?: number; ttlSeconds?: number } = {}
/**
* @returns a local memory cache adapter provider configured with the supplied local memory cache creator function.
*/
static createProviderWithCacheCreator(
cacheCreator: LocalMemoryCacheCreator,
): IEntityCacheAdapterProvider {
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
GenericLocalMemoryCacher.createLRUCache<TFields>(options)
);
return new LocalMemoryCacheAdapterProvider(cacheCreator);
}
private constructor(
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
) {}
private readonly localMemoryCacheAdapterMap = new Map<
string,
GenericEntityCacheAdapter<any, any>
>();
public getCacheAdapter<TFields>(
entityConfiguration: EntityConfiguration<TFields>
): EntityCacheAdapter<TFields> {
return computeIfAbsent(
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
entityConfiguration.tableName,
() => {
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
}
);
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),
);
});
}
}
@@ -1,180 +0,0 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
import {
createLocalMemoryIntegrationTestEntityCompanionProvider,
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
} from '../testfixtures/createLocalMemoryIntegrationTestEntityCompanionProvider';
class TestViewerContext extends ViewerContext {}
describe(LocalMemoryCacheAdapter, () => {
beforeEach(async () => {
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
});
it('has correct caching behavior', async () => {
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['partsCacher']['cacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', 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.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.NEGATIVE,
});
// load again through entities framework to ensure it reads negative result
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(
viewerContext
).loadByIDAsync(nonExistentId);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValueMiss = cachedResultMiss.get(cacheKeyMaker('id', entity1.getID()));
expect(cachedValueMiss).toMatchObject({ status: CacheStatus.MISS });
});
it('shares the cache between different requests', async () => {
const genericLocalMemoryCacherLoadManySpy = jest.spyOn(
GenericLocalMemoryCacher.prototype as unknown as any,
'loadManyAsync'
);
const viewerContext = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// loading an entity should put it in cache
await LocalMemoryTestEntity.loader(viewerContext)
.enforcing()
.loadByIDAsync(entity1Created.getID());
// load entity with a different request
const viewerContext2 = new TestViewerContext(
createLocalMemoryIntegrationTestEntityCompanionProvider()
);
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
expect(entity1WithVc2.getAllFields()).toMatchObject({
id: entity1WithVc2.getID(),
name: 'blah',
dateField: date,
});
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledWith([
cacheKeyMaker('id', entity1WithVc2.getID()),
]);
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledTimes(2);
});
it('respects the parameters of a noop cache', async () => {
const viewerContext = new TestViewerContext(
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
LocalMemoryTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
// 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)
.enforcing()
.loadByIDAsync(entity1Created.getID());
const entitySpecificGenericCacher =
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
)!['partsCacher']['cacher'];
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
]);
const cachedValue = cachedResult.get(cacheKeyMaker('id', 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.loader(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult.ok).toBe(false);
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', nonExistentId),
]);
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
status: CacheStatus.MISS,
});
});
});
@@ -1,13 +1,12 @@
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
DateField,
StringField,
EntityConfiguration,
EntityCompanionDefinition,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
StringField,
UUIDField,
} from '@expo/entity';
export type LocalMemoryTestEntityFields = {
@@ -16,32 +15,36 @@ export type LocalMemoryTestEntityFields = {
dateField: Date | null;
};
export default class LocalMemoryTestEntity extends Entity<
export class LocalMemoryTestEntity extends Entity<
LocalMemoryTestEntityFields,
string,
'id',
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
static defineCompanionDefinition(): EntityCompanionDefinition<
LocalMemoryTestEntityFields,
string,
'id',
ViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy
> {
return localMemoryTestEntityCompanionDefinition;
return {
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
};
}
}
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
LocalMemoryTestEntityFields,
string,
'id',
ViewerContext,
LocalMemoryTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
'id',
ViewerContext,
LocalMemoryTestEntity
>(),
@@ -49,7 +52,7 @@ export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
'id',
ViewerContext,
LocalMemoryTestEntity
>(),
@@ -57,7 +60,7 @@ export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
'id',
ViewerContext,
LocalMemoryTestEntity
>(),
@@ -65,36 +68,32 @@ export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
LocalMemoryTestEntityFields,
string,
'id',
ViewerContext,
LocalMemoryTestEntity
>(),
];
}
export const localMemoryTestEntityConfiguration =
new EntityConfiguration<LocalMemoryTestEntityFields>({
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',
});
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: LocalMemoryTestEntity,
entityConfiguration: localMemoryTestEntityConfiguration,
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
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'>[],
);
});
});
});
@@ -1,125 +0,0 @@
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
import GenericLocalMemoryCacher, {
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
} from '../GenericLocalMemoryCacher';
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
type BlahFields = {
id: string;
};
const entityConfiguration = new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'local-memory',
});
describe(LocalMemoryCacheAdapter, () => {
describe('loadManyAsync', () => {
it('returns appropriate cache results', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
await cacheAdapter.cacheManyAsync('id', cacheHits);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', [
'test-id-1',
'test-id-2',
'test-id-3',
]);
expect(results.get('test-id-1')).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'test-id-1' },
});
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
expect(results.size).toBe(3);
});
it('returns empty map when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
const results = await cacheAdapter.loadManyAsync('id', []);
expect(results).toEqual(new Map());
});
});
describe('cacheManyAsync', () => {
it('correctly caches all objects', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toMatchObject({
id: 'test-id-1',
});
});
});
describe('cacheDBMissesAsync', () => {
it('correctly caches misses', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
const cacheKey = cacheAdapter['partsCacher']['makeCacheKey']('id', 'test-id-1');
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
});
});
describe('invalidateManyAsync', () => {
it('invalidates correctly', async () => {
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
});
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
});
it('returns when passed empty array of fieldValues', async () => {
const cacheAdapter = new LocalMemoryCacheAdapter(
entityConfiguration,
GenericLocalMemoryCacher.createLRUCache<BlahFields>({
maxSize: Number.MAX_SAFE_INTEGER,
ttlSeconds: Number.MAX_SAFE_INTEGER,
})
);
await cacheAdapter.invalidateManyAsync('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';
@@ -1,48 +0,0 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
StubQueryContextProvider,
StubDatabaseAdapterProvider,
} from '@expo/entity';
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
const localMemoryCacheAdapterProvider =
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
? LocalMemoryCacheAdapterProvider.getNoOpProvider()
: LocalMemoryCacheAdapterProvider.getProvider(localMemoryOptions);
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'local-memory',
{
cacheAdapterProvider: localMemoryCacheAdapterProvider,
},
],
])
);
};
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
): EntityCompanionProvider => {
return createLocalMemoryIntegrationTestEntityCompanionProvider(
{ maxSize: 0, ttlSeconds: 0 },
metricsAdapter
);
};
@@ -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" }]
}
+216 -117
View File
@@ -3,234 +3,333 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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 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))
@@ -11,17 +11,16 @@ During `EntityCompanionProvider` instantiation:
```typescript
import Redis from 'ioredis';
const redisCacheAdapterContext = {
const genericRedisCacherContext = {
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}`)
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'ent-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
@@ -36,7 +35,7 @@ export const createDefaultEntityCompanionProvider = (
},
{
['redis']: {
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
},
}
);
@@ -1,38 +1,45 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.24.0",
"version": "0.61.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"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"
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"type": "module",
"dependencies": {
"ioredis": "^4.27.3"
"@expo/entity": "workspace:^"
},
"peerDependencies": {
"ioredis": ">=5"
},
"devDependencies": {
"@expo/entity": "^0.24.0"
"@expo/batcher": "1.0.0",
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.2.0",
"ioredis": "5.10.0",
"ts-mockito": "2.6.1",
"typescript": "5.9.3"
}
}
@@ -1,25 +1,101 @@
import {
import type {
CacheLoadResult,
CacheStatus,
EntityConfiguration,
IEntityGenericCacher,
IEntityLoadKey,
IEntityLoadValue,
} from '@expo/entity';
import {
CacheStatus,
transformCacheObjectToFields,
transformFieldsToCacheObject,
IEntityGenericCacher,
} from '@expo/entity';
import { Redis } from 'ioredis';
import { redisTransformerMap } from './RedisCommon';
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
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[];
};
export interface GenericRedisCacheContext {
/**
* Instance of ioredis.Redis
*/
redisClient: Redis;
redisClient: IRedis;
/**
* 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;
/**
* 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
@@ -32,23 +108,31 @@ export interface GenericRedisCacheContext {
* this TTL will be assumed not present in the database (unless invalidated).
*/
ttlSecondsNegative: number;
/**
* Configuration for cache invalidation strategy.
*/
invalidationConfig: GenericRedisCacheInvalidationConfig;
}
export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
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>
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
) {}
public async loadManyAsync(
keys: readonly string[]
keys: readonly string[],
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
if (keys.length === 0) {
return new Map();
}
const redisResults = await wrapNativeRedisCallAsync(() =>
this.context.redisClient.mget(...keys)
this.context.redisClient.mget(...keys),
);
const results = new Map<string, CacheLoadResult<TFields>>();
@@ -66,7 +150,7 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
item: transformCacheObjectToFields(
this.entityConfiguration,
redisTransformerMap,
JSON.parse(redisResult)
JSON.parse(redisResult),
),
});
} else {
@@ -88,10 +172,10 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
redisTransaction = redisTransaction.set(
key,
JSON.stringify(
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object),
),
'EX',
this.context.ttlSecondsPositive
this.context.ttlSecondsPositive,
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
@@ -108,7 +192,7 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
key,
DOES_NOT_EXIST_REDIS,
'EX',
this.context.ttlSecondsNegative
this.context.ttlSecondsNegative,
);
});
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
@@ -121,4 +205,57 @@ export default class GenericRedisCacher<TFields> implements IEntityGenericCacher
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 parts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
return this.context.makeKeyFn(
this.context.cacheKeyPrefix,
cacheKeyType,
this.entityConfiguration.tableName,
`v3.${cacheKeyVersion}`,
...parts,
);
}
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:
return this.context.invalidationConfig
.cacheKeyVersionsToInvalidateFn(this.entityConfiguration.cacheKeyVersion)
.map((cacheKeyVersion) =>
this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion),
);
}
}
}
@@ -1,77 +0,0 @@
import { EntityConfiguration, PartsCacheAdapter, Parts } from '@expo/entity';
import SimplePartsCacher from '@expo/entity/build/SimplePartsCacher';
import invariant from 'invariant';
import { Redis } from 'ioredis';
import GenericRedisCacher from './GenericRedisCacher';
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 PartsCacheAdapter<TFields> {
constructor(
entityConfiguration: EntityConfiguration<TFields>,
private readonly context: RedisCacheAdapterContext
) {
super(
entityConfiguration,
new SimplePartsCacher(
new GenericRedisCacher(
{
redisClient: context.redisClient,
ttlSecondsNegative: context.ttlSecondsNegative,
ttlSecondsPositive: context.ttlSecondsPositive,
},
entityConfiguration
),
context.makeKeyFn
)
);
}
getParts<N extends keyof TFields>(fieldName: N, fieldValue: NonNullable<TFields[N]>): Parts {
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
invariant(columnName, `database field mapping missing for ${fieldName}`);
return [
this.context.cacheKeyPrefix,
this.entityConfiguration.tableName,
`v2.${this.entityConfiguration.cacheKeyVersion}`,
columnName,
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,186 @@
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,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
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,234 @@
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()),
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
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);
});
});
@@ -1,68 +1,70 @@
import { CacheStatus, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { Redis } from 'ioredis';
import { URL } from 'url';
import GenericRedisCacher from '../GenericRedisCacher';
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity, {
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
import type { RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity.ts';
import {
RedisTestEntity,
redisTestEntityConfiguration,
RedisTestEntityFields,
} from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
} from '../__testfixtures__/RedisTestEntity.ts';
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
class TestViewerContext extends ViewerContext {}
describe(GenericRedisCacher, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
redisCacheAdapterContext = {
genericRedisCacheContext = {
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}`)
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
await (genericRedisCacheContext.redisClient as Redis).flushdb();
});
afterAll(async () => {
redisCacheAdapterContext.redisClient.disconnect();
(genericRedisCacheContext.redisClient as Redis).disconnect();
});
it('has correct caching and loading behavior', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
genericRedisCacheContext,
redisTestEntityConfiguration,
);
const date = new Date();
const buffer = Buffer.from('hello world');
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
.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 redisCacheAdapterContext.redisClient.get(testKey);
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(testKey);
const cachedValue = JSON.parse(cachedJSON!);
expect(cachedValue).toMatchObject({
id: entity1Created.getID(),
@@ -77,14 +79,11 @@ describe(GenericRedisCacher, () => {
});
expect(loadedObjectMap.size).toBe(1);
});
it('has correct negative caching behaviour', async () => {
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
genericRedisCacheContext,
redisTestEntityConfiguration,
);
const testKey = `test-id-key-non-existent-id`;
@@ -93,23 +92,22 @@ describe(GenericRedisCacher, () => {
const cacheLoadResult = loadedObjectMap.get(testKey)!;
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
});
it('has correct invalidation behaviour', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericRedisCacher = new GenericRedisCacher(
{
redisClient: redisCacheAdapterContext.redisClient,
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
},
redisTestEntityConfiguration
genericRedisCacheContext,
redisTestEntityConfiguration,
);
const date = new Date();
const buffer = Buffer.from('hello world');
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.setField('dateField', date)
.enforceCreateAsync();
.setField('bufferField', buffer)
.createAsync();
const testKey = `test-id-key-${entity1Created.getID()}`;
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
[testKey, entity1Created.getAllFields()],
@@ -1,139 +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 viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
RedisTestEntity.getCompanionDefinition()
)['tableDataCoordinator']['cacheAdapter'];
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
.enforceCreateAsync();
// loading an entity should put it in cache
const entity1 = await RedisTestEntity.loader(viewerContext)
.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(viewerContext).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(viewerContext).loadByIDAsync(
nonExistentId
);
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.loader(viewerContext).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 viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const date = new Date();
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('dateField', date).createAsync()
);
expect(entity1.getField('dateField')).toEqual(date);
const entity2 = await RedisTestEntity.loader(viewerContext)
.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);
});
it('caches and restores empty string field keys', async () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const entity1 = await enforceAsyncResult(
RedisTestEntity.creator(viewerContext).setField('name', '').createAsync()
);
const entity2 = await RedisTestEntity.loader(viewerContext)
.enforcing()
.loadByFieldEqualingAsync('name', '');
expect(entity2?.getID()).toEqual(entity1.getID());
// simulate new request
const vc2 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
);
const entity3 = await RedisTestEntity.loader(vc2)
.enforcing()
.loadByFieldEqualingAsync('name', '');
expect(entity3?.getID()).toEqual(entity1.getID());
});
});
@@ -1,46 +1,51 @@
import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
import Redis from 'ioredis';
import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { Redis } from 'ioredis';
import { URL } from 'url';
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
import RedisTestEntity from '../testfixtures/RedisTestEntity';
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
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(RedisCacheAdapter, () => {
let redisCacheAdapterContext: RedisCacheAdapterContext;
describe(GenericRedisCacher, () => {
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;
beforeAll(() => {
redisCacheAdapterContext = {
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
cacheKeyVersion: 1,
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
};
});
beforeEach(async () => {
await redisCacheAdapterContext.redisClient.flushdb();
await redisClient.flushdb();
});
it('throws when redis is disconnected', async () => {
redisCacheAdapterContext.redisClient.disconnect();
redisClient.disconnect();
const vc1 = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
await expect(
RedisTestEntity.creator(vc1).setField('name', 'blah').enforceCreateAsync()
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,307 @@
import {
CacheStatus,
EntityConfiguration,
SingleFieldHolder,
SingleFieldValueHolder,
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;
};
const entityConfiguration = new EntityConfiguration<BlahFields, 'id'>({
idField: 'id',
tableName: 'blah',
cacheKeyVersion: 2,
schema: {
id: new UUIDField({ columnName: 'id', 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),
makeKeyFn: (...parts) => parts.join(':'),
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>()),
makeKeyFn: (...parts) => parts.join(':'),
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),
makeKeyFn: (...parts) => parts.join(':'),
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.cacheManyAsync(new Map([[cacheKey, { 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([]);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
when(mockRedisClient.multi()).thenReturn(pipeline);
const genericCacher = new GenericRedisCacher(
{
redisClient: instance(mockRedisClient),
makeKeyFn: (...parts) => parts.join(':'),
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),
makeKeyFn: (...parts) => parts.join(':'),
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:v3.2:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
});
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),
makeKeyFn: (...parts) => parts.join(':'),
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:v3.1:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.2:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.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),
makeKeyFn: (...parts) => parts.join(':'),
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:v3.2:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.3:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.4:id:wat');
expect(cacheKeys[3]).toBe('hello-:single:blah:v3.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>()),
makeKeyFn: (...parts) => parts.join(':'),
cacheKeyPrefix: 'hello-',
ttlSecondsPositive: 1,
ttlSecondsNegative: 2,
invalidationConfig: {
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
},
},
entityConfiguration,
);
await genericCacher.invalidateManyAsync([]);
});
});
});
@@ -1,172 +0,0 @@
import { CacheStatus, UUIDField, EntityConfiguration } 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: 'postgres',
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['partsCacher']['makeCacheKey']('id', 'wat'),
JSON.stringify({ id: 'wat' })
);
redisResults.set(cacheAdapter['partsCacher']['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['partsCacher']['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['partsCacher']['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['partsCacher']['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', []);
});
});
});
@@ -1,11 +1,12 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import wrapNativeRedisCallAsync from '../wrapNativeRedisCallAsync';
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/no-throw-literal
// eslint-disable-next-line no-throw-literal,@typescript-eslint/only-throw-error
throw 'hello';
};
@@ -1,6 +1,6 @@
import { EntityCacheAdapterTransientError } from '@expo/entity';
export default async function wrapNativeRedisCallAsync<T>(fn: () => Promise<T>): Promise<T> {
export async function wrapNativeRedisCallAsync<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
@@ -4,9 +4,8 @@
* @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 { default as GenericRedisCacher } from './GenericRedisCacher';
export * from './GenericRedisCacher';
export * from './GenericRedisCacher.ts';
export * from './RedisCacheAdapterProvider.ts';
export * from './RedisCommon.ts';
export * from './errors/wrapNativeRedisCallAsync.ts';
export * from './utils/getSurroundingCacheKeyVersionsForInvalidation.ts';
@@ -1,95 +0,0 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
DateField,
StringField,
EntityConfiguration,
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;
}
}
export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
> {
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
RedisTestEntityFields,
string,
ViewerContext,
RedisTestEntity
>(),
];
protected override 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',
cache: true,
}),
dateField: new DateField({
columnName: 'date_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
entityClass: RedisTestEntity,
entityConfiguration: redisTestEntityConfiguration,
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
});
@@ -1,36 +0,0 @@
import {
NoOpEntityMetricsAdapter,
IEntityMetricsAdapter,
EntityCompanionProvider,
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,
new Map([
[
'postgres',
{
adapterProvider: new StubDatabaseAdapterProvider(),
queryContextProvider: StubQueryContextProvider,
},
],
]),
new Map([
[
'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" }]
}
+109
View File
@@ -0,0 +1,109 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [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))
+26
View File
@@ -0,0 +1,26 @@
# @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.61.0",
"description": "jscodeshift codemods for @expo/entity upgrades",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"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 eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
},
"engines": {
"node": ">=18"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"type": "module",
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/jscodeshift": "17.3.0",
"@types/node": "24.12.0",
"jscodeshift": "17.3.0",
"typescript": "5.9.3"
}
}
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,39 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [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.61.0",
"description": "Testing utilities for applications using Entity with Knex database adapter",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"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 eslint src",
"lint-fix": "yarn run lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
},
"engines": {
"node": ">=18"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"type": "module",
"dependencies": {
"@expo/entity": "workspace:^",
"@expo/entity-database-adapter-knex": "workspace:^",
"invariant": "^2.2.4",
"uuid": "^13.0.0"
},
"peerDependencies": {
"@jest/globals": "*"
},
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/invariant": "2.2.37",
"@types/node": "24.12.0",
"typescript": "5.9.3"
}
}
@@ -1,37 +1,49 @@
import invariant from 'invariant';
import { v4 as uuidv4 } from 'uuid';
import EntityConfiguration from '../../EntityConfiguration';
import EntityDatabaseAdapter, {
TableFieldSingleValueEqualityCondition,
TableFieldMultiValueEqualityCondition,
TableQuerySelectionModifiers,
OrderByOrdering,
} from '../../EntityDatabaseAdapter';
import { StringField, IntField } from '../../EntityFields';
import type { EntityConfiguration, FieldTransformerMap } from '@expo/entity';
import {
computeIfAbsent,
getDatabaseFieldForEntityField,
FieldTransformerMap,
IntField,
mapMap,
StringField,
transformFieldsToDatabaseObject,
} from '../../internal/EntityFieldTransformationUtils';
import { computeIfAbsent, mapMap } from '../collections/maps';
} 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 default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
export class StubPostgresDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
constructor(
private readonly entityConfiguration2: EntityConfiguration<T>,
private readonly dataStore: Map<string, Readonly<{ [key: string]: any }>[]>
private readonly entityConfiguration2: EntityConfiguration<TFields, TIDField>,
private readonly dataStore: Map<string, Readonly<{ [key: string]: any }>[]>,
) {
super(entityConfiguration2);
}
public static convertFieldObjectsToDataStore<T>(
entityConfiguration: EntityConfiguration<T>,
dataStore: Map<string, Readonly<T>[]>
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)
)
transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable),
),
);
}
@@ -43,69 +55,103 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
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,
tableField: string,
tableValues: readonly any[]
tableColumns: readonly string[],
tableTuples: (readonly any[])[],
): Promise<object[]> {
const objectCollection = this.getObjectCollectionForTable(tableName);
return tableValues.reduce((acc, fieldValue) => {
return acc.concat(
objectCollection.filter((obj) => {
return obj[tableField] === fieldValue;
})
);
}, [] as { [key: string]: any });
const results = StubPostgresDatabaseAdapter.uniqBy(tableTuples, (tuple) =>
tuple.join(':'),
).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];
}
private static compareByOrderBys(
orderBys: {
columnName: string;
order: OrderByOrdering;
}[],
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 }
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: {
// simulate NULLS FIRST for DESC
if (aField === null && bField === null) {
return 0;
} else if (aField === null) {
return -1;
} else if (bField === null) {
return 1;
}
return aField > bField
? -1
: aField < bField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
case OrderByOrdering.ASCENDING: {
// simulate NULLS LAST for ASC
if (aField === null && bField === null) {
return 0;
} else if (bField === null) {
return -1;
} else if (aField === null) {
return 1;
}
return bField > aField
? -1
: bField < aField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
}
}
@@ -115,7 +161,7 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
let filteredObjects = this.getObjectCollectionForTable(tableName);
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
@@ -129,7 +175,7 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
const orderBy = querySelectionModifiers.orderBy;
if (orderBy !== undefined) {
filteredObjects = filteredObjects.sort((a, b) =>
StubDatabaseAdapter.compareByOrderBys(orderBy, a, b)
StubPostgresDatabaseAdapter.compareByOrderBys(orderBy, a, b),
);
}
@@ -151,21 +197,33 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
_querySelectionModifiers: TableQuerySelectionModifiers
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
}
protected fetchManyBySQLFragmentInternalAsync(
_queryInterface: any,
_tableName: string,
_sqlFragment: SQLFragment<TFields>,
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
}
private generateRandomID(): any {
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
invariant(idSchemaField, `No schema field found for ${this.entityConfiguration2.idField}`);
invariant(
idSchemaField,
`No schema field found for ${String(this.entityConfiguration2.idField)}`,
);
if (idSchemaField instanceof StringField) {
return uuidv4();
return uuidv7();
} else if (idSchemaField instanceof IntField) {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
} else {
throw new Error(
`Unsupported ID type for StubDatabaseAdapter: ${idSchemaField.constructor.name}`
`Unsupported ID type for StubPostgresDatabaseAdapter: ${idSchemaField.constructor.name}`,
);
}
}
@@ -173,13 +231,13 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
protected async insertInternalAsync(
_queryInterface: any,
tableName: string,
object: object
object: object,
): Promise<object[]> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration2,
this.entityConfiguration2.idField
this.entityConfiguration2.idField,
);
const objectToInsert = {
[idField]: this.generateRandomID(),
@@ -194,7 +252,7 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
tableName: string,
tableIdField: string,
id: any,
object: object
object: object,
): Promise<object[]> {
// SQL does not support empty updates, mirror behavior here for better test simulation
if (Object.keys(object).length === 0) {
@@ -206,28 +264,38 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
const objectIndex = objectCollection.findIndex((obj) => {
return obj[tableIdField] === id;
});
invariant(objectIndex >= 0, 'should exist');
// SQL updates to a nonexistent row succeed but affect 0 rows,
// mirror that behavior here for better test simulation
if (objectIndex < 0) {
return [];
}
objectCollection[objectIndex] = {
...objectCollection[objectIndex],
...object,
};
return [objectCollection[objectIndex]!];
return [objectCollection[objectIndex]];
}
protected async deleteInternalAsync(
_queryInterface: any,
tableName: string,
tableIdField: string,
id: any
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,10 +1,11 @@
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import ViewerContext from '../ViewerContext';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
import {
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityConfiguration,
EntityPrivacyPolicy,
UUIDField,
} from '@expo/entity';
export type SimpleTestFields = {
id: string;
@@ -12,12 +13,13 @@ 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: 'postgres',
@@ -26,7 +28,7 @@ export const simpleTestEntityConfiguration = new EntityConfiguration<SimpleTestF
export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
@@ -34,7 +36,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
@@ -43,7 +45,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
@@ -52,7 +54,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
@@ -61,7 +63,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
string,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
@@ -69,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,928 @@
import {
CompositeFieldHolder,
CompositeFieldValueHolder,
EntityQueryContext,
SingleFieldHolder,
SingleFieldValueHolder,
} from '@expo/entity';
import { NullsOrdering, OrderByOrdering, sql } from '@expo/entity-database-adapter-knex';
import { describe, expect, it, jest } from '@jest/globals';
import { instance, mock } from 'ts-mockito';
import { validate, version } from 'uuid';
import { StubPostgresDatabaseAdapter } from '../StubPostgresDatabaseAdapter.ts';
import type { DateIDTestFields } from '../__testfixtures__/DateIDTestEntity.ts';
import { dateIDTestEntityConfiguration } from '../__testfixtures__/DateIDTestEntity.ts';
import type { SimpleTestFields } from '../__testfixtures__/SimpleTestEntity.ts';
import { simpleTestEntityConfiguration } from '../__testfixtures__/SimpleTestEntity.ts';
import type { TestFields } from '../__testfixtures__/TestEntity.ts';
import { testEntityConfiguration } from '../__testfixtures__/TestEntity.ts';
import type { NumberKeyFields } from '../__testfixtures__/TestEntityNumberKey.ts';
import { numberKeyEntityConfiguration } from '../__testfixtures__/TestEntityNumberKey.ts';
// uuid keeps state internally for v7 generation, so we fix the time for all tests for consistent test results
const expectedTime = new Date('2024-06-03T20:16:33.761Z');
jest.useFakeTimers({
now: expectedTime,
});
describe(StubPostgresDatabaseAdapter, () => {
describe('fetchManyWhereAsync', () => {
it('fetches many where single', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'wat',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
[new SingleFieldValueHolder('huh')],
);
expect(results.get(new SingleFieldValueHolder('huh'))).toHaveLength(1);
});
it('fetches many where composite', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'wat',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyWhereAsync(
queryContext,
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
[new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })],
);
expect(
results.get(new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })),
).toHaveLength(1);
const results2 = await databaseAdapter.fetchManyWhereAsync(
queryContext,
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
[new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })],
);
expect(
results2.get(new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })),
).toHaveLength(0);
});
it('handles duplicate tuples and filters them out', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'id1',
testIndexedField: 'h1',
intField: 5,
stringField: 'test1',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'id2',
testIndexedField: 'h2',
intField: 10,
stringField: 'test2',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
// Fetch with duplicate field values - this will cause uniqBy to filter duplicates
const results = await databaseAdapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder('customIdField'),
[
new SingleFieldValueHolder('id1'),
new SingleFieldValueHolder('id2'),
new SingleFieldValueHolder('id1'),
new SingleFieldValueHolder('id2'),
],
);
// Should only get 2 unique results despite passing 4 values (2 duplicates)
expect(results.get(new SingleFieldValueHolder('id1'))).toHaveLength(1);
expect(results.get(new SingleFieldValueHolder('id2'))).toHaveLength(1);
// Verify the actual objects returned
const id1Results = results.get(new SingleFieldValueHolder('id1'));
expect(id1Results?.[0]).toMatchObject({ customIdField: 'id1', stringField: 'test1' });
const id2Results = results.get(new SingleFieldValueHolder('id2'));
expect(id2Results?.[0]).toMatchObject({ customIdField: 'id2', stringField: 'test2' });
});
});
describe('fetchOneWhereAsync', () => {
it('fetches one where single', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
new SingleFieldValueHolder('huh'),
);
expect(result).toMatchObject({
stringField: 'huh',
});
});
it('returns null when no record found', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
new SingleFieldValueHolder('huh'),
);
expect(result).toBeNull();
});
it('fetches one where composite', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
);
expect(result).toMatchObject({
stringField: 'huh',
intField: 5,
});
});
});
describe('fetchManyByFieldEqualityConjunctionAsync', () => {
it('supports conjuntions and query modifiers', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'c',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[
{
fieldName: 'customIdField',
fieldValues: ['hello', 'world'],
},
{
fieldName: 'intField',
fieldValue: 3,
},
],
{
limit: 2,
offset: 1,
orderBy: [
{
fieldName: 'stringField',
order: OrderByOrdering.DESCENDING,
},
],
},
);
expect(results).toHaveLength(2);
expect(results.map((e) => e.stringField)).toEqual(['b', 'a']);
});
it('supports multiple order bys', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'c',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[
{
fieldName: 'intField',
fieldValue: 3,
},
],
{
orderBy: [
{
fieldName: 'intField',
order: OrderByOrdering.DESCENDING,
},
{
fieldName: 'stringField',
order: OrderByOrdering.DESCENDING,
},
],
},
);
expect(results).toHaveLength(3);
expect(results.map((e) => e.stringField)).toEqual(['c', 'b', 'a']);
});
it('supports null field values', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: '1',
testIndexedField: 'h1',
intField: 1,
stringField: 'a',
dateField: new Date(),
nullableField: 'a',
},
{
customIdField: '2',
testIndexedField: 'h2',
intField: 2,
stringField: 'a',
dateField: new Date(),
nullableField: 'b',
},
{
customIdField: '3',
testIndexedField: 'h3',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: '4',
testIndexedField: 'h4',
intField: 4,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[{ fieldName: 'nullableField', fieldValue: null }],
{},
);
expect(results).toHaveLength(2);
expect(results[0]!.nullableField).toBeNull();
const results2 = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[
{ fieldName: 'nullableField', fieldValues: ['a', null] },
{ fieldName: 'stringField', fieldValue: 'a' },
],
{
orderBy: [
{
fieldName: 'nullableField',
order: OrderByOrdering.DESCENDING,
},
],
},
);
expect(results2).toHaveLength(2);
expect(results2.map((e) => e.nullableField)).toEqual([null, 'a']);
});
});
describe('fetchManyByRawWhereClauseAsync', () => {
it('throws because it is unsupported', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
await expect(
databaseAdapter.fetchManyByRawWhereClauseAsync(queryContext, '', [], {}),
).rejects.toThrow();
});
});
describe('fetchManyBySQLFragmentAsync', () => {
it('throws because it is unsupported', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
await expect(
databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, sql``, {}),
).rejects.toThrow();
});
});
describe('insertAsync', () => {
it('inserts a record', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
const result = await databaseAdapter.insertAsync(queryContext, {
stringField: 'hello',
});
expect(result).toMatchObject({
stringField: 'hello',
});
expect(
databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName),
).toHaveLength(1);
});
it('inserts a record with valid v7 id', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
const result = await databaseAdapter.insertAsync(queryContext, {
stringField: 'hello',
});
const ts = getTimeFromUUIDv7(result.customIdField);
expect(ts).toEqual(expectedTime);
});
});
describe('updateAsync', () => {
it('updates a record', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const result = await databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {
stringField: 'b',
});
expect(result).toMatchObject({
stringField: 'b',
testIndexedField: 'h1',
});
});
it('throws error when empty update to match common DBMS behavior', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
await expect(
databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {}),
).rejects.toThrow(`Empty update (custom_id = hello)`);
});
it('throws error when updating nonexistent record', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'existing-id',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
// Try to update a record that doesn't exist
await expect(
databaseAdapter.updateAsync(
queryContext,
'customIdField',
'nonexistent-id', // This ID doesn't exist in the data store
{ stringField: 'updated' },
),
).rejects.toThrow('Empty results from database adapter update');
});
});
describe('deleteAsync', () => {
it('deletes an object', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
await databaseAdapter.deleteAsync(queryContext, 'customIdField', 'hello');
expect(
databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName),
).toHaveLength(0);
});
it('handles deletion of nonexistent record gracefully', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'existing-id',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
// Delete a record that doesn't exist
// Unlike update, delete doesn't throw an error when the record doesn't exist
await databaseAdapter.deleteAsync(
queryContext,
'customIdField',
'nonexistent-id', // This ID doesn't exist in the data store
);
});
});
it('supports string and number IDs', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter1 = new StubPostgresDatabaseAdapter<SimpleTestFields, 'id'>(
simpleTestEntityConfiguration,
new Map(),
);
const insertedObject1 = await databaseAdapter1.insertAsync(queryContext, {});
expect(typeof insertedObject1.id).toBe('string');
const databaseAdapter2 = new StubPostgresDatabaseAdapter<NumberKeyFields, 'id'>(
numberKeyEntityConfiguration,
new Map(),
);
const insertedObject2 = await databaseAdapter2.insertAsync(queryContext, {});
expect(typeof insertedObject2.id).toBe('number');
const databaseAdapter3 = new StubPostgresDatabaseAdapter<DateIDTestFields, 'id'>(
dateIDTestEntityConfiguration,
new Map(),
);
await expect(databaseAdapter3.insertAsync(queryContext, {})).rejects.toThrow(
'Unsupported ID type for StubPostgresDatabaseAdapter: DateField',
);
});
});
describe('compareByOrderBys', () => {
describe('comparison with default nulls ordering', () => {
it.each([
// Default nulls ordering: NULLS FIRST for DESC, NULLS LAST for ASC
// nulls compare with 0
[OrderByOrdering.DESCENDING, null, 0, -1],
[OrderByOrdering.ASCENDING, null, 0, 1],
[OrderByOrdering.DESCENDING, 0, null, 1],
[OrderByOrdering.ASCENDING, 0, null, -1],
// nulls compare with nulls (fall through to next order by, which is empty, so 0)
[OrderByOrdering.DESCENDING, null, null, 0],
[OrderByOrdering.ASCENDING, null, null, 0],
// nulls compare with -1
[OrderByOrdering.DESCENDING, null, -1, -1],
[OrderByOrdering.ASCENDING, null, -1, 1],
[OrderByOrdering.DESCENDING, -1, null, 1],
[OrderByOrdering.ASCENDING, -1, null, -1],
// basic compares
[OrderByOrdering.ASCENDING, 'a', 'b', -1],
[OrderByOrdering.ASCENDING, 'b', 'a', 1],
[OrderByOrdering.DESCENDING, 'a', 'b', 1],
[OrderByOrdering.DESCENDING, 'b', 'a', -1],
])('case (%p; %p; %p)', (order, v1, v2, expectedResult) => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order,
nulls: undefined,
},
],
{
hello: v1,
},
{
hello: v2,
},
),
).toEqual(expectedResult);
});
it('throws for SQL fragment order by', () => {
expect(() =>
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnFragment: sql`some_function(col)`,
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
],
{ hello: 'a' },
{ hello: 'b' },
),
).toThrow('SQL fragment order by not supported for StubDatabaseAdapter');
});
it('works for empty', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[],
{
hello: 'test',
},
{
hello: 'blah',
},
),
).toEqual(0);
});
});
describe('comparison with explicit nulls ordering', () => {
it.each([
// ASC NULLS FIRST: nulls come before non-nulls
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, null, 0, -1],
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, 0, null, 1],
// ASC NULLS LAST: nulls come after non-nulls (same as default ASC)
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, null, 0, 1],
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, 0, null, -1],
// DESC NULLS FIRST: nulls come before non-nulls (same as default DESC)
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, null, 0, -1],
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, 0, null, 1],
// DESC NULLS LAST: nulls come after non-nulls
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, null, 0, 1],
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, 0, null, -1],
// Non-null values should not be affected by nulls ordering
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, 'a', 'b', -1],
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, 'a', 'b', -1],
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, 'a', 'b', 1],
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, 'a', 'b', 1],
// Both null should fall through to next order by (which is empty, so 0)
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, null, null, 0],
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, null, null, 0],
])('case (%p; nulls %p; %p; %p)', (order, nulls, v1, v2, expectedResult) => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order,
nulls,
},
],
{
hello: v1,
},
{
hello: v2,
},
),
).toEqual(expectedResult);
});
});
describe('recursing', () => {
it('recurses to secondary order by on tie', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
{
columnName: 'world',
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
],
{
hello: 'a',
world: 1,
},
{
hello: 'a',
world: 2,
},
),
).toEqual(-1);
});
it('recurses to secondary order by when both values are null', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
{
columnName: 'world',
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
],
{
hello: null,
world: 1,
},
{
hello: null,
world: 2,
},
),
).toEqual(-1);
});
});
});
/**
* Returns the Date object encoded in the first 48 bits of the given UUIDv7.
* @throws TypeError if the UUID is not version 7
*/
function getTimeFromUUIDv7(uuid: string): Date {
if (!(validate(uuid) && version(uuid) === 7)) {
throw new TypeError(`Invalid UUID: ${uuid}`);
}
// The first 48 bits = 12 hex characters of the UUID encode the timestamp in big endian
const hexCharacters = uuid.replaceAll('-', '').split('', 12);
const milliseconds = hexCharacters.reduce(
(milliseconds, character) => milliseconds * 16 + parseInt(character, 16),
0,
);
return new Date(milliseconds);
}
@@ -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,8 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"references": [
{ "path": "../entity" },
{ "path": "../entity-database-adapter-knex" }
]
}
+261 -139
View File
@@ -3,270 +3,392 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
# [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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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 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))
- 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))
- 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))
- 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))
- 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))
- 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))
- 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))
@@ -1,39 +1,42 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.24.0",
"version": "0.61.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"main": "build/index.js",
"types": "build/index.d.ts",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"tsc": "tsc",
"clean": "rm -rf build coverage coverage-integration",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"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"
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn lint --fix",
"test": "yarn test:all --rootDir $(pwd)",
"integration": "yarn integration:all --rootDir $(pwd)"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"peerDependencies": {
"@expo/entity": "*"
},
"type": "module",
"dependencies": {
"knex": "^1.0.2"
"@expo/entity": "workspace:^",
"knex": "^3.1.0"
},
"devDependencies": {
"@expo/entity": "^0.24.0",
"pg": "^8.7.1"
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.2.0",
"pg": "8.20.0",
"ts-mockito": "2.6.1",
"typescript": "5.9.3"
}
}
@@ -0,0 +1,540 @@
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);
}
/**
* 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
* @throws Error when rawWhereClause or bindings are invalid
*
* @deprecated Use loadManyBySQL instead for safer value bindings and more flexible query building.
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.knexDataManager.loadManyByRawWhereClauseAsync(
this.queryContext,
rawWhereClause,
bindings,
querySelectionModifiers,
);
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
/**
* 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,317 @@
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 raw WHERE clause.
*
* @param queryContext - query context with which to perform the fetch
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of objects matching the query
*/
async fetchManyByRawWhereClauseAsync(
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
rawWhereClause,
bindings,
this.convertToTableQueryModifiers(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
protected abstract fetchManyByRawWhereClauseInternalAsync(
queryInterface: Knex,
tableName: string,
rawWhereClause: string,
bindings: object | any[],
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[]>;
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,
};
}
}
@@ -0,0 +1,115 @@
import type {
EntityLoaderOrderByClause,
EntityLoaderQuerySelectionModifiers,
} from './AuthorizationResultBasedKnexEntityLoader.ts';
import type { NullsOrdering } from './BasePostgresEntityDatabaseAdapter.ts';
import { OrderByOrdering } from './BasePostgresEntityDatabaseAdapter.ts';
import type { SQLFragment } from './SQLOperator.ts';
/**
* Base SQL query builder that provides common functionality for building SQL queries.
*/
export abstract class BaseSQLQueryBuilder<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
TResultType,
> {
private executed = false;
constructor(
private readonly sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
private readonly modifiers: {
limit?: number;
offset?: number;
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
},
) {}
/**
* Limit the number of results
*/
limit(n: number): this {
this.modifiers.limit = n;
return this;
}
/**
* Skip a number of results
*/
offset(n: number): this {
this.modifiers.offset = n;
return this;
}
/**
* Order by a field. Can be called multiple times to add multiple order bys.
*/
orderBy(
fieldName: TSelectedFields,
order: OrderByOrdering = OrderByOrdering.ASCENDING,
nulls: NullsOrdering | undefined = undefined,
): this {
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order, nulls }];
return this;
}
/**
* Order by a SQL fragment expression.
* Provides type-safe, parameterized ORDER BY clauses
*
* @example
* ```ts
* query.orderByFragment(
* sql`(data->>'createdAt')::timestamp`,
* OrderByOrdering.DESCENDING,
* );
* // Generates ORDER BY clause that orders by the createdAt field in the JSONB data column, cast to a timestamp, in descending order.
* // Note that the SQL fragment is parameterized, so it is safe from SQL injection.
* // The generated SQL would look like: ORDER BY (data->>'createdAt')::timestamp DESC
* ```
*
* @param fragment - The SQL fragment to order by. Must not include the ASC/DESC keyword, as ordering direction is determined by the `order` parameter.
* @param order - The ordering direction (ascending or descending). Defaults to ascending.
*/
orderBySQL(
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
order: OrderByOrdering = OrderByOrdering.ASCENDING,
nulls: NullsOrdering | undefined = undefined,
): this {
this.modifiers.orderBy = [
...(this.modifiers.orderBy ?? []),
{ fieldFragment: fragment, order, nulls },
];
return this;
}
/**
* Get the current modifiers as QuerySelectionModifiersWithOrderByFragment<TFields>
*/
protected getModifiers(): EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
return this.modifiers;
}
/**
* Get the SQL fragment
*/
protected getSQLFragment(): SQLFragment<Pick<TFields, TSelectedFields>> {
return this.sqlFragment;
}
/**
* Execute the query and return results.
* Implementation depends on the specific loader type.
*/
public async executeAsync(): Promise<readonly TResultType[]> {
if (this.executed) {
throw new Error(
'Query has already been executed. Create a new query builder to execute again.',
);
}
this.executed = true;
return await this.executeInternalAsync();
}
protected abstract executeInternalAsync(): Promise<readonly TResultType[]>;
}
Loaded 100 of 403 files, more files were not shown because too many files have changed in this diff. Show more