Compare commits

..
Author SHA1 Message Date
William Schurman 6222f0f464 feat: dataloaders within transactions 2020-12-10 15:34:56 -08:00
404 changed files with 19873 additions and 61124 deletions

No files matched your search

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