├── .eslintrc.js ├── .github └── workflows │ ├── ci.yaml │ └── publish.yaml ├── .gitignore ├── .nvmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── documentation ├── AdvancedSingleEntityOperations.md ├── Entities.md ├── GSIs.md ├── GettingStartedWithOperations.md ├── QueryingAndScanningMultipleEntities.md ├── README.md ├── Setup.md ├── SingleEntityTableQueriesAndTableScans.md ├── TransactionalOperations.md └── images │ ├── chickens.png │ └── farms.png ├── examples ├── .nvmrc ├── LICENSE ├── package-lock.json ├── package.json ├── src │ ├── example1Sheep.ts │ ├── example2Chickens.ts │ └── example3Farms.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── src └── lib │ ├── dynamoDBInterface.ts │ ├── entities.ts │ ├── entityStore.ts │ ├── index.ts │ ├── internal │ ├── common │ │ ├── deleteCommon.ts │ │ ├── gsiQueryCommon.ts │ │ ├── operationsCommon.ts │ │ ├── putCommon.ts │ │ ├── queryAndScanCommon.ts │ │ └── updateCommon.ts │ ├── entityContext.ts │ ├── multipleEntities │ │ ├── multipleEntitiesQueryAndScanCommon.ts │ │ ├── multipleEntityQueryOperations.ts │ │ ├── multipleEntityScanOperation.ts │ │ └── tableBackedMultipleEntityOperations.ts │ ├── singleEntity │ │ ├── batchDeleteItems.ts │ │ ├── batchGetItems.ts │ │ ├── batchPutItems.ts │ │ ├── batchWriteCommon.ts │ │ ├── deleteItem.ts │ │ ├── getItem.ts │ │ ├── putItem.ts │ │ ├── queryItems.ts │ │ ├── scanItems.ts │ │ ├── singleEntityCommon.ts │ │ ├── tableBackedSingleEntityAdvancedOperations.ts │ │ ├── tableBackedSingleEntityOperations.ts │ │ └── updateItem.ts │ ├── tableBackedConfigurationResolver.ts │ └── transactions │ │ ├── conditionCheckOperation.ts │ │ ├── tableBackedGetTransactionBuilder.ts │ │ └── tableBackedWriteTransactionBuilder.ts │ ├── multipleEntityOperations.ts │ ├── singleEntityAdvancedOperations.ts │ ├── singleEntityOperations.ts │ ├── support │ ├── entitySupport.ts │ ├── index.ts │ ├── querySupport.ts │ └── setupSupport.ts │ ├── tableBackedStore.ts │ ├── tableBackedStoreConfiguration.ts │ ├── transactionOperations.ts │ └── util │ ├── collections.ts │ ├── dateAndTime.ts │ ├── errors.ts │ ├── index.ts │ ├── logger.ts │ └── types.ts ├── test ├── examples │ ├── catTypeAndEntity.ts │ ├── chickenTypeAndEntity.ts │ ├── dogTypeAndEntity.ts │ ├── duckTypeAndEntity.ts │ ├── farmTypeAndEntity.ts │ ├── sheepTypeAndEntity.ts │ ├── template.yaml │ └── testData.ts ├── integration │ ├── integrationTests.test.ts │ ├── testSupportCode │ │ └── integrationTestEnvironment.ts │ └── vitest.config.ts └── unit │ ├── dateAndTime.test.ts │ ├── internal │ ├── entityContext.test.ts │ ├── getOperations.test.ts │ ├── operationsCommon.test.ts │ ├── putOperations.test.ts │ └── updateOperations.test.ts │ ├── support │ ├── entitySupport.test.ts │ ├── querySupport.test.ts │ └── setupSupport.test.ts │ ├── tableBackedEntityStore.test.ts │ ├── testSupportCode │ ├── entityContextSupport.ts │ └── fakes │ │ ├── fakeClock.ts │ │ ├── fakeDynamoDBInterface.ts │ │ ├── fakeLogger.ts │ │ └── fakeSupport.ts │ └── util │ ├── collections.test.ts │ └── errors.test.ts ├── tsconfig-build-cjs.json ├── tsconfig-build-esm.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | env: { 4 | node: true, 5 | es2020: true 6 | }, 7 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 8 | overrides: [], 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { 11 | ecmaVersion: 'latest', 12 | sourceType: 'module' 13 | }, 14 | plugins: ['@typescript-eslint'] 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | # Only allow one run at a time for this workflow 10 | # See https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency 11 | # This workflow will only run once per this workflow name, and per ref (which is the branch or tag) 12 | concurrency: ${{ github.workflow }}-${{ github.ref }} 13 | 14 | # Required because we are using OIDC 15 | permissions: 16 | id-token: write 17 | contents: read 18 | 19 | jobs: 20 | ci: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version-file: '.nvmrc' 29 | cache: 'npm' 30 | 31 | - name: Configure AWS Credentials 32 | uses: aws-actions/configure-aws-credentials@v2 33 | with: 34 | role-to-assume: ${{ secrets.OIDC_ROLE }} 35 | aws-region: us-east-1 36 | 37 | - name: Run tests 38 | env: 39 | STACK_NAME: dynamodb-entity-store-ci 40 | run: > 41 | npm install && npm run deploy-and-all-checks && npm run build 42 | 43 | # To test that doc generation works 44 | - run: npm run generate-docs 45 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version-file: '.nvmrc' 21 | cache: 'npm' 22 | registry-url: https://registry.npmjs.org 23 | 24 | # Capture the name of the published release to an environment variable 25 | # See https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions 26 | - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 27 | - run: npm install 28 | # Modify package.json, inserting version attribute 29 | - run: npm version $RELEASE_VERSION --no-git-tag-version 30 | # Perform publish, which will also run prepublishOnly 31 | - run: npm publish --access public 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | 35 | - run: npm run generate-docs 36 | 37 | - uses: actions/upload-pages-artifact@v2 38 | with: 39 | path: 'docs/' 40 | 41 | - uses: actions/deploy-pages@v2 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs 4 | 5 | yarn-error.log -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "trailingComma": "none", 5 | "singleQuote": true, 6 | "printWidth": 110 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Symphonia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /documentation/README.md: -------------------------------------------------------------------------------- 1 | # Developer Manual 2 | 3 | For an overview of using DynamoDB Entity Store, please see the [README](../README.md). 4 | 5 | 1. [Setup](Setup.md) 6 | 2. [Entities](Entities.md) 7 | 3. [Getting Started With Operations](GettingStartedWithOperations.md) 8 | 4. [Single Entity Table Queries and Table Scans](SingleEntityTableQueriesAndTableScans.md) 9 | 5. [Using Global Secondary Indexes](GSIs.md) 10 | 6. [Advanced Single Entity Operations](AdvancedSingleEntityOperations.md) 11 | 7. [Querying and Scanning Multiple Entities](QueryingAndScanningMultipleEntities.md) 12 | 8. [Transactional Operations](TransactionalOperations.md) -------------------------------------------------------------------------------- /documentation/TransactionalOperations.md: -------------------------------------------------------------------------------- 1 | # Chapter 8 - Transactional Operations 2 | 3 | DynamoDB transactions allow you to group multiple _actions_ into one transactional operation. 4 | They're more limited than what some people may be used to from transactions in relational databases, but here are a couple of examples of when I've used DynamoDB transactions: 5 | 6 | * When I want to put two related items, but I only want to put both items if both satisfy a condition check 7 | * When I want to get two related items in a fast moving system, and know for sure that both items represented the same point in time 8 | 9 | The AWS docs have a [section devoted to DynamoDB transactions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transactions.html) so I recommend you start with that if you're new to this area. 10 | 11 | DynamoDB has two different types of transactional operation - `TransactWriteItems` and `TransactGetItems`. 12 | _DynamoDB Entity Store_ supports both types. 13 | 14 | Transactions often involve multiple types of entity, and one of the powerful aspects of DynamoDB transactional operations is that they support multiple tables in one operation. 15 | Because of these points _DynamoDB Entity Store_ transactional operations support multiple entities, **and** support multiple tables. 16 | 17 | I start by explaining 'get' transactions since they're more simple, and then I move on to 'write' transactions. 18 | 19 | ## Get Transactions 20 | 21 | Here's an example of using Entity Store's Get Transaction support: 22 | 23 | ```typescript 24 | const entityStore = createStore(createStandardSingleTableConfig('AnimalsTable')) 25 | const response = await store.transactions 26 | .buildGetTransaction(SHEEP_ENTITY) 27 | .get({ breed: 'merino', name: 'shaun'}) 28 | .get({ breed: 'alpaca', name: 'alison' }) 29 | .get({ breed: 'merino', name: 'bob' }) 30 | .nextEntity(CHICKEN_ENTITY) 31 | .get({breed: 'sussex', name: 'ginger'}) 32 | .execute() 33 | ``` 34 | 35 | Which results in an object like this: 36 | 37 | ```typescript 38 | { 39 | itemsByEntityType: { 40 | sheep: [{ breed: 'merino', name: 'shaun', ageInYears: 3 }, null, { breed: 'merino', name: 'bob', ageInYears: 4 }] 41 | chicken: [{breed: 'sussex', name: 'ginger', dateOfBirth: '2021-07-01', coop: 'bristol'}] 42 | } 43 | } 44 | ``` 45 | 46 | You start a get transaction by calling [`.transactions.buildGetTransaction(firstEntity)`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/TransactionOperations.html#buildGetTransaction) on the top level store object. 47 | This provides a "builder" object that you can use to provide the item keys you want to get, and finally you call `.execute()` to execute the transaction request. 48 | 49 | The builder object works as follows. 50 | 51 | ### First entity and `nextEntity()` 52 | 53 | Like the single entity operations, `.buildGetTransaction()` interprets actions on an entity-by-entity basis. 54 | In other words each of the get-actions you specify are in the context of an entity, but you can have different entities for different actions. 55 | To kick things off you specify the entity for your first get action. 56 | 57 | `buildGetTransaction()` takes one required parameter - an `Entity`. 58 | Once you've specified all the get-actions for one entity you can then, if necessary, specify actions for a different entity by calling [`nextEntity()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/GetTransactionBuilder.html#nextEntity). 59 | This also takes one required parameter - another instance of `Entity`. 60 | You use the result of `nextEntity()` to add the next actions, and to add more Entity Types if necessary. 61 | 62 | DynamoDB Entity Store transactions support multiple tables, which means a couple of things: 63 | * You can use transactions in single or multi-table configurations 64 | * Each of the entities you specify in one `buildGetTransaction()` operation can be for one or multiple tables 65 | 66 | Furthermore, **unlike** the [multi-entity collection operations](QueryingAndScanningMultipleEntities.md), you aren't required to have an _entityType_ attribute on your table(s). 67 | If your configuration works for regular single-entity `get` operations, it will work for transactional gets too. 68 | 69 | ### `.get()` 70 | 71 | Once you've specified an entity - either the first entity when you call `.buildGetTransaction()`, or subsequent entities by calling `nextEntity()` - you specify "get-actions" in the context of **the most recently specified entity**. 72 | 73 | Each get-action is specified by one call to [`.get()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/GetTransactionBuilder.html#get), which takes one argument - a `keySource` used, along with the entity, to generate the key for desired object. 74 | This uses precisely the same logic as `.getOrThrow()` or `.getOrUndefined()` as described in [chapter 3](GettingStartedWithOperations.md). 75 | 76 | Because the library uses a builder pattern for transactions make sure to use the result of each `.get()` for whatever you do next. 77 | 78 | DynamoDB's _TransactGetItems_ logic takes an ordered array of up to 100 get actions, and so you can specify up to 100 actions with a single call to `buildGetTransaction()`. 79 | 80 | Further, since the list of actions is ordered then if necessary you can switch back to a previously specified entity as part of setting up a transaction. 81 | E.g. the following call is valid: 82 | 83 | ```typescript 84 | await store.transactions 85 | .buildGetTransaction(SHEEP_ENTITY) 86 | .get({ breed: 'merino', name: 'shaun'}) 87 | .nextEntity(CHICKEN_ENTITY) 88 | .get({breed: 'sussex', name: 'ginger'}) 89 | .nextEntity(SHEEP_ENTITY) 90 | .get({ breed: 'alpaca', name: 'alison' }) 91 | .nextEntity(CHICKEN_ENTITY) 92 | .get({breed: 'sussex', name: 'babs'}) 93 | .execute() 94 | ``` 95 | 96 | ### `.execute()`, and response 97 | 98 | When you've specified all the get-actions you call [`.execute()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/GetTransactionBuilder.html#execute) to perform the operation. 99 | 100 | `.execute()` has an optional `options` parameter, which allows you to request capacity metadata (with the `consumedCapacity` field). 101 | This works in the same way as was described in [chapter 6](AdvancedSingleEntityOperations.md). 102 | 103 | If `.execute()` is successful it returns an object of type [`GetTransactionResponse`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/GetTransactionResponse.html). 104 | This has one required field - `itemsByEntityType`. 105 | As shown in the example above, `itemsByEntityType` is a map from entity type to array of the parsed results of each get-action. 106 | Note that the entity type comes solely from the entity object that was in scope when each action was specified - the underlying table items **do not** need an entity-type attribute. 107 | 108 | Each array of parsed items (per entity type) is in the same order as was originally created in the `buildGetTransaction()` chain. 109 | Further, if a particular item didn't exist in the table then it is represented in the result array with a `null`. 110 | 111 | If you specified options on the call to `.execute()` then look for metadata on the `.metadata` field in the way described in [chapter 6](AdvancedSingleEntityOperations.md). 112 | 113 | ## Write Transactions 114 | 115 | Write Transactions in Entity Store work very similarly to Get Transactions. 116 | The main differences are each action is more complicated, and there's not much interesting on the response. 117 | Here's an example: 118 | 119 | ```typescript 120 | const entityStore = createStore(createStandardSingleTableConfig('AnimalsTable')) 121 | await store.transactions 122 | .buildWriteTransaction(SHEEP_ENTITY) 123 | .put({ breed: 'merino', name: 'shaun', ageInYears: 3 }, 124 | { conditionExpression: 'attribute_not_exists(PK)' }) 125 | .put({ breed: 'merino', name: 'bob', ageInYears: 4 }) 126 | .nextEntity(CHICKEN_ENTITY) 127 | .put({ breed: 'sussex', name: 'ginger', dateOfBirth: '2021-07-01', coop: 'bristol' }, 128 | { conditionExpression: 'attribute_not_exists(PK)' }) 129 | .execute() 130 | ``` 131 | 132 | Write transactions have the same pattern as get transactions, specifically: 133 | 134 | * Start specifying a transaction by calling `buildWriteTransaction(firstEntity)`, passing the entity for the first action 135 | * This returns a builder-object you can use for specifying the rest of the operation 136 | * Use action specifiers 137 | * Call `.nextEntity(entity)` to change the entity context for the next action(s) 138 | * Call `.execute()` to finalize the operation, and make the request to DynamoDB 139 | 140 | Just like get transactions, write transactions support multiple entities and multiple tables. 141 | 142 | ### Action specifiers 143 | 144 | Each write transaction consists of an ordered list of one or more actions. 145 | There are four different action types, each of which have their own function on the transaction builder. 146 | 147 | * [`.put()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#put) 148 | * [`.update()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#update) 149 | * [`.delete()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#delete) 150 | * [`.conditionCheck()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#conditionCheck) 151 | 152 | `.put()`, `.update()`, `.delete()` all work in almost exactly the same way as their _standard_ single-item equivalent operations, so if in doubt see 153 | [chapter 3](GettingStartedWithOperations.md) for what this means. 154 | 155 | The only difference is that they can each take an additional field on their options argument - `returnValuesOnConditionCheckFailure`. 156 | See [the AWS docs](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html#DDB-PutItem-request-ReturnValuesOnConditionCheckFailure) for an explanation. 157 | 158 | For transactions you'll often be using condition expressions for some / all of these actions, and expression specification works the same way for transactions as it does for single-item operations. 159 | 160 | `.conditionCheck()` is specific for write transactions. 161 | Its parameters are the same as those for `.delete()` with two differences: 162 | 163 | * The second parameter - `options` - is required 164 | * The `conditionExpression` field on the options parameter is required 165 | 166 | DynamoDB interprets a condition check in the same way as it does a delete, except it doesn't actually make any data changes. 167 | For more details, see the [_TransactWriteItems_ docs](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html). 168 | 169 | ### `.execute()` 170 | 171 | [`.execute()`](https://symphoniacloud.github.io/dynamodb-entity-store/interfaces/WriteTransactionBuilder.html#execute) works in mostly the same way as it does for get-expressions in that it builds the full transaction request, and makes the call to DynamoDB. 172 | 173 | You may specify `returnConsumedCapacity` and `returnItemCollectionMetrics` fields on `.execute()`'s options to retrieve diagnostic metadata on the response. 174 | 175 | You may also specify a write-transaction specific option - `clientRequestToken`. 176 | This is passed to DynamoDB, and you can read more about it in the [AWS Docs](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#API_TransactWriteItems_RequestParameters). 177 | 178 | `.execute()` returns an empty response, unless you specify either/both of the metadata options, in which case the response will have a `.metadata` field. 179 | 180 | ## Congratulations! 181 | 182 | You've made it to the end of the manual! That's it, no more to see! If you have any questions [drop me a line](mailto:mike@symphonia.io), or use the issues in the GitHub project. 183 | -------------------------------------------------------------------------------- /documentation/images/chickens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/symphoniacloud/dynamodb-entity-store/c55eae6a94939e6cafc04fc0a42fcf4cbb7c6099/documentation/images/chickens.png -------------------------------------------------------------------------------- /documentation/images/farms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/symphoniacloud/dynamodb-entity-store/c55eae6a94939e6cafc04fc0a42fcf4cbb7c6099/documentation/images/farms.png -------------------------------------------------------------------------------- /examples/.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /examples/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Symphonia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symphoniacloud/dynamodb-entity-store-examples", 3 | "description": "Examples for @symphoniacloud/dynamodb-entity-store", 4 | "license": "MIT", 5 | "author": { 6 | "email": "mike@symphonia.io", 7 | "name": "Mike Roberts", 8 | "url": "https://symphonia.io" 9 | }, 10 | "scripts": { 11 | "example1": "npx ts-node -T src/example1Sheep.ts", 12 | "example2": "npx ts-node -T src/example2Chickens.ts", 13 | "example3": "npx ts-node -T src/example3Farms.ts", 14 | "local-checks": "tsc" 15 | }, 16 | "dependencies": { 17 | "@symphoniacloud/dynamodb-entity-store": "1.x" 18 | }, 19 | "devDependencies": { 20 | "@aws-sdk/types": "3.x", 21 | "@types/deep-equal": "1.x", 22 | "@types/node": "16.x", 23 | "ts-node": "10.x", 24 | "typescript": "4.x" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/src/example1Sheep.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEntity, 3 | createStandardSingleTableConfig, 4 | createStore, 5 | DynamoDBValues, 6 | rangeWhereSkBetween 7 | } from '@symphoniacloud/dynamodb-entity-store' 8 | 9 | // Our domain type for Sheep 10 | export interface Sheep { 11 | breed: string 12 | name: string 13 | ageInYears: number 14 | } 15 | 16 | // Type predicate for Sheep type 17 | const isSheep = function (x: DynamoDBValues): x is Sheep { 18 | const candidate = x as Sheep 19 | return candidate.breed !== undefined && candidate.name !== undefined && candidate.ageInYears !== undefined 20 | } 21 | 22 | // The Entity that defines how to swap between internal domain objects and DynamoDB items 23 | export const SHEEP_ENTITY = createEntity( 24 | 'sheep', 25 | isSheep, 26 | ({ breed }: Pick) => `SHEEP#BREED#${breed}`, 27 | ({ name }: Pick) => `NAME#${name}` 28 | ) 29 | 30 | async function run() { 31 | // Create entity store using default configuration 32 | const config = createStandardSingleTableConfig('AnimalsTable') 33 | // config.store.logger = consoleLogger 34 | const entityStore = createStore(config) 35 | 36 | const sheepOperations = entityStore.for(SHEEP_ENTITY) 37 | await sheepOperations.put({ breed: 'merino', name: 'shaun', ageInYears: 3 }) 38 | await sheepOperations.put({ breed: 'merino', name: 'bob', ageInYears: 4 }) 39 | await sheepOperations.put({ breed: 'suffolk', name: 'alison', ageInYears: 2 }) 40 | 41 | const shaun: Sheep = await sheepOperations.getOrThrow({ breed: 'merino', name: 'shaun' }) 42 | console.log(`shaun is ${shaun.ageInYears} years old`) 43 | 44 | console.log('\nAll merinos:') 45 | const merinos: Sheep[] = await sheepOperations.queryAllByPk({ breed: 'merino' }) 46 | for (const sheep of merinos) { 47 | console.log(`${sheep.name} is ${sheep.ageInYears} years old`) 48 | } 49 | 50 | function rangeWhereNameBetween(nameRangeStart: string, nameRangeEnd: string) { 51 | return rangeWhereSkBetween(`NAME#${nameRangeStart}`, `NAME#${nameRangeEnd}`) 52 | } 53 | 54 | console.log('\nMerinos with their name starting with the first half of the alphabet:') 55 | const earlyAlphabetMerinos = await sheepOperations.queryAllByPkAndSk( 56 | { breed: 'merino' }, 57 | rangeWhereNameBetween('a', 'n') 58 | ) 59 | 60 | for (const sheep of earlyAlphabetMerinos) { 61 | console.log(sheep.name) 62 | } 63 | } 64 | 65 | run() 66 | -------------------------------------------------------------------------------- /examples/src/example2Chickens.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStandardSingleTableConfig, 3 | createStore, 4 | Entity, 5 | rangeWhereSkBeginsWith, 6 | rangeWhereSkGreaterThan, 7 | rangeWhereSkLessThan, 8 | typePredicateParser 9 | } from '@symphoniacloud/dynamodb-entity-store' 10 | 11 | export interface Chicken { 12 | breed: string 13 | name: string 14 | dateOfBirth: string 15 | coop: string 16 | } 17 | 18 | export function isChicken(x: unknown): x is Chicken { 19 | const candidate = x as Chicken 20 | return ( 21 | candidate.breed !== undefined && 22 | candidate.name !== undefined && 23 | candidate.dateOfBirth !== undefined && 24 | candidate.coop !== undefined 25 | ) 26 | } 27 | 28 | export const CHICKEN_ENTITY: Entity< 29 | Chicken, 30 | Pick, 31 | Pick 32 | > = { 33 | type: 'chicken', 34 | parse: typePredicateParser(isChicken, 'chicken'), 35 | pk({ breed }: Pick): string { 36 | return `CHICKEN#BREED#${breed}` 37 | }, 38 | sk({ name, dateOfBirth }: Pick): string { 39 | return sk(name, dateOfBirth) 40 | }, 41 | gsis: { 42 | gsi: { 43 | pk({ coop }: Pick): string { 44 | return `COOP#${coop}` 45 | }, 46 | sk({ breed, dateOfBirth }: Pick): string { 47 | return `CHICKEN#BREED#${breed}#DATEOFBIRTH#${dateOfBirth}` 48 | } 49 | } 50 | } 51 | } 52 | 53 | function sk(name: string, dateOfBirth: string) { 54 | return `DATEOFBIRTH#${dateOfBirth}#NAME#${name}` 55 | } 56 | 57 | export function findOlderThan(dateOfBirthStart: string) { 58 | return rangeWhereSkLessThan(`DATEOFBIRTH#${dateOfBirthStart}`) 59 | } 60 | 61 | export function findYoungerThan(dateOfBirthStart: string) { 62 | return rangeWhereSkGreaterThan(`DATEOFBIRTH#${dateOfBirthStart}`) 63 | } 64 | 65 | export function gsiBreed(breed: string) { 66 | return rangeWhereSkBeginsWith(`CHICKEN#BREED#${breed}`) 67 | } 68 | 69 | async function run() { 70 | // Create entity store using default configuration 71 | const entityStore = createStore(createStandardSingleTableConfig('AnimalsTable')) 72 | const chickenStore = entityStore.for(CHICKEN_ENTITY) 73 | 74 | await chickenStore.put({ breed: 'sussex', name: 'ginger', dateOfBirth: '2021-07-01', coop: 'bristol' }) 75 | await chickenStore.put({ breed: 'sussex', name: 'babs', dateOfBirth: '2021-09-01', coop: 'bristol' }) 76 | await chickenStore.put({ breed: 'sussex', name: 'bunty', dateOfBirth: '2021-01-01', coop: 'bristol' }) 77 | await chickenStore.put({ breed: 'sussex', name: 'yolko', dateOfBirth: '2020-12-01', coop: 'dakota' }) 78 | await chickenStore.put({ breed: 'orpington', name: 'cluck', dateOfBirth: '2022-03-01', coop: 'dakota' }) 79 | 80 | console.log('Chickens in Dakota:') 81 | for (const chicken of await chickenStore.queryAllWithGsiByPk({ coop: 'dakota' })) { 82 | console.log(chicken.name) 83 | } 84 | 85 | console.log('\nOrpingtons in Dakota:') 86 | for (const chicken of await chickenStore.queryAllWithGsiByPkAndSk( 87 | { coop: 'dakota' }, 88 | rangeWhereSkBeginsWith('CHICKEN#BREED#orpington') 89 | )) { 90 | console.log(chicken.name) 91 | } 92 | } 93 | 94 | run() 95 | -------------------------------------------------------------------------------- /examples/src/example3Farms.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createMinimumSingleTableConfig, 3 | createStore, 4 | DynamoDBValues, 5 | entityFromPkOnlyEntity, 6 | getPKValue, 7 | MetaAttributeNames 8 | } from '@symphoniacloud/dynamodb-entity-store' 9 | 10 | export interface Farm { 11 | name: string 12 | address: string 13 | } 14 | 15 | function isFarm(x: unknown): x is Farm { 16 | const candidate = x as Farm 17 | return candidate.name !== undefined && candidate.address !== undefined 18 | } 19 | 20 | export const FARM_ENTITY = entityFromPkOnlyEntity({ 21 | type: 'farm', 22 | pk({ name }: Pick): string { 23 | return name 24 | }, 25 | convertToDynamoFormat: (item) => { 26 | return { 27 | // 'Name' is automatically added because it is the PK 28 | FarmAddress: item.address 29 | } 30 | }, 31 | parse: (item: DynamoDBValues, _: string[], metaAttributeNames: MetaAttributeNames): Farm => { 32 | const parsed = { 33 | // We could just read item.Name here, but this shows that in a custom parser we can 34 | // access the meta attribute names of table 35 | name: getPKValue(item, metaAttributeNames), 36 | address: item.FarmAddress 37 | } 38 | if (isFarm(parsed)) return parsed 39 | else throw new Error('Unable to parse DynamoDB record to Farm') 40 | } 41 | }) 42 | 43 | async function run() { 44 | // Custom configuration - use one table where the partition key attribute name is 'Name', and there is no SK 45 | // or any other metadata 46 | const entityStore = createStore({ 47 | ...createMinimumSingleTableConfig('FarmTable', { pk: 'Name' }), 48 | allowScans: true 49 | }) 50 | 51 | const farmStore = entityStore.for(FARM_ENTITY) 52 | await farmStore.put({ name: 'Sunflower Farm', address: 'Green Shoots Road, Honesdale, PA' }) 53 | await farmStore.put({ name: 'Worthy Farm', address: 'Glastonbury, England' }) 54 | 55 | const worthyFarm = await farmStore.getOrThrow({ name: 'Worthy Farm' }) 56 | console.log(`Address of Worthy Farm: ${worthyFarm.address}`) 57 | 58 | console.log('\nAll farms:') 59 | for (const farm of await farmStore.scanAll()) { 60 | console.log(`Name: ${farm.name}, Address: ${farm.address}`) 61 | } 62 | } 63 | 64 | run() 65 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "include": ["src/**/*.ts"], 4 | "compilerOptions": { 5 | "target": "es2021", 6 | "module": "commonjs", 7 | 8 | // ** Type Checking ** 9 | "strict": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | 13 | // ** Interop ** 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | 18 | // ** Completeness ** 19 | "skipLibCheck": true, 20 | 21 | "noEmit": true, 22 | "declaration": false 23 | } 24 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symphoniacloud/dynamodb-entity-store", 3 | "description": "A lightly opinionated DynamoDB library for TypeScript & JavaScript applications", 4 | "license": "MIT", 5 | "author": { 6 | "email": "mike@symphonia.io", 7 | "name": "Mike Roberts", 8 | "url": "https://symphonia.io" 9 | }, 10 | "keywords": [ 11 | "dynamodb" 12 | ], 13 | "homepage": "https://github.com/symphoniacloud/dynamodb-entity-store", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/symphoniacloud/dynamodb-entity-store.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/symphoniacloud/dynamodb-entity-store/issues" 20 | }, 21 | "scripts": { 22 | "lint": "eslint --max-warnings=0 --fix-dry-run \"{src,test}/**/*.{js,ts}\"", 23 | "format": "prettier --check \"{src,test}/**/*.{js,ts}\"", 24 | "unit-tests": "npx vitest run --dir test/unit", 25 | "local-checks": "tsc && npm run lint && npm run unit-tests", 26 | "integration-tests": "npx vitest run --dir test/integration --config ./test/integration/vitest.config.ts", 27 | "all-checks": "npm run local-checks && npm run integration-tests", 28 | "deploy": "cd test/examples && aws cloudformation deploy --template-file template.yaml --stack-name \"${STACK_NAME-entity-store-test-stack}\" --no-fail-on-empty-changeset", 29 | "deploy-and-all-checks": "npm run deploy && npm run all-checks", 30 | "build": "rm -rf dist && tsc -p tsconfig-build-esm.json && tsc -p tsconfig-build-cjs.json && echo '{\"type\": \"module\"}' > dist/esm/package.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", 31 | "prepublishOnly": "npm run local-checks && npm run build", 32 | "check-examples": "cd examples && npm run local-checks", 33 | "generate-docs": "typedoc src/lib/index.ts" 34 | }, 35 | "engines": { 36 | "node": ">=16.20.0" 37 | }, 38 | "dependencies": { 39 | "@aws-sdk/client-dynamodb": "3.x", 40 | "@aws-sdk/lib-dynamodb": "3.x", 41 | "@aws-sdk/util-dynamodb": "3.x" 42 | }, 43 | "devDependencies": { 44 | "@aws-sdk/client-cloudformation": "3.x", 45 | "@aws-sdk/types": "3.x", 46 | "@types/deep-equal": "1.x", 47 | "@types/node": "16.x", 48 | "@typescript-eslint/eslint-plugin": "5.x", 49 | "@typescript-eslint/parser": "5.x", 50 | "deep-equal": "2.x", 51 | "eslint": "8.x", 52 | "eslint-config-prettier": "8.x", 53 | "prettier": "2.x", 54 | "ts-node": "10.x", 55 | "typedoc": "^0.25.1", 56 | "typescript": "4.x", 57 | "vitest": "0.x" 58 | }, 59 | "files": [ 60 | "package.json", 61 | "README.md", 62 | "LICENSE", 63 | "dist/" 64 | ], 65 | "main": "./dist/cjs/index.js", 66 | "module": "./dist/esm/index.js", 67 | "types": "./dist/cjs/index.d.ts" 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/dynamoDBInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BatchGetCommand, 3 | BatchGetCommandInput, 4 | BatchGetCommandOutput, 5 | BatchWriteCommand, 6 | BatchWriteCommandInput, 7 | BatchWriteCommandOutput, 8 | DeleteCommand, 9 | DeleteCommandInput, 10 | DeleteCommandOutput, 11 | DynamoDBDocumentClient, 12 | GetCommand, 13 | GetCommandInput, 14 | GetCommandOutput, 15 | paginateQuery, 16 | paginateScan, 17 | PutCommand, 18 | PutCommandInput, 19 | PutCommandOutput, 20 | QueryCommand, 21 | QueryCommandInput, 22 | QueryCommandOutput, 23 | ScanCommand, 24 | ScanCommandInput, 25 | ScanCommandOutput, 26 | TransactGetCommand, 27 | TransactGetCommandInput, 28 | TransactGetCommandOutput, 29 | TransactWriteCommand, 30 | TransactWriteCommandInput, 31 | TransactWriteCommandOutput, 32 | UpdateCommand, 33 | UpdateCommandInput, 34 | UpdateCommandOutput 35 | } from '@aws-sdk/lib-dynamodb' 36 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb' 37 | import { EntityStoreLogger, isDebugLoggingEnabled } from './util' 38 | 39 | export interface DynamoDBInterface { 40 | put(params: PutCommandInput): Promise 41 | 42 | batchWrite(params: BatchWriteCommandInput): Promise 43 | 44 | update(params: UpdateCommandInput): Promise 45 | 46 | get(params: GetCommandInput): Promise 47 | 48 | batchGet(params: BatchGetCommandInput): Promise 49 | 50 | delete(params: DeleteCommandInput): Promise 51 | 52 | transactionWrite(params: TransactWriteCommandInput): Promise 53 | 54 | transactionGet(params: TransactGetCommandInput): Promise 55 | 56 | queryOnePage(params: QueryCommandInput): Promise 57 | 58 | // We only return one property of `QueryCommandOutput`, but it means this is the same type of result of queryOnePage 59 | queryAllPages(params: QueryCommandInput): Promise 60 | 61 | scanOnePage(params: ScanCommandInput): Promise 62 | 63 | // We only return one property of `ScanCommandOutput`, but it means this is the same type of result of queryOnePage 64 | scanAllPages(params: ScanCommandInput): Promise 65 | } 66 | 67 | export function documentClientBackedInterface( 68 | logger: EntityStoreLogger, 69 | documentClient?: DynamoDBDocumentClient 70 | ): DynamoDBInterface { 71 | const docClient = documentClient ?? DynamoDBDocumentClient.from(new DynamoDBClient({})) 72 | 73 | return { 74 | async put(params: PutCommandInput) { 75 | return await docClient.send(new PutCommand(params)) 76 | }, 77 | 78 | async batchWrite(params: BatchWriteCommandInput) { 79 | return await docClient.send(new BatchWriteCommand(params)) 80 | }, 81 | 82 | async update(params: UpdateCommandInput) { 83 | return await docClient.send(new UpdateCommand(params)) 84 | }, 85 | 86 | async get(params: GetCommandInput) { 87 | return await docClient.send(new GetCommand(params)) 88 | }, 89 | 90 | async batchGet(params: BatchGetCommandInput) { 91 | return await docClient.send(new BatchGetCommand(params)) 92 | }, 93 | 94 | async delete(params: DeleteCommandInput) { 95 | return await docClient.send(new DeleteCommand(params)) 96 | }, 97 | 98 | async transactionWrite(params: TransactWriteCommandInput) { 99 | return await docClient.send(new TransactWriteCommand(params)) 100 | }, 101 | 102 | async transactionGet(params: TransactGetCommandInput) { 103 | return await docClient.send(new TransactGetCommand(params)) 104 | }, 105 | 106 | async queryOnePage(params: QueryCommandInput) { 107 | return await docClient.send(new QueryCommand(params)) 108 | }, 109 | 110 | async queryAllPages(params: QueryCommandInput) { 111 | // See https://aws.amazon.com/blogs/developer/pagination-using-async-iterators-in-modular-aws-sdk-for-javascript/ 112 | const pages: QueryCommandOutput[] = [] 113 | for await (const page of paginateQuery({ client: docClient }, params)) { 114 | if (isDebugLoggingEnabled(logger)) { 115 | logger.debug('Received query results page', { page }) 116 | } 117 | pages.push(page) 118 | } 119 | return pages 120 | }, 121 | 122 | async scanOnePage(params: ScanCommandInput) { 123 | return await docClient.send(new ScanCommand(params)) 124 | }, 125 | 126 | async scanAllPages(params: ScanCommandInput) { 127 | // See https://aws.amazon.com/blogs/developer/pagination-using-async-iterators-in-modular-aws-sdk-for-javascript/ 128 | const pages: ScanCommandOutput[] = [] 129 | for await (const page of paginateScan({ client: docClient }, params)) { 130 | if (isDebugLoggingEnabled(logger)) { 131 | logger.debug('Received scan results page', { page }) 132 | } 133 | pages.push(page) 134 | } 135 | return pages 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/lib/entities.ts: -------------------------------------------------------------------------------- 1 | import { NativeAttributeValue, NativeScalarAttributeValue } from '@aws-sdk/util-dynamodb/dist-types/models' 2 | 3 | export type DynamoDBValues = Record 4 | 5 | /** 6 | * The attribute names on the underlying table for various "meta" attributes 7 | * Note that only pk is required 8 | * These values can change for multi-table stores. E.g. one table may be a multi-entity "standard" configuration, and another 9 | * might be more customized for one specific entity 10 | * This type is included in this file since MetaAttributeNames are parsed to an Entity's parser function 11 | * "Standard" configuration for a multi-entity / single-table configuration, is defined in setupSupport.ts, but in brief is: 12 | * pk: 'PK' 13 | * sk: 'SK' 14 | * gsisById: { gsi: { pk: 'GSIPK', sk: 'GSISK' } } 15 | * ttl: 'ttl', 16 | * entityType: '_et', 17 | * lastUpdated: '_lastUpdated' 18 | */ 19 | export interface MetaAttributeNames { 20 | pk: string 21 | sk?: string 22 | /** 23 | * Map of GSI **ID** (which might be different from the underlying index name) to corresponding GSI PK / GSI SK attribute names 24 | * If no GSIs are defined for the table this map should be undefined or empty 25 | */ 26 | gsisById?: Record 27 | ttl?: string 28 | entityType?: string 29 | lastUpdated?: string 30 | } 31 | 32 | /** 33 | * Given an internal object, convert to the form stored in DynamoDB 34 | * By default the formatter is a "no-op" function - i.e. the object is stored to DynamoDB with no changes 35 | * Entities may want to customize this behavior, however, e.g. for changing formats (e.g. date times), or for stopping 36 | * certain properties being persisted 37 | */ 38 | export type EntityFormatter = (item: T) => DynamoDBValues 39 | 40 | /** 41 | * Given an item retrieved from DynamoDB, convert to internal form 42 | * If the EntityFormatter on an Entity is left as default then this is usually just an instantiation of 'typePredicateParser' 43 | * which doesn't actually do any object manipulation but does assert the correct type 44 | */ 45 | export type EntityParser = ( 46 | item: DynamoDBValues, 47 | allMetaAttributeNames: string[], 48 | metaAttributeNames: MetaAttributeNames 49 | ) => T 50 | 51 | /** 52 | * Every object stored via Entity Store must have a corresponding Entity. 53 | * Each entity must be related to a type (`TItem`) that must at least define the table key fields, and optionally 54 | * the other fields of the internal representation of an object 55 | */ 56 | export interface Entity { 57 | /** 58 | * Must be unique for each Entity used in the store, but can be any string. For tables that store multiple entities 59 | * the type is stored as a meta attribute on the table 60 | */ 61 | type: string 62 | 63 | /** 64 | * @see EntityFormatter 65 | */ 66 | convertToDynamoFormat?: EntityFormatter 67 | 68 | /** 69 | * @see EntityParser 70 | */ 71 | parse: EntityParser 72 | 73 | /** 74 | * Given a subset of the fields of the entity type, generate the partition key value for the stored version of the object 75 | * Note that this may be as simple as returning a specific property on the object, but for "single table designs" will 76 | * often be some kind of encoding of one more fields 77 | * @param source 78 | */ 79 | pk(source: TPKSource): NativeScalarAttributeValue 80 | 81 | /** 82 | * Given a subset of the fields of the entity type, generate the sort key value for the stored version of the object. 83 | * This field is required for Entity, since most usages of DynamoDB include a sort key. However if your 84 | * entity is stored in a table without a sort key then see PKOnlyEntity for a version of Entity that only requires a 85 | * partition key 86 | * Note that this may be as simple as returning a specific property on the object, but for "single table designs" will 87 | * often be some kind of encoding of one more fields 88 | * @param source 89 | */ 90 | sk(source: TSKSource): NativeScalarAttributeValue 91 | 92 | /** 93 | * A map of GSI ID to generator functions, similar to pk() and sk() 94 | * Note that the GSI IDs do *not* have to be the same as the underlying index names, rather they must 95 | * be the same IDs as those defined in the table configuration (e.g. 'gsi', 'gsi2', if using "standard" configuration.) 96 | */ 97 | gsis?: Record 98 | } 99 | 100 | /** 101 | * An entity which stores objects on a table that only has a partition key, and does not have a sort key 102 | */ 103 | export type PKOnlyEntity = Omit, 'sk'> 104 | 105 | export interface GsiGenerators { 106 | // ToMaybe - better types for these? 107 | pk(source: unknown): NativeScalarAttributeValue 108 | 109 | sk?(source: unknown): NativeScalarAttributeValue 110 | } 111 | -------------------------------------------------------------------------------- /src/lib/entityStore.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from './entities' 2 | import { MultipleEntityOperations } from './multipleEntityOperations' 3 | import { SingleEntityOperations } from './singleEntityOperations' 4 | import { TransactionOperations } from './transactionOperations' 5 | 6 | /** 7 | * Top-level interface for all operations in dynamodb-entity-store. 8 | * Typically use the `for(entity)` method, but use the other methods here for more advanced usage 9 | */ 10 | export interface AllEntitiesStore { 11 | /** 12 | * Build an object to work with one specific Entity. To work with multiple entities in one operation 13 | * use one of the other methods on this type. But to perform multiple operations that each use 14 | * an individual entity type then just call this for each entity type. 15 | * This method is fairly cheap, so feel free to either call it for every operation, or call it and 16 | * cache it - it's up to you and your style 17 | * @param entity 18 | */ 19 | for( 20 | entity: Entity 21 | ): SingleEntityOperations 22 | 23 | /** 24 | * Build an object to work with multiple entities in one (non-transactional) operation. 25 | * This can be useful, for example, if you want to use 26 | * one query to return multiple entities that each share a common partition key. 27 | * @param entities 28 | */ 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 30 | forMultiple(entities: Entity[]): MultipleEntityOperations 31 | 32 | /** 33 | * An object to wrap all transactional operations 34 | */ 35 | transactions: TransactionOperations 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './support' 2 | export * from './util' 3 | 4 | export * from './dynamoDBInterface' 5 | export * from './entities' 6 | export * from './entityStore' 7 | export * from './multipleEntityOperations' 8 | export * from './singleEntityOperations' 9 | export * from './singleEntityAdvancedOperations' 10 | export * from './tableBackedStore' 11 | export * from './tableBackedStoreConfiguration' 12 | export * from './transactionOperations' 13 | -------------------------------------------------------------------------------- /src/lib/internal/common/deleteCommon.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { 3 | conditionExpressionParam, 4 | expressionAttributeParamsFromOptions, 5 | keyParamFromSource, 6 | tableNameParam 7 | } from './operationsCommon' 8 | import { DeleteOptions } from '../../singleEntityOperations' 9 | 10 | // Also used for generating transaction delete items 11 | export function deleteParams< 12 | TItem extends TPKSource & TSKSource, 13 | TKeySource extends TPKSource & TSKSource, 14 | TPKSource, 15 | TSKSource 16 | >(context: EntityContext, keySource: TKeySource, options?: DeleteOptions) { 17 | return { 18 | ...tableNameParam(context), 19 | ...keyParamFromSource(context, keySource), 20 | ...conditionExpressionParam(options), 21 | ...expressionAttributeParamsFromOptions(options) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/internal/common/gsiQueryCommon.ts: -------------------------------------------------------------------------------- 1 | import { GsiGenerators } from '../../entities' 2 | import { EntityContext } from '../entityContext' 3 | import { throwError } from '../../util' 4 | import { WithGsiId } from '../../singleEntityOperations' 5 | 6 | export interface GsiDetails { 7 | id: string 8 | attributeNames: { pk: string; sk?: string } 9 | tableIndexName: string 10 | generators: GsiGenerators 11 | } 12 | 13 | // TODO - needs more testing 14 | export function findGsiDetails( 15 | entityContext: EntityContext, 16 | withGsiId: WithGsiId 17 | ): GsiDetails { 18 | const entityGsi = findEntityGsi(entityContext, withGsiId) 19 | const tableGsi = findGsiTableDetails(entityContext, entityGsi.gsiId) 20 | return { 21 | id: entityGsi.gsiId, 22 | generators: entityGsi.gsiGenerators, 23 | attributeNames: tableGsi.attributeNames, 24 | tableIndexName: tableGsi.indexName 25 | } 26 | } 27 | 28 | function findEntityGsi( 29 | { entity: { type, gsis } }: EntityContext, 30 | withGsiId: WithGsiId 31 | ): { gsiId: string; gsiGenerators: GsiGenerators } { 32 | const entityGsiCount = Object.keys(gsis ?? {}).length 33 | if (!gsis || entityGsiCount === 0) 34 | throw new Error(`Entity type ${type} has no GSIs, and therefore cannot be queried by GSI`) 35 | if (entityGsiCount === 1) { 36 | const onlyGsi = Object.entries(gsis)[0] 37 | return { 38 | gsiId: onlyGsi[0], 39 | gsiGenerators: onlyGsi[1] 40 | } 41 | } 42 | if (!withGsiId.gsiId) 43 | throw new Error( 44 | `Entity type ${type} has multiple GSIs but no GSI ID (.gsiId) was specified on query options` 45 | ) 46 | return { 47 | gsiId: withGsiId.gsiId, 48 | gsiGenerators: gsis[withGsiId.gsiId] 49 | } 50 | } 51 | 52 | function findGsiTableDetails( 53 | entityContext: EntityContext, 54 | gsiId: string 55 | ) { 56 | const attributeNames = 57 | entityContext.metaAttributeNames.gsisById[gsiId] ?? 58 | throwError( 59 | `Table configuration is not correct for GSI ID ${gsiId} - GSI attribute names are not available` 60 | )() 61 | const indexName = 62 | entityContext.tableGsiNames[gsiId] ?? 63 | throwError(`Table configuration is not correct for GSI ID ${gsiId} - no index name is configured`)() 64 | 65 | return { 66 | attributeNames, 67 | indexName 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/internal/common/operationsCommon.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBValues } from '../../entities' 2 | import { EntityContext } from '../entityContext' 3 | import { Clock, secondsTimestampInFutureDays } from '../../util' 4 | import { DeleteItemOutput } from '@aws-sdk/client-dynamodb' 5 | import { 6 | ReturnConsumedCapacityOption, 7 | ReturnItemCollectionMetricsOption, 8 | ReturnValuesOptions 9 | } from '../../singleEntityAdvancedOperations' 10 | 11 | export interface WithExpression { 12 | expressionAttributeValues?: DynamoDBValues 13 | expressionAttributeNames?: Record 14 | } 15 | 16 | export interface Conditional extends WithExpression { 17 | conditionExpression?: string 18 | } 19 | 20 | export interface WithTTL { 21 | ttl?: number 22 | ttlInFutureDays?: number 23 | } 24 | 25 | // **** PARAMS 26 | 27 | export function createKeyFromSource( 28 | { entity, metaAttributeNames: { pk, sk } }: EntityContext, 29 | keySource: TPKSource & TSKSource 30 | ): DynamoDBValues { 31 | return { 32 | [pk]: entity.pk(keySource), 33 | ...(sk ? { [sk]: entity.sk(keySource) } : {}) 34 | } 35 | } 36 | 37 | export function keyParamFromSource( 38 | context: EntityContext, 39 | keySource: TPKSource & TSKSource 40 | ) { 41 | return { Key: createKeyFromSource(context, keySource) } 42 | } 43 | 44 | export function tableNameParam({ tableName }: Pick, 'tableName'>) { 45 | return { TableName: tableName } 46 | } 47 | 48 | export function conditionExpressionParam(condition?: Conditional) { 49 | const { conditionExpression } = condition ?? {} 50 | return conditionExpression 51 | ? { 52 | ConditionExpression: conditionExpression 53 | } 54 | : {} 55 | } 56 | 57 | export function expressionAttributeParams( 58 | expressionAttributeValues?: DynamoDBValues, 59 | expressionAttributeNames?: Record 60 | ) { 61 | return { 62 | ...(expressionAttributeValues ? { ExpressionAttributeValues: expressionAttributeValues } : {}), 63 | ...(expressionAttributeNames ? { ExpressionAttributeNames: expressionAttributeNames } : {}) 64 | } 65 | } 66 | 67 | export function expressionAttributeParamsFromOptions(attributeOptions?: WithExpression) { 68 | return expressionAttributeParams( 69 | attributeOptions?.expressionAttributeValues, 70 | attributeOptions?.expressionAttributeNames 71 | ) 72 | } 73 | 74 | export function returnParamsForCapacityMetricsAndValues( 75 | options?: ReturnConsumedCapacityOption & ReturnItemCollectionMetricsOption & ReturnValuesOptions 76 | ) { 77 | return { 78 | ...returnConsumedCapacityParam(options), 79 | ...returnValuesParams(options), 80 | ...returnItemCollectionMetricsParam(options) 81 | } 82 | } 83 | 84 | export function returnConsumedCapacityParam(option?: ReturnConsumedCapacityOption) { 85 | return option?.returnConsumedCapacity ? { ReturnConsumedCapacity: option.returnConsumedCapacity } : {} 86 | } 87 | 88 | export function returnValuesParams(options?: ReturnValuesOptions) { 89 | return { 90 | ...(options?.returnValues ? { ReturnValues: options.returnValues } : {}), 91 | ...(options?.returnValuesOnConditionCheckFailure 92 | ? { ReturnValuesOnConditionCheckFailure: options.returnValuesOnConditionCheckFailure } 93 | : {}) 94 | } 95 | } 96 | 97 | export function returnItemCollectionMetricsParam(option?: ReturnItemCollectionMetricsOption) { 98 | return option?.returnItemCollectionMetrics 99 | ? { ReturnItemCollectionMetrics: option.returnItemCollectionMetrics } 100 | : {} 101 | } 102 | 103 | export function determineTTL(clock: Clock, { ttl, ttlInFutureDays }: WithTTL = {}): number | undefined { 104 | return ttl ?? (ttlInFutureDays ? secondsTimestampInFutureDays(clock, ttlInFutureDays) : undefined) 105 | } 106 | 107 | // *** PARSING 108 | 109 | export function parseItem( 110 | context: EntityContext, 111 | item: DynamoDBValues 112 | ): TItem { 113 | return context.entity.parse(item, context.allMetaAttributeNames, context.metaAttributeNames) 114 | } 115 | 116 | export function parseUnparsedReturnedAttributes(result: { Attributes?: DynamoDBValues }) { 117 | return result.Attributes ? { unparsedReturnedAttributes: result.Attributes } : {} 118 | } 119 | 120 | export function parseConsumedCapacityAndItemCollectionMetrics( 121 | result: Pick 122 | ) { 123 | return result.ConsumedCapacity || result.ItemCollectionMetrics 124 | ? { 125 | metadata: { 126 | ...(result.ConsumedCapacity ? { consumedCapacity: result.ConsumedCapacity } : {}), 127 | ...(result.ItemCollectionMetrics ? { itemCollectionMetrics: result.ItemCollectionMetrics } : {}) 128 | } 129 | } 130 | : {} 131 | } 132 | -------------------------------------------------------------------------------- /src/lib/internal/common/putCommon.ts: -------------------------------------------------------------------------------- 1 | import { Clock } from '../../util' 2 | import { 3 | conditionExpressionParam, 4 | createKeyFromSource, 5 | determineTTL, 6 | expressionAttributeParamsFromOptions, 7 | tableNameParam 8 | } from './operationsCommon' 9 | import { EntityContext } from '../entityContext' 10 | import { DynamoDBValues, Entity, MetaAttributeNames } from '../../entities' 11 | import { Mandatory } from '../../util' 12 | import { PutCommandInput } from '@aws-sdk/lib-dynamodb' 13 | import { PutOptions } from '../../singleEntityOperations' 14 | 15 | // Also used for generating transaction put items 16 | export function putParams( 17 | context: EntityContext, 18 | item: TItem, 19 | options?: PutOptions 20 | ): PutCommandInput { 21 | return { 22 | ...tableNameParam(context), 23 | ...conditionExpressionParam(options), 24 | ...expressionAttributeParamsFromOptions(options), 25 | ...itemParam(context, item, options) 26 | // ...returnParamsForCapacityMetricsAndValues(options) 27 | } 28 | } 29 | 30 | export function itemParam( 31 | context: EntityContext, 32 | item: TItem, 33 | options?: PutOptions 34 | ): { Item: DynamoDBValues } { 35 | const { entity, clock } = context 36 | return { 37 | Item: 38 | { 39 | ...createKeyFromSource(context, item), 40 | ...gsiAttributes(context.metaAttributeNames, entity, item), 41 | ...(context.metaAttributeNames.entityType 42 | ? { [context.metaAttributeNames.entityType]: entity.type } 43 | : {}), 44 | ...(context.metaAttributeNames.lastUpdated 45 | ? { [context.metaAttributeNames.lastUpdated]: clock.now().toISOString() } 46 | : {}), 47 | ...ttlAttribute(clock, context.metaAttributeNames.ttl, options), 48 | ...(entity.convertToDynamoFormat ? entity.convertToDynamoFormat(item) : item) 49 | } || {} 50 | } 51 | } 52 | 53 | export function ttlAttribute(clock: Clock, attributeName?: string, options?: PutOptions) { 54 | if (attributeName) { 55 | const ttlValue = determineTTL(clock, options) 56 | if (ttlValue) { 57 | return { [attributeName]: ttlValue } 58 | } 59 | } 60 | return {} 61 | } 62 | 63 | export function gsiAttributes( 64 | metaAttributeNames: Mandatory, 65 | entity: Entity, 66 | item: TItem 67 | ) { 68 | return Object.entries(entity.gsis ?? {}).reduce((accum, [id, gsi]) => { 69 | const attributeNamesForID = metaAttributeNames.gsisById[id] 70 | if (!attributeNamesForID) throw new Error(`Unable to find GSI attribute names for GSI ID ${id}`) 71 | 72 | function gsiSK() { 73 | const skAttributeName = attributeNamesForID.sk 74 | if (!skAttributeName) return {} 75 | if (!gsi.sk) 76 | throw new Error( 77 | `Sort key attribute exists on table GSI for GSI ID ${id} but no GSI SK generator exists on entity type ${entity.type} for same GSI ID` 78 | ) 79 | return { 80 | [skAttributeName]: gsi.sk(item) 81 | } 82 | } 83 | 84 | return { 85 | ...accum, 86 | [attributeNamesForID.pk]: gsi.pk(item), 87 | ...gsiSK() 88 | } 89 | }, {}) 90 | } 91 | -------------------------------------------------------------------------------- /src/lib/internal/common/queryAndScanCommon.ts: -------------------------------------------------------------------------------- 1 | import { parseItem, returnConsumedCapacityParam } from './operationsCommon' 2 | import { DynamoDBValues } from '../../entities' 3 | import { EntityStoreLogger, isDebugLoggingEnabled, removeNullOrUndefined } from '../../util' 4 | import { 5 | QueryCommandInput, 6 | QueryCommandOutput, 7 | ScanCommandInput, 8 | ScanCommandOutput 9 | } from '@aws-sdk/lib-dynamodb' 10 | import { EntityContext } from '../entityContext' 11 | import { MultipleEntityCollectionResponse } from '../../multipleEntityOperations' 12 | import { 13 | AdvancedCollectionResponse, 14 | AdvancedQueryOnePageOptions, 15 | AdvancedScanOnePageOptions, 16 | ConsumedCapacitiesMetadata 17 | } from '../../singleEntityAdvancedOperations' 18 | import { GsiDetails } from './gsiQueryCommon' 19 | 20 | export interface QueryScanOperationConfiguration< 21 | TCommandInput extends ScanCommandInput & QueryCommandInput, 22 | TCommandOutput extends ScanCommandOutput & QueryCommandOutput 23 | > { 24 | operationParams: TCommandInput 25 | useAllPageOperation: boolean 26 | allPageOperation: (params: TCommandInput) => Promise 27 | onePageOperation: (params: TCommandInput) => Promise 28 | } 29 | 30 | export function configureScanOperation( 31 | { dynamoDB, tableName }: Pick, 'tableName' | 'dynamoDB'>, 32 | options: AdvancedScanOnePageOptions, 33 | allPages: boolean, 34 | gsiDetails?: GsiDetails 35 | ): QueryScanOperationConfiguration { 36 | return { 37 | ...configureOperation( 38 | tableName, 39 | options, 40 | allPages, 41 | gsiDetails ? { IndexName: gsiDetails.tableIndexName } : undefined 42 | ), 43 | allPageOperation: dynamoDB.scanAllPages, 44 | onePageOperation: dynamoDB.scanOnePage 45 | } 46 | } 47 | 48 | export function configureQueryOperation( 49 | { dynamoDB, tableName }: Pick, 'tableName' | 'dynamoDB'>, 50 | options: AdvancedQueryOnePageOptions, 51 | allPages: boolean, 52 | queryParamsParts?: Omit 53 | ): QueryScanOperationConfiguration { 54 | return { 55 | ...configureOperation(tableName, options, allPages, queryParamsParts), 56 | allPageOperation: dynamoDB.queryAllPages, 57 | onePageOperation: dynamoDB.queryOnePage 58 | } 59 | } 60 | 61 | export function configureOperation( 62 | tableName: string, 63 | options: AdvancedQueryOnePageOptions, 64 | allPages: boolean, 65 | paramsParts?: Omit 66 | ): { operationParams: ScanCommandInput & QueryCommandInput; useAllPageOperation: boolean } { 67 | const { limit, exclusiveStartKey, consistentRead } = options 68 | return { 69 | operationParams: { 70 | TableName: tableName, 71 | ...(limit ? { Limit: limit } : {}), 72 | ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), 73 | ...(consistentRead !== undefined ? { ConsistentRead: consistentRead } : {}), 74 | ...(paramsParts ? paramsParts : {}), 75 | ...returnConsumedCapacityParam(options) 76 | }, 77 | useAllPageOperation: allPages 78 | } 79 | } 80 | 81 | export interface UnparsedCollectionResult { 82 | items: DynamoDBValues[] 83 | lastEvaluatedKey?: DynamoDBValues 84 | metadata?: ConsumedCapacitiesMetadata 85 | } 86 | 87 | export async function executeQueryOrScan< 88 | TCommandInput extends ScanCommandInput & QueryCommandInput, 89 | TCommandOutput extends ScanCommandOutput & QueryCommandOutput 90 | >( 91 | { 92 | operationParams, 93 | useAllPageOperation, 94 | allPageOperation, 95 | onePageOperation 96 | }: QueryScanOperationConfiguration, 97 | logger: EntityStoreLogger, 98 | entityType?: string 99 | ): Promise { 100 | if (isDebugLoggingEnabled(logger)) { 101 | logger.debug( 102 | `Attempting to query or scan ${ 103 | entityType ? `entity ${entityType}` : `table ${operationParams.TableName}` 104 | }`, 105 | { 106 | useAllPageOperation, 107 | operationParams 108 | } 109 | ) 110 | } 111 | 112 | async function performAllPageOperation(): Promise { 113 | const result = await allPageOperation(operationParams) 114 | // No need to log - each page logged at lower level 115 | const lastPage = result[result.length - 1] 116 | const consumedCapacities = removeNullOrUndefined(result.map((page) => page.ConsumedCapacity)) 117 | return { 118 | items: result.map((page) => page.Items || []).flat(), 119 | ...(lastPage.LastEvaluatedKey ? { lastEvaluatedKey: lastPage.LastEvaluatedKey } : {}), 120 | ...(consumedCapacities.length > 0 ? { metadata: { consumedCapacities: consumedCapacities } } : {}) 121 | } 122 | } 123 | 124 | async function performOnePageOperation(): Promise { 125 | const result = await onePageOperation(operationParams) 126 | if (isDebugLoggingEnabled(logger)) { 127 | logger.debug(`Query or scan result`, { result }) 128 | } 129 | return { 130 | items: result.Items ?? [], 131 | ...(result.LastEvaluatedKey ? { lastEvaluatedKey: result.LastEvaluatedKey } : {}), 132 | ...(result.ConsumedCapacity ? { metadata: { consumedCapacities: [result.ConsumedCapacity] } } : {}) 133 | } 134 | } 135 | 136 | return useAllPageOperation ? await performAllPageOperation() : await performOnePageOperation() 137 | } 138 | 139 | export function commonCollectionResponseElements( 140 | unparsedItems: DynamoDBValues[], 141 | unparsedResult: UnparsedCollectionResult 142 | ): Pick { 143 | return { 144 | ...(unparsedItems.length > 0 ? { unparsedItems: unparsedItems } : {}), 145 | ...(unparsedResult.lastEvaluatedKey ? { lastEvaluatedKey: unparsedResult.lastEvaluatedKey } : {}), 146 | ...(unparsedResult.metadata ? { metadata: unparsedResult.metadata } : {}) 147 | } 148 | } 149 | 150 | export function parseResultsForEntity( 151 | context: EntityContext, 152 | unparsedResult: UnparsedCollectionResult 153 | ): AdvancedCollectionResponse { 154 | const entityTypeAttributeName = context.metaAttributeNames.entityType 155 | const { parsedItems, unparsedItems } = unparsedResult.items.reduce( 156 | (accum, item: DynamoDBValues) => { 157 | const parseable = !entityTypeAttributeName || item[entityTypeAttributeName] === context.entity.type 158 | if (parseable) accum.parsedItems.push(parseItem(context, item)) 159 | else accum.unparsedItems.push(item) 160 | return accum 161 | }, 162 | { 163 | parsedItems: [], 164 | unparsedItems: [] 165 | } 166 | ) 167 | 168 | return { 169 | items: parsedItems, 170 | ...commonCollectionResponseElements(unparsedItems, unparsedResult) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/lib/internal/common/updateCommon.ts: -------------------------------------------------------------------------------- 1 | // Also used for generating transaction update items 2 | import { ContextMetaAttributeNames, EntityContext } from '../entityContext' 3 | import { Mandatory } from '../../util' 4 | import { 5 | conditionExpressionParam, 6 | determineTTL, 7 | expressionAttributeParamsFromOptions, 8 | keyParamFromSource, 9 | tableNameParam 10 | } from './operationsCommon' 11 | import { UpdateCommandInput } from '@aws-sdk/lib-dynamodb' 12 | import { UpdateOptions } from '../../singleEntityOperations' 13 | 14 | // Also used by transactions 15 | export function createUpdateParams< 16 | TItem extends TPKSource & TSKSource, 17 | TKeySource extends TPKSource & TSKSource, 18 | TPKSource, 19 | TSKSource 20 | >( 21 | context: EntityContext, 22 | keySource: TKeySource, 23 | options: UpdateOptions 24 | ): Mandatory { 25 | return { 26 | ...tableNameParam(context), 27 | ...keyParamFromSource(context, keySource), 28 | ...updateExpressionParam(context, options), 29 | ...conditionExpressionParam(options), 30 | ...expressionAttributeParamsFromOptions( 31 | withTTLAttributeIfRelevant(context, withLastUpdatedAttributeIfRelevant(context, options)) 32 | ) 33 | } 34 | } 35 | 36 | function updateExpressionParam( 37 | context: EntityContext, 38 | options: UpdateOptions 39 | ) { 40 | return { 41 | UpdateExpression: joinNonEmpty( 42 | [ 43 | createSetClause(context, options), 44 | createClause('REMOVE', options.update?.remove), 45 | createClause('ADD', options.update?.add), 46 | createClause('DELETE', options.update?.delete) 47 | ], 48 | ' ' 49 | ) 50 | } 51 | } 52 | 53 | const LAST_UPDATED_EXPRESSION_ATTRIBUTE_NAME = '#lastUpdated' 54 | const LAST_UPDATED_EXPRESSION_ATTRIBUTE_VALUE = ':newLastUpdated' 55 | const SET_NEW_LAST_UPDATED = `${LAST_UPDATED_EXPRESSION_ATTRIBUTE_NAME} = ${LAST_UPDATED_EXPRESSION_ATTRIBUTE_VALUE}` 56 | const TTL_EXPRESSION_ATTRIBUTE_NAME = '#ttl' 57 | const TTL_EXPRESSION_ATTRIBUTE_VALUE = ':newTTL' 58 | const SET_NEW_TTL = `${TTL_EXPRESSION_ATTRIBUTE_NAME} = ${TTL_EXPRESSION_ATTRIBUTE_VALUE}` 59 | 60 | function createSetClause( 61 | context: EntityContext, 62 | options: UpdateOptions 63 | ) { 64 | const lastUpdatedAttributeName = context.metaAttributeNames.lastUpdated 65 | return createClause( 66 | 'SET', 67 | joinNonEmpty( 68 | [ 69 | options.update?.set, 70 | lastUpdatedAttributeName ? SET_NEW_LAST_UPDATED : '', 71 | resetTTL(context.metaAttributeNames, options) ? SET_NEW_TTL : '' 72 | ], 73 | ', ' 74 | ) 75 | ) 76 | } 77 | 78 | function resetTTL(metaAttributeNames: ContextMetaAttributeNames, { ttl, ttlInFutureDays }: UpdateOptions) { 79 | return metaAttributeNames.ttl && (ttl !== undefined || ttlInFutureDays !== undefined) 80 | } 81 | 82 | function joinNonEmpty(arr: Array, separator: string) { 83 | return arr.filter((x) => x && x.length > 1).join(separator) 84 | } 85 | 86 | function createClause(operator: string, element: string | undefined) { 87 | return element && element.length > 0 ? `${operator} ${element}` : '' 88 | } 89 | 90 | function withLastUpdatedAttributeIfRelevant( 91 | context: EntityContext, 92 | options: UpdateOptions 93 | ): UpdateOptions { 94 | const lastUpdatedAttributeName = context.metaAttributeNames.lastUpdated 95 | if (lastUpdatedAttributeName === undefined) return options 96 | return { 97 | ...options, 98 | expressionAttributeNames: { 99 | ...(options.expressionAttributeNames ?? {}), 100 | [LAST_UPDATED_EXPRESSION_ATTRIBUTE_NAME]: lastUpdatedAttributeName 101 | }, 102 | expressionAttributeValues: { 103 | ...options.expressionAttributeValues, 104 | [LAST_UPDATED_EXPRESSION_ATTRIBUTE_VALUE]: context.clock.now().toISOString() 105 | } 106 | } 107 | } 108 | 109 | function withTTLAttributeIfRelevant( 110 | context: EntityContext, 111 | options: UpdateOptions 112 | ): UpdateOptions { 113 | const ttlAttributeName = context.metaAttributeNames.ttl 114 | if (!(ttlAttributeName && resetTTL(context.metaAttributeNames, options))) return options 115 | return { 116 | ...options, 117 | expressionAttributeNames: { 118 | ...(options.expressionAttributeNames ?? {}), 119 | [TTL_EXPRESSION_ATTRIBUTE_NAME]: ttlAttributeName 120 | }, 121 | expressionAttributeValues: { 122 | ...options.expressionAttributeValues, 123 | [TTL_EXPRESSION_ATTRIBUTE_VALUE]: determineTTL(context.clock, options) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/lib/internal/entityContext.ts: -------------------------------------------------------------------------------- 1 | import { Entity, MetaAttributeNames } from '../entities' 2 | import { Mandatory } from '../util/types' 3 | import { StoreContext, TableConfig } from '../tableBackedStoreConfiguration' 4 | 5 | export type ContextMetaAttributeNames = Mandatory 6 | 7 | export interface EntityContext 8 | extends StoreContext, 9 | Pick { 10 | entity: Entity 11 | tableGsiNames: Record 12 | metaAttributeNames: ContextMetaAttributeNames 13 | allMetaAttributeNames: string[] 14 | } 15 | 16 | export interface EntityContextParams { 17 | table: TableConfig 18 | storeContext: StoreContext 19 | } 20 | 21 | export function createEntityContext( 22 | { storeContext, table }: EntityContextParams, 23 | entity: Entity 24 | ): EntityContext { 25 | const metaAttributeNames = { 26 | ...table.metaAttributeNames, 27 | gsisById: table.metaAttributeNames.gsisById ?? {} 28 | } 29 | return { 30 | ...storeContext, 31 | tableName: table.tableName, 32 | tableGsiNames: table.gsiNames ?? {}, 33 | ...(table.allowScans !== undefined ? { allowScans: table.allowScans } : {}), 34 | entity, 35 | metaAttributeNames, 36 | allMetaAttributeNames: calcAllMetaAttributeNames(metaAttributeNames) 37 | } 38 | } 39 | 40 | export function calcAllMetaAttributeNames({ gsisById, ...basicAttributes }: MetaAttributeNames): string[] { 41 | return [ 42 | ...Object.values(basicAttributes), 43 | ...Object.values(gsisById ?? {}) 44 | .map(({ pk, sk }) => [pk, sk ?? []].flat()) 45 | .flat() 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/internal/multipleEntities/multipleEntitiesQueryAndScanCommon.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { MultipleEntityCollectionResponse } from '../../multipleEntityOperations' 3 | import { parseItem } from '../common/operationsCommon' 4 | import { 5 | commonCollectionResponseElements, 6 | executeQueryOrScan, 7 | QueryScanOperationConfiguration, 8 | UnparsedCollectionResult 9 | } from '../common/queryAndScanCommon' 10 | import { 11 | QueryCommandInput, 12 | QueryCommandOutput, 13 | ScanCommandInput, 14 | ScanCommandOutput 15 | } from '@aws-sdk/lib-dynamodb' 16 | 17 | // Not defined in the type, but a guarantee from tableBackedMultipleEntityOperations 18 | // is that this only contains contexts for one table 19 | // Therefore any entry contains the same table details 20 | export type EntityContextsByEntityType = Record> 21 | 22 | export async function performMultipleEntityOperationAndParse< 23 | TCommandInput extends ScanCommandInput & QueryCommandInput, 24 | TCommandOutput extends ScanCommandOutput & QueryCommandOutput 25 | >( 26 | contextsByEntityType: EntityContextsByEntityType, 27 | operationConfiguration: QueryScanOperationConfiguration, 28 | defaultEntityContext: EntityContext 29 | ) { 30 | const { 31 | logger, 32 | metaAttributeNames: { entityType: entityTypeAttributeName } 33 | } = defaultEntityContext 34 | 35 | if (!entityTypeAttributeName) 36 | throw new Error( 37 | `Unable to operate on multiple entities - no entityType attribute is configured for table` 38 | ) 39 | 40 | return parseMultipleEntityResults( 41 | entityTypeAttributeName, 42 | contextsByEntityType, 43 | await executeQueryOrScan(operationConfiguration, logger) 44 | ) 45 | } 46 | 47 | export function parseMultipleEntityResults( 48 | entityTypeAttributeName: string, 49 | contextsByEntityType: EntityContextsByEntityType, 50 | unparsedResult: UnparsedCollectionResult 51 | ): MultipleEntityCollectionResponse { 52 | const { itemsByEntityType, unparsedItems } = unparsedResult.items.reduce( 53 | (accum, item) => { 54 | const et = item[entityTypeAttributeName] 55 | const context = contextsByEntityType[et] 56 | if (context) { 57 | if (!accum.itemsByEntityType[et]) accum.itemsByEntityType[et] = [] 58 | accum.itemsByEntityType[et].push(parseItem(context, item)) 59 | } else accum.unparsedItems.push(item) 60 | return accum 61 | }, 62 | { 63 | itemsByEntityType: {}, 64 | unparsedItems: [] 65 | } 66 | ) 67 | 68 | return { 69 | itemsByEntityType, 70 | ...commonCollectionResponseElements(unparsedItems, unparsedResult) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/internal/multipleEntities/multipleEntityQueryOperations.ts: -------------------------------------------------------------------------------- 1 | import { expressionAttributeParams } from '../common/operationsCommon' 2 | import { MultipleEntityCollectionResponse } from '../../multipleEntityOperations' 3 | import { EntityContext } from '../entityContext' 4 | import { QueryCommandInput } from '@aws-sdk/lib-dynamodb' 5 | import { 6 | EntityContextsByEntityType, 7 | performMultipleEntityOperationAndParse 8 | } from './multipleEntitiesQueryAndScanCommon' 9 | import { findGsiDetails } from '../common/gsiQueryCommon' 10 | import { SkQueryRange } from '../../singleEntityOperations' 11 | import { configureQueryOperation } from '../common/queryAndScanCommon' 12 | import { 13 | AdvancedGsiQueryOnePageOptions, 14 | AdvancedQueryOnePageOptions 15 | } from '../../singleEntityAdvancedOperations' 16 | import { Entity } from '../../entities' 17 | import { throwError } from '../../util' 18 | 19 | export async function queryMultipleByPk( 20 | contexts: EntityContextsByEntityType, 21 | keyEntity: Entity, 22 | pkSource: TPKSource, 23 | allPages: boolean, 24 | options: AdvancedQueryOnePageOptions = {} 25 | ): Promise { 26 | const keyEntityContext = findKeyEntityContext(contexts, keyEntity) 27 | return await queryMultipleWithCriteria( 28 | contexts, 29 | keyEntityContext, 30 | options, 31 | `${keyEntityContext.metaAttributeNames.pk} = :pk`, 32 | expressionAttributeParams({ ':pk': keyEntityContext.entity.pk(pkSource) }), 33 | allPages 34 | ) 35 | } 36 | 37 | export async function queryMultipleBySkRange( 38 | contexts: EntityContextsByEntityType, 39 | keyEntity: Entity, 40 | pkSource: TPKSource, 41 | queryRange: SkQueryRange, 42 | allPages: boolean, 43 | options: AdvancedQueryOnePageOptions = {} 44 | ) { 45 | const keyEntityContext = findKeyEntityContext(contexts, keyEntity) 46 | if (!keyEntityContext.metaAttributeNames.sk) 47 | throw new Error('Unable to query by sk - table has no sort key') 48 | return await queryMultipleWithCriteria( 49 | contexts, 50 | keyEntityContext, 51 | options, 52 | `${keyEntityContext.metaAttributeNames.pk} = :pk and ${queryRange.skConditionExpressionClause}`, 53 | expressionAttributeParams( 54 | { 55 | ':pk': keyEntityContext.entity.pk(pkSource), 56 | ...queryRange.expressionAttributeValues 57 | }, 58 | { 59 | '#sk': keyEntityContext.metaAttributeNames.sk 60 | } 61 | ), 62 | allPages 63 | ) 64 | } 65 | 66 | export async function queryMultipleByGsiPk( 67 | contexts: EntityContextsByEntityType, 68 | keyEntity: Entity, 69 | pkSource: TGSIPKSource, 70 | allPages: boolean, 71 | options: AdvancedGsiQueryOnePageOptions = {} 72 | ): Promise { 73 | const keyEntityContext = findKeyEntityContext(contexts, keyEntity) 74 | const gsiDetails = findGsiDetails(keyEntityContext, options) 75 | 76 | return await queryMultipleWithCriteria( 77 | contexts, 78 | keyEntityContext, 79 | options, 80 | `${gsiDetails.attributeNames.pk} = :pk`, 81 | { 82 | IndexName: gsiDetails.tableIndexName, 83 | ...expressionAttributeParams({ ':pk': gsiDetails.generators.pk(pkSource) }) 84 | }, 85 | allPages 86 | ) 87 | } 88 | 89 | export async function queryMultipleByGsiSkRange( 90 | contexts: EntityContextsByEntityType, 91 | keyEntity: Entity, 92 | pkSource: TGSIPKSource, 93 | queryRange: SkQueryRange, 94 | allPages: boolean, 95 | options: AdvancedGsiQueryOnePageOptions = {} 96 | ) { 97 | const keyEntityContext = findKeyEntityContext(contexts, keyEntity) 98 | const gsiDetails = findGsiDetails(keyEntityContext, options) 99 | if (!gsiDetails.attributeNames.sk) 100 | throw new Error('Unable to query by GSI sk - GSI on table has no sort key') 101 | 102 | return await queryMultipleWithCriteria( 103 | contexts, 104 | keyEntityContext, 105 | options, 106 | `${gsiDetails.attributeNames.pk} = :pk and ${queryRange.skConditionExpressionClause}`, 107 | { 108 | IndexName: gsiDetails.tableIndexName, 109 | ...expressionAttributeParams( 110 | { 111 | ':pk': gsiDetails.generators.pk(pkSource), 112 | ...queryRange.expressionAttributeValues 113 | }, 114 | { 115 | '#sk': gsiDetails.attributeNames.sk, 116 | ...queryRange.expressionAttributeNames 117 | } 118 | ) 119 | }, 120 | allPages 121 | ) 122 | } 123 | 124 | function findKeyEntityContext( 125 | contextsByEntityType: EntityContextsByEntityType, 126 | keyEntity: Entity 127 | ): EntityContext { 128 | return ( 129 | (contextsByEntityType[keyEntity.type] as EntityContext) ?? 130 | throwError(`Unable to find context for entity type ${keyEntity.type}`)() 131 | ) 132 | } 133 | 134 | async function queryMultipleWithCriteria( 135 | contextsByEntityType: EntityContextsByEntityType, 136 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 137 | keyEntityContext: EntityContext, 138 | { scanIndexForward, ...otherOptions }: AdvancedQueryOnePageOptions, 139 | keyConditionExpression: string, 140 | partialCriteria: Omit< 141 | QueryCommandInput, 142 | 'KeyConditionExpression' | 'TableName' | 'ExclusiveStartKey' | 'Limit' | 'ScanIndexForward' 143 | >, 144 | allPages: boolean 145 | ): Promise { 146 | return performMultipleEntityOperationAndParse( 147 | contextsByEntityType, 148 | configureQueryOperation(keyEntityContext, otherOptions, allPages, { 149 | KeyConditionExpression: keyConditionExpression, 150 | ...partialCriteria, 151 | ...(scanIndexForward === false ? { ScanIndexForward: scanIndexForward } : {}) 152 | }), 153 | keyEntityContext 154 | ) 155 | } 156 | -------------------------------------------------------------------------------- /src/lib/internal/multipleEntities/multipleEntityScanOperation.ts: -------------------------------------------------------------------------------- 1 | import { MultipleEntityCollectionResponse } from '../../multipleEntityOperations' 2 | import { 3 | EntityContextsByEntityType, 4 | performMultipleEntityOperationAndParse 5 | } from './multipleEntitiesQueryAndScanCommon' 6 | 7 | import { configureScanOperation } from '../common/queryAndScanCommon' 8 | import { AdvancedScanOnePageOptions } from '../../singleEntityAdvancedOperations' 9 | 10 | export async function scanMultiple( 11 | contextsByEntityType: EntityContextsByEntityType, 12 | allPages: boolean, 13 | options: AdvancedScanOnePageOptions = {} 14 | ): Promise { 15 | const defaultEntityContext = Object.values(contextsByEntityType)[0] 16 | 17 | if (defaultEntityContext.allowScans === undefined || !defaultEntityContext.allowScans) { 18 | throw new Error('Scan operations are disabled for this store') 19 | } 20 | 21 | return await performMultipleEntityOperationAndParse( 22 | contextsByEntityType, 23 | configureScanOperation(defaultEntityContext, options, allPages), 24 | defaultEntityContext 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/internal/multipleEntities/tableBackedMultipleEntityOperations.ts: -------------------------------------------------------------------------------- 1 | import { EntityContextResolver } from '../tableBackedConfigurationResolver' 2 | import { Entity } from '../../entities' 3 | import { MultipleEntityCollectionResponse, MultipleEntityOperations } from '../../multipleEntityOperations' 4 | import { createEntityContext } from '../entityContext' 5 | import { scanMultiple } from './multipleEntityScanOperation' 6 | import { 7 | AdvancedGsiQueryAllOptions, 8 | AdvancedGsiQueryOnePageOptions, 9 | AdvancedQueryAllOptions, 10 | AdvancedQueryOnePageOptions, 11 | AdvancedScanAllOptions, 12 | AdvancedScanOnePageOptions 13 | } from '../../singleEntityAdvancedOperations' 14 | import { 15 | queryMultipleByGsiPk, 16 | queryMultipleByGsiSkRange, 17 | queryMultipleByPk, 18 | queryMultipleBySkRange 19 | } from './multipleEntityQueryOperations' 20 | import { SkQueryRange } from '../../singleEntityOperations' 21 | import { EntityContextsByEntityType } from './multipleEntitiesQueryAndScanCommon' 22 | 23 | export function tableBackedMultipleEntityOperations( 24 | entityContextResolver: EntityContextResolver, 25 | entities: Entity[] 26 | ): MultipleEntityOperations { 27 | const contextsByEntityType: EntityContextsByEntityType = Object.fromEntries( 28 | entities.map((e) => [e.type, createEntityContext(entityContextResolver(e.type), e)]) 29 | ) 30 | if (new Set(Object.values(contextsByEntityType).map((c) => c.tableName)).size > 1) 31 | throw new Error( 32 | 'Several tables would be required for this operation - please select only entities stored in one table' 33 | ) 34 | 35 | return { 36 | async queryAllByPk( 37 | keyEntity: Entity, 38 | pkSource: TPKSource, 39 | options?: AdvancedQueryAllOptions 40 | ) { 41 | return queryMultipleByPk(contextsByEntityType, keyEntity, pkSource, true, options) 42 | }, 43 | async queryOnePageByPk( 44 | keyEntity: Entity, 45 | pkSource: TPKSource, 46 | options?: AdvancedQueryOnePageOptions 47 | ) { 48 | return queryMultipleByPk(contextsByEntityType, keyEntity, pkSource, false, options) 49 | }, 50 | async queryAllByPkAndSk( 51 | keyEntity: Entity, 52 | pkSource: TPKSource, 53 | queryRange: SkQueryRange, 54 | options?: AdvancedQueryAllOptions 55 | ) { 56 | return queryMultipleBySkRange(contextsByEntityType, keyEntity, pkSource, queryRange, true, options) 57 | }, 58 | async queryOnePageByPkAndSk( 59 | keyEntity: Entity, 60 | pkSource: TPKSource, 61 | queryRange: SkQueryRange, 62 | options?: AdvancedQueryOnePageOptions 63 | ) { 64 | return queryMultipleBySkRange(contextsByEntityType, keyEntity, pkSource, queryRange, false, options) 65 | }, 66 | async queryAllWithGsiByPk( 67 | keyEntity: Entity, 68 | pkSource: TGSIPKSource, 69 | options?: AdvancedGsiQueryAllOptions 70 | ) { 71 | return queryMultipleByGsiPk(contextsByEntityType, keyEntity, pkSource, true, options) 72 | }, 73 | async queryOnePageWithGsiByPk( 74 | keyEntity: Entity, 75 | pkSource: TGSIPKSource, 76 | options?: AdvancedQueryOnePageOptions 77 | ) { 78 | return queryMultipleByGsiPk(contextsByEntityType, keyEntity, pkSource, false, options) 79 | }, 80 | async queryAllWithGsiByPkAndSk( 81 | keyEntity: Entity, 82 | pkSource: TGSIPKSource, 83 | queryRange: SkQueryRange, 84 | options?: AdvancedGsiQueryAllOptions 85 | ): Promise { 86 | return queryMultipleByGsiSkRange(contextsByEntityType, keyEntity, pkSource, queryRange, true, options) 87 | }, 88 | async queryOnePageWithGsiByPkAndSk( 89 | keyEntity: Entity, 90 | pkSource: TGSIPKSource, 91 | queryRange: SkQueryRange, 92 | options?: AdvancedGsiQueryOnePageOptions 93 | ): Promise { 94 | return queryMultipleByGsiSkRange(contextsByEntityType, keyEntity, pkSource, queryRange, false, options) 95 | }, 96 | async scanAll(options?: AdvancedScanAllOptions): Promise { 97 | return await scanMultiple(contextsByEntityType, true, options) 98 | }, 99 | async scanOnePage(options?: AdvancedScanOnePageOptions) { 100 | return await scanMultiple(contextsByEntityType, false, options) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/batchDeleteItems.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { keyParamFromSource } from '../common/operationsCommon' 3 | import { batchWrite, createWriteParamsBatches } from './batchWriteCommon' 4 | import { BatchWriteCommandInput } from '@aws-sdk/lib-dynamodb' 5 | import { BatchDeleteOptions } from '../../singleEntityAdvancedOperations' 6 | 7 | export async function deleteItems< 8 | TItem extends TPKSource & TSKSource, 9 | TKeySource extends TPKSource & TSKSource, 10 | TPKSource, 11 | TSKSource 12 | >( 13 | context: EntityContext, 14 | keySources: TKeySource[], 15 | options?: BatchDeleteOptions 16 | ) { 17 | return await batchWrite(context, deleteParamsBatches(context, keySources, options)) 18 | } 19 | 20 | export function deleteParamsBatches< 21 | TItem extends TPKSource & TSKSource, 22 | TKeySource extends TPKSource & TSKSource, 23 | TPKSource, 24 | TSKSource 25 | >( 26 | context: EntityContext, 27 | keySources: TKeySource[], 28 | options?: BatchDeleteOptions 29 | ): BatchWriteCommandInput[] { 30 | function toDeleteRequest(keySource: TKeySource) { 31 | return { 32 | DeleteRequest: keyParamFromSource(context, keySource) 33 | } 34 | } 35 | 36 | return createWriteParamsBatches(keySources, context.tableName, toDeleteRequest, options) 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/batchGetItems.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { chunk, isDebugLoggingEnabled, removeNullOrUndefined } from '../../util' 3 | import { DEFAULT_AND_MAX_BATCH_READ_SIZE } from './batchWriteCommon' 4 | import { createKeyFromSource, parseItem, returnConsumedCapacityParam } from '../common/operationsCommon' 5 | import { BatchGetCommandInput, BatchGetCommandOutput } from '@aws-sdk/lib-dynamodb' 6 | import { AdvancedBatchGetResponse, BatchGetOptions } from '../../singleEntityAdvancedOperations' 7 | 8 | export async function getItems< 9 | TItem extends TPKSource & TSKSource, 10 | TKeySource extends TPKSource & TSKSource, 11 | TPKSource, 12 | TSKSource 13 | >( 14 | context: EntityContext, 15 | keySources: TKeySource[], 16 | options?: BatchGetOptions 17 | ): Promise> { 18 | const batchesParams = createBatchesParams(context, keySources, options) 19 | const results = await executeAllBatches(context, batchesParams) 20 | return parseBatchResults(context, results) 21 | } 22 | 23 | // TODO - unit test consistent read is getting set 24 | export function createBatchesParams< 25 | TItem extends TPKSource & TSKSource, 26 | TKeySource extends TPKSource & TSKSource, 27 | TPKSource, 28 | TSKSource 29 | >( 30 | context: EntityContext, 31 | keySources: TKeySource[], 32 | options?: BatchGetOptions 33 | ): BatchGetCommandInput[] { 34 | return chunk(keySources, options?.batchSize ?? DEFAULT_AND_MAX_BATCH_READ_SIZE).map((batch) => { 35 | return { 36 | RequestItems: { 37 | [context.tableName]: { 38 | Keys: batch.map((keySource: TKeySource) => createKeyFromSource(context, keySource)), 39 | ...(options?.consistentRead !== undefined ? { ConsistentRead: options.consistentRead } : {}) 40 | } 41 | }, 42 | ...returnConsumedCapacityParam(options) 43 | } 44 | }) 45 | } 46 | 47 | export async function executeAllBatches( 48 | context: EntityContext, 49 | patchesParams: BatchGetCommandInput[] 50 | ): Promise { 51 | const results: BatchGetCommandOutput[] = [] 52 | for (const batch of patchesParams) { 53 | if (isDebugLoggingEnabled(context.logger)) { 54 | context.logger.debug(`Attempting to get batch ${context.entity.type}`, { batch }) 55 | } 56 | const result = await context.dynamoDB.batchGet(batch) 57 | if (isDebugLoggingEnabled(context.logger)) { 58 | context.logger.debug(`Get batch result`, { result }) 59 | } 60 | results.push(result) 61 | } 62 | return results 63 | } 64 | 65 | // TODO - unprocessed items need testing (either integration if we can force it, or unit) 66 | export function parseBatchResults( 67 | context: EntityContext, 68 | results: BatchGetCommandOutput[] 69 | ): AdvancedBatchGetResponse { 70 | const items = results 71 | .map((r) => Object.values(r.Responses ?? {})) 72 | .flat(2) 73 | .map((item) => parseItem(context, item)) 74 | 75 | const unprocessedKeys = results 76 | .map((r) => { 77 | return Object.values(r.UnprocessedKeys ?? {}).map((unprocessed) => unprocessed.Keys ?? []) 78 | }) 79 | .flat(2) 80 | 81 | const consumedCapacities = removeNullOrUndefined(results.map((r) => r.ConsumedCapacity)).flat() 82 | 83 | return { 84 | items, 85 | ...(unprocessedKeys.length > 0 ? { unprocessedKeys } : {}), 86 | ...(consumedCapacities.length > 0 ? { metadata: { consumedCapacities } } : {}) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/batchPutItems.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { batchWrite, createWriteParamsBatches } from './batchWriteCommon' 3 | import { BatchWriteCommandInput } from '@aws-sdk/lib-dynamodb' 4 | import { itemParam } from '../common/putCommon' 5 | import { BatchPutOptions } from '../../singleEntityAdvancedOperations' 6 | 7 | export async function batchPutItems( 8 | context: EntityContext, 9 | items: TItem[], 10 | options?: BatchPutOptions 11 | ) { 12 | return await batchWrite(context, putParamsBatches(context, items, options)) 13 | } 14 | 15 | export function putParamsBatches( 16 | context: EntityContext, 17 | items: TItem[], 18 | options?: BatchPutOptions 19 | ): BatchWriteCommandInput[] { 20 | function toPutRequest(item: TItem) { 21 | return { 22 | PutRequest: { 23 | ...itemParam(context, item, options) 24 | } 25 | } 26 | } 27 | 28 | return createWriteParamsBatches(items, context.tableName, toPutRequest, options) 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/batchWriteCommon.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { chunk, isDebugLoggingEnabled, removeNullOrUndefined } from '../../util' 3 | import { BatchWriteCommandInput, BatchWriteCommandOutput } from '@aws-sdk/lib-dynamodb' 4 | import { DynamoDBValues } from '../../entities' 5 | import { returnConsumedCapacityParam, returnItemCollectionMetricsParam } from '../common/operationsCommon' 6 | import { 7 | AdvancedBatchWriteResponse, 8 | BatchDeleteOptions, 9 | BatchPutOptions, 10 | ConsumedCapacitiesMetadata, 11 | ItemCollectionMetricsCollectionMetadata 12 | } from '../../singleEntityAdvancedOperations' 13 | 14 | export const DEFAULT_AND_MAX_BATCH_WRITE_SIZE = 25 15 | export const DEFAULT_AND_MAX_BATCH_READ_SIZE = 100 16 | 17 | export function createWriteParamsBatches( 18 | allItems: T[], 19 | tableName: string, 20 | generateRequest: (item: T) => { 21 | DeleteRequest?: { Key: DynamoDBValues } 22 | PutRequest?: { Item: DynamoDBValues } 23 | }, 24 | options?: BatchDeleteOptions | BatchPutOptions 25 | ): BatchWriteCommandInput[] { 26 | return chunk(allItems, options?.batchSize ?? DEFAULT_AND_MAX_BATCH_WRITE_SIZE).map((batch) => { 27 | return { 28 | RequestItems: { 29 | [tableName]: batch.map(generateRequest) 30 | }, 31 | ...returnConsumedCapacityParam(options), 32 | ...returnItemCollectionMetricsParam(options) 33 | } 34 | }) 35 | } 36 | 37 | // TODO - unprocessed items need testing (either integration if we can force it, or unit) 38 | export async function batchWrite( 39 | context: EntityContext, 40 | paramsBatches: BatchWriteCommandInput[] 41 | ): Promise { 42 | const results: BatchWriteCommandOutput[] = [] 43 | 44 | for (const batch of paramsBatches) { 45 | if (isDebugLoggingEnabled(context.logger)) { 46 | context.logger.debug(`Attempting to write batch ${context.entity.type}`, { batch }) 47 | } 48 | const result = await context.dynamoDB.batchWrite(batch) 49 | results.push(result) 50 | if (isDebugLoggingEnabled(context.logger)) { 51 | context.logger.debug(`Write batch result`, { result }) 52 | } 53 | } 54 | 55 | // Ignores the table on UnprocessedItems since this operation is in the context of only using one table 56 | const unprocessedItems = results 57 | .map((r) => (r.UnprocessedItems ? Object.values(r.UnprocessedItems) : [])) 58 | .flat(2) 59 | 60 | const consumedCapacities = removeNullOrUndefined(results.map((r) => r.ConsumedCapacity)).flat() 61 | const allItemCollectionMetrics = removeNullOrUndefined(results.map((r) => r.ItemCollectionMetrics)) 62 | 63 | const metadata: ConsumedCapacitiesMetadata & ItemCollectionMetricsCollectionMetadata = { 64 | ...(consumedCapacities.length > 0 ? { consumedCapacities: consumedCapacities } : {}), 65 | ...(allItemCollectionMetrics.length > 0 66 | ? { itemCollectionMetricsCollection: allItemCollectionMetrics } 67 | : {}) 68 | } 69 | return { 70 | ...(unprocessedItems.length > 0 ? { unprocessedItems } : {}), 71 | ...(metadata.consumedCapacities || metadata.itemCollectionMetricsCollection ? { metadata } : {}) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/deleteItem.ts: -------------------------------------------------------------------------------- 1 | import { DeleteCommandInput } from '@aws-sdk/lib-dynamodb' 2 | import { EntityContext } from '../entityContext' 3 | import { deleteParams } from '../common/deleteCommon' 4 | import { isDebugLoggingEnabled } from '../../util' 5 | import { parseAttributesCapacityAndMetrics } from './singleEntityCommon' 6 | import { AdvancedDeleteOptions, AdvancedDeleteResponse } from '../../singleEntityAdvancedOperations' 7 | import { returnParamsForCapacityMetricsAndValues } from '../common/operationsCommon' 8 | 9 | export async function deleteItem< 10 | TItem extends TPKSource & TSKSource, 11 | TKeySource extends TPKSource & TSKSource, 12 | TPKSource, 13 | TSKSource 14 | >( 15 | context: EntityContext, 16 | keySource: TKeySource, 17 | options?: AdvancedDeleteOptions 18 | ): Promise { 19 | const params = { 20 | ...deleteParams(context, keySource, options), 21 | ...returnParamsForCapacityMetricsAndValues(options) 22 | } 23 | 24 | const result = await executeRequest(context, params) 25 | return parseAttributesCapacityAndMetrics(result) 26 | } 27 | 28 | export async function executeRequest( 29 | context: EntityContext, 30 | params: DeleteCommandInput 31 | ) { 32 | if (isDebugLoggingEnabled(context.logger)) { 33 | context.logger.debug(`Attempting to delete ${context.entity.type}`, { params }) 34 | } 35 | const result = await context.dynamoDB.delete(params) 36 | if (isDebugLoggingEnabled(context.logger)) { 37 | context.logger.debug(`Delete result`, { result }) 38 | } 39 | return result 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/getItem.ts: -------------------------------------------------------------------------------- 1 | import { 2 | keyParamFromSource, 3 | parseItem, 4 | returnConsumedCapacityParam, 5 | tableNameParam 6 | } from '../common/operationsCommon' 7 | import { EntityContext } from '../entityContext' 8 | import { isDebugLoggingEnabled } from '../../util' 9 | import { GetCommandInput, GetCommandOutput } from '@aws-sdk/lib-dynamodb' 10 | import { AdvancedGetOptions, AdvancedGetResponse } from '../../singleEntityAdvancedOperations' 11 | 12 | export async function getItem< 13 | TItem extends TPKSource & TSKSource, 14 | TKeySource extends TPKSource & TSKSource, 15 | TPKSource, 16 | TSKSource 17 | >( 18 | context: EntityContext, 19 | keySource: TKeySource, 20 | options?: AdvancedGetOptions 21 | ): Promise> { 22 | const params = createGetItemParams(context, keySource, options) 23 | const result = await executeRequest(context, params) 24 | return parseResult(context, result) 25 | } 26 | 27 | export function createGetItemParams< 28 | TItem extends TPKSource & TSKSource, 29 | TKeySource extends TPKSource & TSKSource, 30 | TPKSource, 31 | TSKSource 32 | >( 33 | context: EntityContext, 34 | keySource: TKeySource, 35 | options?: AdvancedGetOptions 36 | ): GetCommandInput { 37 | return { 38 | ...tableNameParam(context), 39 | ...keyParamFromSource(context, keySource), 40 | ...returnConsumedCapacityParam(options), 41 | ...(options?.consistentRead !== undefined ? { ConsistentRead: options.consistentRead } : {}) 42 | } 43 | } 44 | 45 | export async function executeRequest( 46 | context: EntityContext, 47 | params: GetCommandInput 48 | ) { 49 | if (isDebugLoggingEnabled(context.logger)) { 50 | context.logger.debug(`Attempting to get ${context.entity.type}`, { params }) 51 | } 52 | const result = await context.dynamoDB.get(params) 53 | if (isDebugLoggingEnabled(context.logger)) { 54 | context.logger.debug(`Get result`, { getResult: result }) 55 | } 56 | return result 57 | } 58 | 59 | export function parseResult( 60 | context: EntityContext, 61 | result: GetCommandOutput 62 | ) { 63 | const unparsedItem = result.Item 64 | return { 65 | item: unparsedItem ? parseItem(context, unparsedItem) : unparsedItem, 66 | ...(result.ConsumedCapacity ? { metadata: { consumedCapacity: result.ConsumedCapacity } } : {}) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/putItem.ts: -------------------------------------------------------------------------------- 1 | import { PutCommandInput } from '@aws-sdk/lib-dynamodb' 2 | import { EntityContext } from '../entityContext' 3 | import { isDebugLoggingEnabled } from '../../util' 4 | import { parseAttributesCapacityAndMetrics } from './singleEntityCommon' 5 | import { AdvancedPutOptions, AdvancedPutResponse } from '../../singleEntityAdvancedOperations' 6 | import { putParams } from '../common/putCommon' 7 | import { returnParamsForCapacityMetricsAndValues } from '../common/operationsCommon' 8 | 9 | export async function putItem( 10 | context: EntityContext, 11 | item: TItem, 12 | options?: AdvancedPutOptions 13 | ): Promise { 14 | const params = { 15 | ...putParams(context, item, options), 16 | ...returnParamsForCapacityMetricsAndValues(options) 17 | } 18 | const result = await executeRequest(context, params) 19 | return parseAttributesCapacityAndMetrics(result) 20 | } 21 | 22 | export async function executeRequest( 23 | context: EntityContext, 24 | params: PutCommandInput 25 | ) { 26 | if (isDebugLoggingEnabled(context.logger)) { 27 | context.logger.debug(`Attempting to put ${context.entity.type}`, { params }) 28 | } 29 | const result = await context.dynamoDB.put(params) 30 | if (isDebugLoggingEnabled(context.logger)) { 31 | context.logger.debug(`Put result`, { result }) 32 | } 33 | return result 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/queryItems.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { 3 | configureQueryOperation, 4 | executeQueryOrScan, 5 | parseResultsForEntity 6 | } from '../common/queryAndScanCommon' 7 | import { QueryCommandInput } from '@aws-sdk/lib-dynamodb' 8 | import { AdvancedCollectionResponse, AdvancedQueryOnePageOptions } from '../../singleEntityAdvancedOperations' 9 | import { expressionAttributeParams } from '../common/operationsCommon' 10 | import { GsiDetails } from '../common/gsiQueryCommon' 11 | import { SkQueryRange } from '../../singleEntityOperations' 12 | 13 | export interface QueryCriteria { 14 | keyConditionExpression: string 15 | partialCriteria: Omit< 16 | QueryCommandInput, 17 | 'KeyConditionExpression' | 'TableName' | 'ExclusiveStartKey' | 'Limit' | 'ScanIndexForward' 18 | > 19 | } 20 | 21 | export function pkQueryCriteria( 22 | { metaAttributeNames, entity }: EntityContext, 23 | source: TPKSource 24 | ): QueryCriteria { 25 | return { 26 | keyConditionExpression: `${metaAttributeNames.pk} = :pk`, 27 | partialCriteria: expressionAttributeParams({ ':pk': entity.pk(source) }) 28 | } 29 | } 30 | 31 | export function skRangeQueryCriteria( 32 | { metaAttributeNames, entity }: EntityContext, 33 | pkSource: TPKSource, 34 | queryRange: SkQueryRange 35 | ): QueryCriteria { 36 | if (!metaAttributeNames.sk) throw new Error('Unable to query by sk - table has no sort key') 37 | return { 38 | keyConditionExpression: `${metaAttributeNames.pk} = :pk and ${queryRange.skConditionExpressionClause}`, 39 | partialCriteria: expressionAttributeParams( 40 | { 41 | ':pk': entity.pk(pkSource), 42 | ...queryRange.expressionAttributeValues 43 | }, 44 | { 45 | '#sk': metaAttributeNames.sk 46 | } 47 | ) 48 | } 49 | } 50 | 51 | export function gsiPkQueryCriteria(gsiDetails: GsiDetails, pkSource: unknown): QueryCriteria { 52 | return { 53 | keyConditionExpression: `${gsiDetails.attributeNames.pk} = :pk`, 54 | partialCriteria: { 55 | IndexName: gsiDetails.tableIndexName, 56 | ...expressionAttributeParams({ ':pk': gsiDetails.generators.pk(pkSource) }) 57 | } 58 | } 59 | } 60 | 61 | export function gsiSkRangeQueryCriteria( 62 | gsiDetails: GsiDetails, 63 | pkSource: unknown, 64 | queryRange: SkQueryRange 65 | ): QueryCriteria { 66 | if (!gsiDetails.attributeNames.sk) 67 | throw new Error('Unable to query by GSI sk - GSI on table has no sort key') 68 | return { 69 | keyConditionExpression: `${gsiDetails.attributeNames.pk} = :pk and ${queryRange.skConditionExpressionClause}`, 70 | partialCriteria: { 71 | IndexName: gsiDetails.tableIndexName, 72 | ...expressionAttributeParams( 73 | { 74 | ':pk': gsiDetails.generators.pk(pkSource), 75 | ...queryRange.expressionAttributeValues 76 | }, 77 | { 78 | '#sk': gsiDetails.attributeNames.sk, 79 | ...queryRange.expressionAttributeNames 80 | } 81 | ) 82 | } 83 | } 84 | } 85 | 86 | export async function queryItems( 87 | context: EntityContext, 88 | { keyConditionExpression, partialCriteria }: QueryCriteria, 89 | allPages: boolean, 90 | { scanIndexForward, ...otherOptions }: AdvancedQueryOnePageOptions 91 | ): Promise> { 92 | const queryConfig = configureQueryOperation(context, otherOptions, allPages, { 93 | KeyConditionExpression: keyConditionExpression, 94 | ...partialCriteria, 95 | ...(scanIndexForward === false ? { ScanIndexForward: scanIndexForward } : {}) 96 | }) 97 | const result = await executeQueryOrScan(queryConfig, context.logger, context.entity.type) 98 | return parseResultsForEntity(context, result) 99 | } 100 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/scanItems.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { 3 | configureScanOperation, 4 | executeQueryOrScan, 5 | parseResultsForEntity 6 | } from '../common/queryAndScanCommon' 7 | import { AdvancedCollectionResponse, AdvancedScanOnePageOptions } from '../../singleEntityAdvancedOperations' 8 | import { GsiDetails } from '../common/gsiQueryCommon' 9 | 10 | export async function scanItems( 11 | context: EntityContext, 12 | options: AdvancedScanOnePageOptions, 13 | allPages: boolean, 14 | gsiDetails?: GsiDetails 15 | ): Promise> { 16 | if (context.allowScans === undefined || !context.allowScans) 17 | throw new Error('Scan operations are disabled for this store') 18 | 19 | const scanConfig = configureScanOperation(context, options, allPages, gsiDetails) 20 | const result = await executeQueryOrScan(scanConfig, context.logger, context.entity.type) 21 | return parseResultsForEntity(context, result) 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/singleEntityCommon.ts: -------------------------------------------------------------------------------- 1 | import { DeleteItemOutput } from '@aws-sdk/client-dynamodb' 2 | import { 3 | parseConsumedCapacityAndItemCollectionMetrics, 4 | parseUnparsedReturnedAttributes 5 | } from '../common/operationsCommon' 6 | 7 | export function parseAttributesCapacityAndMetrics( 8 | result: Pick 9 | ) { 10 | return { 11 | ...parseUnparsedReturnedAttributes(result), 12 | ...parseConsumedCapacityAndItemCollectionMetrics(result) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/tableBackedSingleEntityAdvancedOperations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AdvancedDeleteOptions, 3 | AdvancedGetOptions, 4 | AdvancedGsiQueryAllOptions, 5 | AdvancedGsiQueryOnePageOptions, 6 | AdvancedGsiScanAllOptions, 7 | AdvancedGsiScanOnePageOptions, 8 | AdvancedPutOptions, 9 | AdvancedQueryAllOptions, 10 | AdvancedQueryOnePageOptions, 11 | AdvancedScanAllOptions, 12 | AdvancedScanOnePageOptions, 13 | AdvancedUpdateOptions, 14 | BatchDeleteOptions, 15 | BatchGetOptions, 16 | BatchPutOptions, 17 | SingleEntityAdvancedOperations 18 | } from '../../singleEntityAdvancedOperations' 19 | import { EntityContext } from '../entityContext' 20 | import { putItem } from './putItem' 21 | import { getItem } from './getItem' 22 | import { updateItem } from './updateItem' 23 | import { deleteItem } from './deleteItem' 24 | import { batchPutItems } from './batchPutItems' 25 | import { deleteItems } from './batchDeleteItems' 26 | import { getItems } from './batchGetItems' 27 | import { 28 | gsiPkQueryCriteria, 29 | gsiSkRangeQueryCriteria, 30 | pkQueryCriteria, 31 | queryItems, 32 | skRangeQueryCriteria 33 | } from './queryItems' 34 | import { SkQueryRange } from '../../singleEntityOperations' 35 | import { findGsiDetails } from '../common/gsiQueryCommon' 36 | import { scanItems } from './scanItems' 37 | 38 | export function tableBackedSingleEntityAdvancedOperations< 39 | TItem extends TPKSource & TSKSource, 40 | TPKSource, 41 | TSKSource 42 | >( 43 | entityContext: EntityContext 44 | ): SingleEntityAdvancedOperations { 45 | async function queryByPk(pkSource: TPKSource, allPages: boolean, options: AdvancedQueryAllOptions = {}) { 46 | return queryItems(entityContext, pkQueryCriteria(entityContext, pkSource), allPages, options) 47 | } 48 | 49 | async function queryByPkAndSk( 50 | pkSource: TPKSource, 51 | queryRange: SkQueryRange, 52 | allPages: boolean, 53 | options: AdvancedQueryAllOptions = {} 54 | ) { 55 | return queryItems( 56 | entityContext, 57 | skRangeQueryCriteria(entityContext, pkSource, queryRange), 58 | allPages, 59 | options 60 | ) 61 | } 62 | 63 | async function queryGsiByPk( 64 | pkSource: TGSIPKSource, 65 | allPages: boolean, 66 | options: AdvancedGsiQueryAllOptions = {} 67 | ) { 68 | const gsiDetails = findGsiDetails(entityContext, options) 69 | return await queryItems(entityContext, gsiPkQueryCriteria(gsiDetails, pkSource), allPages, options) 70 | } 71 | 72 | async function queryGsiByPkAndSk( 73 | pkSource: TGSIPKSource, 74 | queryRange: SkQueryRange, 75 | allPages: boolean, 76 | options: AdvancedGsiQueryAllOptions = {} 77 | ) { 78 | const gsiDetails = findGsiDetails(entityContext, options) 79 | return await queryItems( 80 | entityContext, 81 | gsiSkRangeQueryCriteria(gsiDetails, pkSource, queryRange), 82 | allPages, 83 | options 84 | ) 85 | } 86 | 87 | return { 88 | async put(item: TItem, options?: AdvancedPutOptions) { 89 | return putItem(entityContext, item, options) 90 | }, 91 | async update( 92 | keySource: TKeySource, 93 | options: AdvancedUpdateOptions = {} 94 | ) { 95 | return updateItem(entityContext, keySource, options) 96 | }, 97 | async getOrUndefined( 98 | keySource: TKeySource, 99 | options?: AdvancedGetOptions 100 | ) { 101 | return getItem(entityContext, keySource, options) 102 | }, 103 | async getOrThrow( 104 | keySource: TKeySource, 105 | options?: AdvancedGetOptions 106 | ) { 107 | const { item, ...restOfResponse } = await getItem(entityContext, keySource, options) 108 | if (item) return { item, ...restOfResponse } 109 | throw new Error( 110 | `Unable to find item for entity [${entityContext.entity.type}] with key source ${JSON.stringify( 111 | keySource 112 | )}` 113 | ) 114 | }, 115 | async delete( 116 | keySource: TKeySource, 117 | options?: AdvancedDeleteOptions 118 | ) { 119 | return deleteItem(entityContext, keySource, options) 120 | }, 121 | async queryAllByPk(pkSource: TPKSource, options?: AdvancedQueryAllOptions) { 122 | return queryByPk(pkSource, true, options) 123 | }, 124 | async queryOnePageByPk(pkSource: TPKSource, options?: AdvancedQueryOnePageOptions) { 125 | return queryByPk(pkSource, false, options) 126 | }, 127 | async queryAllByPkAndSk( 128 | pkSource: TPKSource, 129 | queryRange: SkQueryRange, 130 | options?: AdvancedQueryAllOptions 131 | ) { 132 | return queryByPkAndSk(pkSource, queryRange, true, options) 133 | }, 134 | async queryOnePageByPkAndSk( 135 | pkSource: TPKSource, 136 | queryRange: SkQueryRange, 137 | options?: AdvancedQueryOnePageOptions 138 | ) { 139 | return queryByPkAndSk(pkSource, queryRange, false, options) 140 | }, 141 | async queryAllWithGsiByPk(pkSource: TGSIPKSource, options?: AdvancedGsiQueryAllOptions) { 142 | return queryGsiByPk(pkSource, true, options) 143 | }, 144 | async queryOnePageWithGsiByPk( 145 | pkSource: TGSIPKSource, 146 | options?: AdvancedGsiQueryOnePageOptions 147 | ) { 148 | return queryGsiByPk(pkSource, false, options) 149 | }, 150 | async queryAllWithGsiByPkAndSk( 151 | pkSource: TGSIPKSource, 152 | queryRange: SkQueryRange, 153 | options?: AdvancedGsiQueryAllOptions 154 | ) { 155 | return queryGsiByPkAndSk(pkSource, queryRange, true, options) 156 | }, 157 | async queryOnePageWithGsiByPkAndSk( 158 | pkSource: TGSIPKSource, 159 | queryRange: SkQueryRange, 160 | options: AdvancedGsiQueryOnePageOptions = {} 161 | ) { 162 | return queryGsiByPkAndSk(pkSource, queryRange, false, options) 163 | }, 164 | async scanAll(options: AdvancedScanAllOptions = {}) { 165 | return scanItems(entityContext, options, true) 166 | }, 167 | async scanOnePage(options: AdvancedScanOnePageOptions = {}) { 168 | return scanItems(entityContext, options, false) 169 | }, 170 | async scanAllWithGsi(options: AdvancedGsiScanAllOptions = {}) { 171 | return scanItems(entityContext, options, true, findGsiDetails(entityContext, options)) 172 | }, 173 | async scanOnePageWithGsi(options: AdvancedGsiScanOnePageOptions = {}) { 174 | return scanItems(entityContext, options, false, findGsiDetails(entityContext, options)) 175 | }, 176 | async batchPut(items: TItem[], options?: BatchPutOptions) { 177 | return batchPutItems(entityContext, items, options) 178 | }, 179 | async batchDelete( 180 | keySources: TKeySource[], 181 | options?: BatchDeleteOptions 182 | ) { 183 | return deleteItems(entityContext, keySources, options) 184 | }, 185 | async batchGet( 186 | keySources: TKeySource[], 187 | options?: BatchGetOptions 188 | ) { 189 | return getItems(entityContext, keySources, options) 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/tableBackedSingleEntityOperations.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { 3 | DeleteOptions, 4 | GetOptions, 5 | GsiQueryAllOptions, 6 | GsiQueryOnePageOptions, 7 | GsiScanAllOptions, 8 | GsiScanOnePageOptions, 9 | OnePageResponse, 10 | PutOptions, 11 | QueryAllOptions, 12 | QueryOnePageOptions, 13 | ScanAllOptions, 14 | ScanOnePageOptions, 15 | SingleEntityOperations, 16 | SkQueryRange, 17 | UpdateOptions 18 | } from '../../singleEntityOperations' 19 | import { tableBackedSingleEntityAdvancedOperations } from './tableBackedSingleEntityAdvancedOperations' 20 | 21 | export function tableBackedSingleEntityOperations( 22 | entityContext: EntityContext 23 | ): SingleEntityOperations { 24 | const advancedOperations = tableBackedSingleEntityAdvancedOperations(entityContext) 25 | 26 | return { 27 | advancedOperations, 28 | 29 | async put(item: TItem, options?: PutOptions): Promise { 30 | await advancedOperations.put(item, options) 31 | return item 32 | }, 33 | 34 | async update( 35 | keySource: TKeySource, 36 | options?: UpdateOptions 37 | ): Promise { 38 | await advancedOperations.update(keySource, options) 39 | }, 40 | 41 | async getOrUndefined( 42 | keySource: TKeySource, 43 | options?: GetOptions 44 | ): Promise { 45 | return (await advancedOperations.getOrUndefined(keySource, options)).item 46 | }, 47 | 48 | async getOrThrow( 49 | keySource: TKeySource, 50 | options?: GetOptions 51 | ): Promise { 52 | return (await advancedOperations.getOrThrow(keySource, options)).item 53 | }, 54 | 55 | async delete( 56 | keySource: TKeySource, 57 | options?: DeleteOptions 58 | ): Promise { 59 | await advancedOperations.delete(keySource, options) 60 | }, 61 | 62 | async queryAllByPk(pkSource: TPKSource, options: QueryAllOptions = {}): Promise { 63 | return (await advancedOperations.queryAllByPk(pkSource, options)).items 64 | }, 65 | 66 | async queryOnePageByPk( 67 | pkSource: TPKSource, 68 | options: QueryOnePageOptions = {} 69 | ): Promise> { 70 | return await advancedOperations.queryOnePageByPk(pkSource, options) 71 | }, 72 | 73 | async queryAllByPkAndSk( 74 | pkSource: TPKSource, 75 | queryRange: SkQueryRange, 76 | options: QueryAllOptions = {} 77 | ): Promise { 78 | return (await advancedOperations.queryAllByPkAndSk(pkSource, queryRange, options)).items 79 | }, 80 | 81 | async queryOnePageByPkAndSk( 82 | pkSource: TPKSource, 83 | queryRange: SkQueryRange, 84 | options: QueryOnePageOptions = {} 85 | ): Promise> { 86 | return await advancedOperations.queryOnePageByPkAndSk(pkSource, queryRange, options) 87 | }, 88 | 89 | async queryAllWithGsiByPk( 90 | pkSource: TGSIPKSource, 91 | options: GsiQueryAllOptions = {} 92 | ): Promise { 93 | return (await advancedOperations.queryAllWithGsiByPk(pkSource, options)).items 94 | }, 95 | 96 | async queryOnePageWithGsiByPk( 97 | pkSource: TGSIPKSource, 98 | options: GsiQueryOnePageOptions = {} 99 | ): Promise> { 100 | return await advancedOperations.queryOnePageWithGsiByPk(pkSource, options) 101 | }, 102 | 103 | async queryAllWithGsiByPkAndSk( 104 | pkSource: TGSIPKSource, 105 | queryRange: SkQueryRange, 106 | options: GsiQueryAllOptions = {} 107 | ): Promise { 108 | return (await advancedOperations.queryAllWithGsiByPkAndSk(pkSource, queryRange, options)).items 109 | }, 110 | 111 | async queryOnePageWithGsiByPkAndSk( 112 | pkSource: TGSIPKSource, 113 | queryRange: SkQueryRange, 114 | options: GsiQueryOnePageOptions = {} 115 | ): Promise> { 116 | return await advancedOperations.queryOnePageWithGsiByPkAndSk(pkSource, queryRange, options) 117 | }, 118 | 119 | async scanAll(options: ScanAllOptions = {}) { 120 | return (await advancedOperations.scanAll(options)).items 121 | }, 122 | 123 | async scanOnePage(options: ScanOnePageOptions = {}) { 124 | return await advancedOperations.scanOnePage(options) 125 | }, 126 | 127 | async scanAllWithGsi(options: GsiScanAllOptions = {}) { 128 | return (await advancedOperations.scanAllWithGsi(options)).items 129 | }, 130 | 131 | async scanOnePageWithGsi(options: GsiScanOnePageOptions = {}) { 132 | return await advancedOperations.scanOnePageWithGsi(options) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/lib/internal/singleEntity/updateItem.ts: -------------------------------------------------------------------------------- 1 | import { EntityContext } from '../entityContext' 2 | import { isDebugLoggingEnabled } from '../../util/logger' 3 | import { createUpdateParams } from '../common/updateCommon' 4 | import { UpdateCommandInput } from '@aws-sdk/lib-dynamodb' 5 | import { parseAttributesCapacityAndMetrics } from './singleEntityCommon' 6 | import { AdvancedUpdateOptions, AdvancedUpdateResponse } from '../../singleEntityAdvancedOperations' 7 | import { returnParamsForCapacityMetricsAndValues } from '../common/operationsCommon' 8 | 9 | export async function updateItem< 10 | TItem extends TPKSource & TSKSource, 11 | TKeySource extends TPKSource & TSKSource, 12 | TPKSource, 13 | TSKSource 14 | >( 15 | context: EntityContext, 16 | keySource: TKeySource, 17 | options: AdvancedUpdateOptions 18 | ): Promise { 19 | const params = { 20 | ...createUpdateParams(context, keySource, options), 21 | ...returnParamsForCapacityMetricsAndValues(options) 22 | } 23 | const result = await executeRequest(context, params) 24 | return parseAttributesCapacityAndMetrics(result) 25 | } 26 | 27 | export async function executeRequest( 28 | context: EntityContext, 29 | params: UpdateCommandInput 30 | ) { 31 | if (isDebugLoggingEnabled(context.logger)) { 32 | context.logger.debug(`Attempting to update ${context.entity.type}`, { updateParams: params }) 33 | } 34 | const result = await context.dynamoDB.update(params) 35 | if (isDebugLoggingEnabled(context.logger)) { 36 | context.logger.debug(`Update result`, { result }) 37 | } 38 | return result 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/internal/tableBackedConfigurationResolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isSingleTableConfig, 3 | MultiEntityTableConfig, 4 | StoreContext, 5 | TableConfig, 6 | TablesConfig 7 | } from '../tableBackedStoreConfiguration' 8 | import { EntityContextParams } from './entityContext' 9 | import { throwError } from '../util/errors' 10 | 11 | export type EntityContextResolver = (entityType: string) => EntityContextParams 12 | 13 | export function resolverFor(storeContext: StoreContext, config: TablesConfig): EntityContextResolver { 14 | return isSingleTableConfig(config) 15 | ? singleTableResolver(storeContext, config) 16 | : multiTableResolver(storeContext, config.entityTables, config.defaultTableName) 17 | } 18 | 19 | function singleTableResolver(storeContext: StoreContext, table: TableConfig) { 20 | const entityContext = { storeContext, table } 21 | return () => entityContext 22 | } 23 | 24 | function multiTableResolver( 25 | storeContext: StoreContext, 26 | entityTables: MultiEntityTableConfig[], 27 | defaultTableName: string | undefined 28 | ) { 29 | const tablesByEt: Record = Object.fromEntries( 30 | entityTables.map((table) => (table.entityTypes ?? []).map((entityType) => [entityType, table])).flat() 31 | ) 32 | 33 | const defaultTable = defaultTableName 34 | ? entityTables.find((t) => t.tableName === defaultTableName) ?? 35 | throwError(`Unable to find table configuration for default table name ${defaultTableName}`)() 36 | : undefined 37 | 38 | return (entityType: string) => { 39 | return { 40 | storeContext, 41 | table: 42 | tablesByEt[entityType] ?? 43 | defaultTable ?? 44 | throwError(`Unable to locate table that supports entity type ${entityType}`)() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/internal/transactions/conditionCheckOperation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expressionAttributeParamsFromOptions, 3 | keyParamFromSource, 4 | tableNameParam 5 | } from '../common/operationsCommon' 6 | import { EntityContext } from '../entityContext' 7 | import { DynamoDBValues } from '../../entities' 8 | import { TransactionConditionCheckOptions } from '../../transactionOperations' 9 | 10 | export interface ConditionCheckParams { 11 | Key: DynamoDBValues 12 | TableName: string 13 | ConditionExpression: string 14 | ExpressionAttributeNames?: Record 15 | ExpressionAttributeValues?: DynamoDBValues 16 | } 17 | 18 | export function createTransactionConditionCheck< 19 | TItem extends TPKSource & TSKSource, 20 | TKeySource extends TPKSource & TSKSource, 21 | TPKSource, 22 | TSKSource 23 | >( 24 | context: EntityContext, 25 | keySource: TKeySource, 26 | options: TransactionConditionCheckOptions 27 | ): ConditionCheckParams { 28 | return { 29 | ConditionExpression: options.conditionExpression, 30 | ...tableNameParam(context), 31 | ...keyParamFromSource(context, keySource), 32 | ...expressionAttributeParamsFromOptions(options) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/internal/transactions/tableBackedGetTransactionBuilder.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBValues, Entity } from '../../entities' 2 | import { EntityContextParams, createEntityContext, EntityContext } from '../entityContext' 3 | import { 4 | keyParamFromSource, 5 | parseItem, 6 | returnConsumedCapacityParam, 7 | tableNameParam 8 | } from '../common/operationsCommon' 9 | import { TransactGetCommandInput, TransactGetCommandOutput } from '@aws-sdk/lib-dynamodb' 10 | import { isDebugLoggingEnabled } from '../../util/logger' 11 | 12 | import { 13 | GetTransactionBuilder, 14 | GetTransactionOptions, 15 | GetTransactionResponse 16 | } from '../../transactionOperations' 17 | 18 | interface GetTransactionAction { 19 | Get: { 20 | Key: DynamoDBValues 21 | TableName: string 22 | } 23 | // Projection Expression fields to come later, maybe 24 | } 25 | 26 | export class TableBackedGetTransactionBuilder 27 | implements GetTransactionBuilder 28 | { 29 | private readonly actions: GetTransactionAction[] 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | private readonly contextsPerAction: EntityContext[] 32 | private readonly tableConfigResolver: (entityType: string) => EntityContextParams 33 | private readonly context: EntityContext 34 | 35 | constructor( 36 | tableConfigResolver: (entityType: string) => EntityContextParams, 37 | currentEntity: Entity, 38 | { 39 | contexts, 40 | actions 41 | }: { contexts: EntityContext[]; actions: GetTransactionAction[] } = { 42 | contexts: [], 43 | actions: [] 44 | } 45 | ) { 46 | this.tableConfigResolver = tableConfigResolver 47 | this.actions = actions 48 | this.contextsPerAction = contexts 49 | this.context = createEntityContext(tableConfigResolver(currentEntity.type), currentEntity) 50 | } 51 | 52 | get( 53 | keySource: TKeySource 54 | ): GetTransactionBuilder { 55 | this.actions.push({ 56 | Get: { 57 | ...tableNameParam(this.context), 58 | ...keyParamFromSource(this.context, keySource) 59 | } 60 | }) 61 | this.contextsPerAction.push(this.context) 62 | return this 63 | } 64 | 65 | nextEntity( 66 | nextEntity: Entity 67 | ): GetTransactionBuilder { 68 | return new TableBackedGetTransactionBuilder(this.tableConfigResolver, nextEntity, { 69 | contexts: this.contextsPerAction, 70 | actions: this.actions 71 | }) 72 | } 73 | 74 | async execute(options?: GetTransactionOptions): Promise { 75 | const transactionParams: TransactGetCommandInput = { 76 | TransactItems: this.actions, 77 | ...returnConsumedCapacityParam(options) 78 | } 79 | 80 | if (isDebugLoggingEnabled(this.context.logger)) { 81 | this.context.logger.debug(`Attempting get transaction`, { transactionParams }) 82 | } 83 | const result = await this.context.dynamoDB.transactionGet(transactionParams) 84 | if (isDebugLoggingEnabled(this.context.logger)) { 85 | this.context.logger.debug(`Get transaction result`, { result }) 86 | } 87 | 88 | return parseResponse(this.contextsPerAction, result) 89 | } 90 | } 91 | 92 | export function parseResponse( 93 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 94 | contexts: EntityContext[], 95 | result: TransactGetCommandOutput 96 | ): GetTransactionResponse { 97 | const parsedWithElementType = Array.from(result.Responses ?? [], (response, i) => { 98 | return [response.Item ? parseItem(contexts[i], response.Item) : null, contexts[i].entity.type] 99 | }) 100 | 101 | const itemsByEntityType = parsedWithElementType.reduce((accum: Record, [item, et]) => { 102 | if (!accum[et]) accum[et] = [] 103 | accum[et].push(item) 104 | return accum 105 | }, {}) 106 | 107 | return { 108 | itemsByEntityType, 109 | ...(result.ConsumedCapacity ? { metadata: { consumedCapacity: result.ConsumedCapacity } } : {}) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/lib/internal/transactions/tableBackedWriteTransactionBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '../../entities' 2 | import { EntityContextParams, createEntityContext, EntityContext } from '../entityContext' 3 | import { 4 | DeleteCommandInput, 5 | PutCommandInput, 6 | TransactWriteCommandInput, 7 | UpdateCommandInput 8 | } from '@aws-sdk/lib-dynamodb' 9 | import { isDebugLoggingEnabled } from '../../util/logger' 10 | import { Mandatory } from '../../util/types' 11 | import { ConditionCheckParams, createTransactionConditionCheck } from './conditionCheckOperation' 12 | import { returnConsumedCapacityParam, returnItemCollectionMetricsParam } from '../common/operationsCommon' 13 | import { 14 | TransactionConditionCheckOptions, 15 | TransactionDeleteOptions, 16 | TransactionPutOptions, 17 | TransactionUpdateOptions, 18 | WriteTransactionBuilder, 19 | WriteTransactionOptions, 20 | WriteTransactionResponse 21 | } from '../../transactionOperations' 22 | import { putParams } from '../common/putCommon' 23 | import { deleteParams } from '../common/deleteCommon' 24 | import { createUpdateParams } from '../common/updateCommon' 25 | 26 | type WriteTransactionAction = 27 | | { 28 | Put: PutCommandInput 29 | } 30 | | { 31 | Delete: DeleteCommandInput 32 | } 33 | | { 34 | Update: Mandatory 35 | } 36 | | { 37 | ConditionCheck: ConditionCheckParams 38 | } 39 | 40 | export class TableBackedWriteTransactionBuilder 41 | implements WriteTransactionBuilder 42 | { 43 | private readonly actions: WriteTransactionAction[] 44 | private readonly tableConfigResolver: (entityType: string) => EntityContextParams 45 | private readonly context: EntityContext 46 | 47 | constructor( 48 | tableConfigResolver: (entityType: string) => EntityContextParams, 49 | currentEntity: Entity, 50 | actions?: WriteTransactionAction[] 51 | ) { 52 | this.tableConfigResolver = tableConfigResolver 53 | this.actions = actions ?? [] 54 | this.context = createEntityContext(tableConfigResolver(currentEntity.type), currentEntity) 55 | } 56 | 57 | nextEntity( 58 | nextEntity: Entity 59 | ): WriteTransactionBuilder { 60 | return new TableBackedWriteTransactionBuilder(this.tableConfigResolver, nextEntity, this.actions) 61 | } 62 | 63 | put(item: TItem, options?: TransactionPutOptions): WriteTransactionBuilder { 64 | this.actions.push({ Put: putParams(this.context, item, options) }) 65 | return this 66 | } 67 | 68 | update( 69 | keySource: TKeySource, 70 | options: TransactionUpdateOptions 71 | ): WriteTransactionBuilder { 72 | this.actions.push({ Update: createUpdateParams(this.context, keySource, options) }) 73 | return this 74 | } 75 | 76 | delete( 77 | keySource: TKeySource, 78 | options?: TransactionDeleteOptions 79 | ): WriteTransactionBuilder { 80 | this.actions.push({ Delete: deleteParams(this.context, keySource, options) }) 81 | return this 82 | } 83 | 84 | conditionCheck( 85 | keySource: TKeySource, 86 | options: TransactionConditionCheckOptions 87 | ): WriteTransactionBuilder { 88 | this.actions.push({ 89 | ConditionCheck: createTransactionConditionCheck(this.context, keySource, options) 90 | }) 91 | return this 92 | } 93 | 94 | async execute(options?: WriteTransactionOptions): Promise { 95 | const transactionParams: TransactWriteCommandInput = { 96 | TransactItems: this.actions, 97 | ...returnConsumedCapacityParam(options), 98 | ...returnItemCollectionMetricsParam(options), 99 | ...(options?.clientRequestToken ? { ClientRequestToken: options.clientRequestToken } : {}) 100 | } 101 | if (isDebugLoggingEnabled(this.context.logger)) { 102 | this.context.logger.debug(`Attempting write transaction`, { transactionParams }) 103 | } 104 | const result = await this.context.dynamoDB.transactionWrite(transactionParams) 105 | if (isDebugLoggingEnabled(this.context.logger)) { 106 | this.context.logger.debug(`Write transaction result`, { result }) 107 | } 108 | return result.ConsumedCapacity || result.ItemCollectionMetrics 109 | ? { 110 | metadata: { 111 | ...(result.ConsumedCapacity ? { consumedCapacity: result.ConsumedCapacity } : {}), 112 | ...(result.ItemCollectionMetrics ? { itemCollectionMetrics: result.ItemCollectionMetrics } : {}) 113 | } 114 | } 115 | : {} 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/lib/multipleEntityOperations.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBValues, Entity } from './entities' 2 | import { 3 | AdvancedGsiQueryAllOptions, 4 | AdvancedGsiQueryOnePageOptions, 5 | AdvancedQueryAllOptions, 6 | AdvancedQueryOnePageOptions, 7 | AdvancedScanAllOptions, 8 | AdvancedScanOnePageOptions, 9 | ConsumedCapacitiesMetadata 10 | } from './singleEntityAdvancedOperations' 11 | import { SkQueryRange } from './singleEntityOperations' 12 | 13 | export interface MultipleEntityOperations { 14 | queryAllByPk( 15 | keyEntity: Entity, 16 | pkSource: TPKSource, 17 | options?: AdvancedQueryAllOptions 18 | ): Promise 19 | 20 | queryOnePageByPk( 21 | keyEntity: Entity, 22 | pkSource: TPKSource, 23 | options?: AdvancedQueryOnePageOptions 24 | ): Promise 25 | 26 | queryAllByPkAndSk( 27 | keyEntity: Entity, 28 | pkSource: TPKSource, 29 | queryRange: SkQueryRange, 30 | options?: AdvancedQueryAllOptions 31 | ): Promise 32 | 33 | queryOnePageByPkAndSk( 34 | keyEntity: Entity, 35 | pkSource: TPKSource, 36 | queryRange: SkQueryRange, 37 | options?: AdvancedQueryOnePageOptions 38 | ): Promise 39 | 40 | queryAllWithGsiByPk( 41 | keyEntity: Entity, 42 | pkSource: TGSIPKSource, 43 | options?: AdvancedGsiQueryAllOptions 44 | ): Promise 45 | 46 | queryOnePageWithGsiByPk( 47 | keyEntity: Entity, 48 | pkSource: TGSIPKSource, 49 | options?: AdvancedGsiQueryOnePageOptions 50 | ): Promise 51 | 52 | queryAllWithGsiByPkAndSk( 53 | keyEntity: Entity, 54 | pkSource: TGSIPKSource, 55 | queryRange: SkQueryRange, 56 | options?: AdvancedGsiQueryAllOptions 57 | ): Promise 58 | 59 | queryOnePageWithGsiByPkAndSk( 60 | keyEntity: Entity, 61 | pkSource: TGSIPKSource, 62 | queryRange: SkQueryRange, 63 | options?: AdvancedGsiQueryOnePageOptions 64 | ): Promise 65 | 66 | scanAll(options?: AdvancedScanAllOptions): Promise 67 | 68 | scanOnePage(options?: AdvancedScanOnePageOptions): Promise 69 | } 70 | 71 | export interface MultipleEntityCollectionResponse { 72 | itemsByEntityType: Record 73 | unparsedItems?: DynamoDBValues[] 74 | lastEvaluatedKey?: DynamoDBValues 75 | /** 76 | * Only set if consumedCapacities sub property has a value 77 | */ 78 | metadata?: ConsumedCapacitiesMetadata 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/support/entitySupport.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | import { excludeKeys } from '../util/collections' 3 | import { 4 | DynamoDBValues, 5 | Entity, 6 | EntityFormatter, 7 | EntityParser, 8 | MetaAttributeNames, 9 | PKOnlyEntity 10 | } from '../entities' 11 | import { throwError } from '../util/errors' 12 | 13 | export type TypePredicateFunction = (o: DynamoDBValues) => o is T 14 | 15 | export function typePredicateParser( 16 | typePredicate: TypePredicateFunction, 17 | entityType: string 18 | ): EntityParser { 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | return (rawItem: DynamoDBValues, allMetaAttributeNames: string[]) => { 21 | const item = excludeKeys(rawItem, allMetaAttributeNames) 22 | if (typePredicate(item)) return item 23 | else throw new Error(`Failed to parse entity to type ${entityType}`) 24 | } 25 | } 26 | 27 | export function createEntity( 28 | type: string, 29 | typePredicate: TypePredicateFunction, 30 | pk: (source: TPKSource) => string, 31 | sk: (source: TSKSource) => string 32 | ): Entity { 33 | return { 34 | type, 35 | parse: typePredicateParser(typePredicate, type), 36 | pk, 37 | sk 38 | } 39 | } 40 | 41 | export function entityFromPkOnlyEntity( 42 | pkOnlyEntity: PKOnlyEntity 43 | ): Entity { 44 | return { 45 | ...pkOnlyEntity, 46 | sk(): string { 47 | throw new Error(`${this.type} has no sort key`) 48 | } 49 | } 50 | } 51 | 52 | export const keyOnlyFormatter: EntityFormatter = () => ({}) 53 | 54 | export function getPKValue(item: DynamoDBValues, metaAttributeNames: MetaAttributeNames) { 55 | const pkAttributeName = metaAttributeNames.pk 56 | return ( 57 | item[pkAttributeName] ?? throwError(`Unable to find PK attribute (${pkAttributeName}) in DynamoDB item`)() 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/support/index.ts: -------------------------------------------------------------------------------- 1 | export * from './setupSupport' 2 | export * from './entitySupport' 3 | export * from './querySupport' 4 | -------------------------------------------------------------------------------- /src/lib/support/querySupport.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBValues } from '../entities' 2 | import { SkQueryRange } from '../singleEntityOperations' 3 | 4 | // All of these use an expression attribute name instead of the actual sort key attribute name 5 | // This is then substituted for the actual sort key attribute name when a query is run 6 | // Because the expression attribute name is'#sk' you cannot use that same expression attribute name for anything else 7 | 8 | // These ranges can be used both for table and GSI queries since the #sk expression attribute name 9 | // is replaced with either the table or GSI SK attribute name depending on how the query is executed 10 | 11 | export function rangeWhereSkEquals(sk: string): SkQueryRange { 12 | return queryRange('#sk = :sk', { 13 | ':sk': sk 14 | }) 15 | } 16 | 17 | export function rangeWhereSkGreaterThan(sk: string): SkQueryRange { 18 | return queryRange('#sk > :sk', { 19 | ':sk': sk 20 | }) 21 | } 22 | 23 | export function rangeWhereSkGreaterThanOrEquals(sk: string): SkQueryRange { 24 | return queryRange('#sk >= :sk', { 25 | ':sk': sk 26 | }) 27 | } 28 | 29 | export function rangeWhereSkLessThan(sk: string): SkQueryRange { 30 | return queryRange('#sk < :sk', { 31 | ':sk': sk 32 | }) 33 | } 34 | 35 | export function rangeWhereSkLessThanOrEquals(sk: string): SkQueryRange { 36 | return queryRange('#sk <= :sk', { 37 | ':sk': sk 38 | }) 39 | } 40 | 41 | export function rangeWhereSkBetween(from: string, to: string): SkQueryRange { 42 | return queryRange('#sk BETWEEN :from AND :to', { 43 | ':from': from, 44 | ':to': to 45 | }) 46 | } 47 | 48 | export function rangeWhereSkBeginsWith(prefix: string): SkQueryRange { 49 | return queryRange('begins_with(#sk, :skPrefix)', { 50 | ':skPrefix': prefix 51 | }) 52 | } 53 | 54 | function queryRange(clause: string, values: DynamoDBValues): SkQueryRange { 55 | return { 56 | skConditionExpressionClause: clause, 57 | expressionAttributeValues: values 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/support/setupSupport.ts: -------------------------------------------------------------------------------- 1 | import { documentClientBackedInterface } from '../dynamoDBInterface' 2 | import { realClock } from '../util/dateAndTime' 3 | import { MultiTableConfig, StoreContext, TableConfig } from '../tableBackedStoreConfiguration' 4 | import { noopLogger } from '../util/logger' 5 | import { MetaAttributeNames } from '../entities' 6 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' 7 | 8 | /** 9 | * Create store context, optionally passing overrides to defaults. 10 | * By default the following are used: 11 | * * `logger` : No-op logger (Don't log) 12 | * * `dynamoDB` : Wrapper using default DynamoDB document client. Typically you'd only override this in unit / in-process tests. To use non default DynamoDB Document Client behavior then specify the documentClient parameter instead. 13 | * * `clock` : Real clock based on system time (it can be useful to override this in tests) 14 | * @param options - override any of the default context values. Can be explicitly set to `{}` to use default values. 15 | * @param documentClient - override the DynamoDB document client used in the default DynamoDB wrapper. **IGNORED** if `dynamoDB` is provided in `options` 16 | */ 17 | export function createStoreContext( 18 | options: Partial = {}, 19 | documentClient?: DynamoDBDocumentClient 20 | ): StoreContext { 21 | const logger = options.logger ?? noopLogger 22 | return { 23 | clock: options.clock ?? realClock(), 24 | logger, 25 | dynamoDB: options.dynamoDB ?? documentClientBackedInterface(logger, documentClient) 26 | } 27 | } 28 | 29 | /** 30 | * Create the minimum table config when using a single table. Useful as a base if you're using "non-standard" config 31 | * @param tableName - the underlying DynamoDB table name 32 | * @param metaAttributeNames - Attribute names for meta values. At least Partition Key must be specified 33 | */ 34 | export function createMinimumSingleTableConfig( 35 | tableName: string, 36 | metaAttributeNames: MetaAttributeNames 37 | ): TableConfig { 38 | return { 39 | tableName, 40 | metaAttributeNames 41 | } 42 | } 43 | 44 | /** 45 | * Create "standard single table" config. 46 | * * Partition Key attribute name = `PK` 47 | * * Sort Key attribute name = `SK` 48 | * * TTL attribute name = `ttl` 49 | * * Entity Type attribute name = `_et` 50 | * * Last Updated attribute name = `_lastUpdated` 51 | * * One GSI, with Table GSI name = `GSI`, logical GSI name = `gsi`, GSI Partition Key = `GSIPK`, GSI Sort Key = `GSISK` 52 | * * Scans not allowed 53 | * @param tableName - the underlying DynamoDB table name 54 | */ 55 | export function createStandardSingleTableConfig(tableName: string): TableConfig { 56 | return { 57 | ...createMinimumSingleTableConfig(tableName, SingleGSIStandardMetaAttributeNames), 58 | gsiNames: { gsi: 'GSI' }, 59 | allowScans: false 60 | } 61 | } 62 | 63 | /** 64 | * Same configuration as createStandardSingleTableConfig but for multiple tables 65 | * @param tablesToEntityTypes Map of underlying Dynamo Table Names to the entities stored in each table 66 | * @param defaultTableName Which table to use if an operation is performed on an entity not explicitly configured in tablesToEntityTypes. Default - no default table is used, and all entities must be explicitly configured. 67 | * @throws if `defaultTableName` isn't included in keys of `tablesToEntityTypes` 68 | */ 69 | export function createStandardMultiTableConfig( 70 | tablesToEntityTypes: Record, 71 | defaultTableName?: string 72 | ): MultiTableConfig { 73 | if (defaultTableName && !Object.keys(tablesToEntityTypes).includes(defaultTableName)) 74 | throw new Error(`Default table ${defaultTableName} is not included in list of tables`) 75 | 76 | return { 77 | ...(defaultTableName ? { defaultTableName } : {}), 78 | entityTables: Object.entries(tablesToEntityTypes).map(([tableName, entityTypes]) => { 79 | return { 80 | ...createStandardSingleTableConfig(tableName), 81 | entityTypes 82 | } 83 | }) 84 | } 85 | } 86 | 87 | export const NoGSIStandardMetaAttributeNames = { 88 | pk: 'PK', 89 | sk: 'SK', 90 | ttl: 'ttl', 91 | entityType: '_et', 92 | lastUpdated: '_lastUpdated' 93 | } 94 | 95 | export const SingleGSIStandardMetaAttributeNames = { 96 | ...NoGSIStandardMetaAttributeNames, 97 | gsisById: { 98 | gsi: { 99 | pk: 'GSIPK', 100 | sk: 'GSISK' 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/lib/tableBackedStore.ts: -------------------------------------------------------------------------------- 1 | import { AllEntitiesStore } from './entityStore' 2 | import { Entity } from './entities' 3 | import { StoreContext, TablesConfig } from './tableBackedStoreConfiguration' 4 | import { tableBackedSingleEntityOperations } from './internal/singleEntity/tableBackedSingleEntityOperations' 5 | import { resolverFor } from './internal/tableBackedConfigurationResolver' 6 | import { TableBackedWriteTransactionBuilder } from './internal/transactions/tableBackedWriteTransactionBuilder' 7 | import { MultipleEntityOperations } from './multipleEntityOperations' 8 | import { tableBackedMultipleEntityOperations } from './internal/multipleEntities/tableBackedMultipleEntityOperations' 9 | import { SingleEntityOperations } from './singleEntityOperations' 10 | import { TableBackedGetTransactionBuilder } from './internal/transactions/tableBackedGetTransactionBuilder' 11 | import { GetTransactionBuilder, WriteTransactionBuilder } from './transactionOperations' 12 | import { createStoreContext } from './support' 13 | import { createEntityContext } from './internal/entityContext' 14 | 15 | /** 16 | * Entry point to dynamodb-entity-store. A Table Backed Store can use either one DynamoDB backing table, 17 | * or several; and can be used to persist one entity type, or several. 18 | * @param tablesConfig - either using objects created from setupSupport.ts, (e.g. `createStandardSingleTableConfig`) or you can fully customize 19 | * @param context - override the default store context. To see what those defaults are, see `createStoreContext` in setupSupport.ts 20 | */ 21 | export function createStore(tablesConfig: TablesConfig, context?: StoreContext): AllEntitiesStore { 22 | const tableConfigResolver = resolverFor(context ?? createStoreContext(), tablesConfig) 23 | return { 24 | for( 25 | entity: Entity 26 | ): SingleEntityOperations { 27 | return tableBackedSingleEntityOperations(createEntityContext(tableConfigResolver(entity.type), entity)) 28 | }, 29 | forMultiple(entities: Entity[]): MultipleEntityOperations { 30 | return tableBackedMultipleEntityOperations(tableConfigResolver, entities) 31 | }, 32 | transactions: { 33 | buildWriteTransaction( 34 | firstEntity: Entity 35 | ): WriteTransactionBuilder { 36 | return new TableBackedWriteTransactionBuilder(tableConfigResolver, firstEntity) 37 | }, 38 | buildGetTransaction( 39 | firstEntity: Entity 40 | ): GetTransactionBuilder { 41 | return new TableBackedGetTransactionBuilder(tableConfigResolver, firstEntity) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/tableBackedStoreConfiguration.ts: -------------------------------------------------------------------------------- 1 | import { Clock, EntityStoreLogger } from './util' 2 | import { DynamoDBInterface } from './dynamoDBInterface' 3 | import { MetaAttributeNames } from './entities' 4 | 5 | // See functions in _setupSupport.ts_ for assistance in creating these objects 6 | 7 | export interface StoreContext { 8 | logger: EntityStoreLogger 9 | dynamoDB: DynamoDBInterface 10 | clock: Clock 11 | } 12 | 13 | export type TablesConfig = TableConfig | MultiTableConfig 14 | 15 | export interface TableConfig { 16 | tableName: string 17 | metaAttributeNames: MetaAttributeNames 18 | allowScans?: boolean 19 | gsiNames?: Record 20 | } 21 | 22 | export interface MultiTableConfig { 23 | entityTables: MultiEntityTableConfig[] 24 | defaultTableName?: string 25 | } 26 | 27 | export interface MultiEntityTableConfig extends TableConfig { 28 | entityTypes?: string[] 29 | } 30 | 31 | export function isSingleTableConfig(x: TablesConfig): x is TableConfig { 32 | return (x as TableConfig).tableName !== undefined 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/transactionOperations.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBValues, Entity } from './entities' 2 | import { 3 | ReturnConsumedCapacityOption, 4 | ReturnItemCollectionMetricsOption, 5 | ReturnValuesOnConditionCheckFailureOption 6 | } from './singleEntityAdvancedOperations' 7 | import { ConsumedCapacity } from '@aws-sdk/client-dynamodb' 8 | import { DeleteOptions, PutOptions, UpdateOptions } from './singleEntityOperations' 9 | import { Mandatory } from './util' 10 | 11 | // TOMAYBE - consider non builder versions 12 | export interface TransactionOperations { 13 | buildWriteTransaction( 14 | firstEntity: Entity 15 | ): WriteTransactionBuilder 16 | 17 | buildGetTransaction( 18 | firstEntity: Entity 19 | ): GetTransactionBuilder 20 | } 21 | 22 | export interface WriteTransactionBuilder { 23 | put(item: TItem, options?: TransactionPutOptions): WriteTransactionBuilder 24 | 25 | update( 26 | keySource: TKeySource, 27 | options: TransactionUpdateOptions 28 | ): WriteTransactionBuilder 29 | 30 | delete( 31 | keySource: TKeySource, 32 | options?: TransactionDeleteOptions 33 | ): WriteTransactionBuilder 34 | 35 | conditionCheck( 36 | keySource: TKeySource, 37 | options: TransactionConditionCheckOptions 38 | ): WriteTransactionBuilder 39 | 40 | nextEntity( 41 | nextEntity: Entity 42 | ): WriteTransactionBuilder 43 | 44 | execute(options?: WriteTransactionOptions): Promise 45 | } 46 | 47 | export interface GetTransactionBuilder { 48 | get( 49 | keySource: TKeySource 50 | ): GetTransactionBuilder 51 | 52 | nextEntity( 53 | nextEntity: Entity 54 | ): GetTransactionBuilder 55 | 56 | execute(options?: GetTransactionOptions): Promise 57 | } 58 | 59 | export type TransactionPutOptions = PutOptions & ReturnValuesOnConditionCheckFailureOption 60 | export type TransactionUpdateOptions = UpdateOptions & ReturnValuesOnConditionCheckFailureOption 61 | export type TransactionDeleteOptions = DeleteOptions & ReturnValuesOnConditionCheckFailureOption 62 | export type TransactionConditionCheckOptions = Mandatory 63 | 64 | export interface WriteTransactionOptions 65 | extends ReturnConsumedCapacityOption, 66 | ReturnItemCollectionMetricsOption { 67 | /** 68 | * Optional client request token. See https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#API_TransactWriteItems_RequestParameters 69 | */ 70 | clientRequestToken?: string 71 | } 72 | 73 | export type GetTransactionOptions = ReturnConsumedCapacityOption 74 | 75 | export interface WriteTransactionResponse { 76 | /** 77 | * Only set if any sub properties are set 78 | */ 79 | metadata?: TransactionConsumedCapacityMetadata & TransactionCollectionMetricsMetadata 80 | } 81 | 82 | export interface GetTransactionResponse { 83 | /** 84 | * Each array is in the same order as the original request 85 | * Any elements that could not be found are represented by null in their corresponding array 86 | */ 87 | itemsByEntityType: Record 88 | /** 89 | * Only set if any sub properties are set 90 | */ 91 | metadata?: TransactionConsumedCapacityMetadata 92 | } 93 | 94 | export interface TransactionConsumedCapacityMetadata { 95 | /** 96 | * Only set if returnConsumedCapacity set appropriately on request 97 | * @see https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html#API_TransactGetItems_ResponseElements 98 | * @see https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#API_TransactWriteItems_ResponseElements 99 | */ 100 | consumedCapacity?: ConsumedCapacity[] 101 | } 102 | 103 | export interface TransactionCollectionMetricsMetadata { 104 | /** 105 | * Only set if returnItemCollectionMetrics set appropriately on request and item contained any collections 106 | * @see https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html#API_TransactWriteItems_ResponseElements 107 | */ 108 | itemCollectionMetrics?: Record< 109 | string, 110 | { 111 | SizeEstimateRangeGB?: number[] 112 | ItemCollectionKey?: DynamoDBValues 113 | }[] 114 | > 115 | } 116 | -------------------------------------------------------------------------------- /src/lib/util/collections.ts: -------------------------------------------------------------------------------- 1 | function filterKeys(object: T, keyPredicate: (x: string) => boolean) { 2 | return object ? Object.fromEntries(Object.entries(object).filter(([key]) => keyPredicate(key))) : object 3 | } 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | export function excludeKeys(object: any, keys: string[]) { 7 | return filterKeys(object, (key) => !keys.includes(key)) 8 | } 9 | 10 | export function selectKeys(object: T, keys: string[]) { 11 | return filterKeys(object, (key) => keys.includes(key)) 12 | } 13 | 14 | export function chunk(array: T[], size: number) { 15 | return Array.from({ length: Math.ceil(array.length / size) }, (_v, i) => 16 | array.slice(i * size, i * size + size) 17 | ) 18 | } 19 | 20 | export function removeNullOrUndefined(array: Array): Array { 21 | return array.filter((x) => !(x === undefined || x === null)) as Array 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/util/dateAndTime.ts: -------------------------------------------------------------------------------- 1 | export interface Clock { 2 | now(): Date 3 | } 4 | 5 | export function realClock(): Clock { 6 | return { 7 | now(): Date { 8 | return new Date() 9 | } 10 | } 11 | } 12 | 13 | export function dateTimeAddMilliseconds(date: Date, millis: number) { 14 | return new Date(date.valueOf() + millis) 15 | } 16 | 17 | export function dateTimeAddSeconds(date: Date, seconds: number) { 18 | return dateTimeAddMilliseconds(date, seconds * 1000) 19 | } 20 | 21 | export function dateTimeAddMinutes(date: Date, minutes: number) { 22 | return dateTimeAddSeconds(date, minutes * 60) 23 | } 24 | 25 | export function dateTimeAddHours(date: Date, hours: number) { 26 | return dateTimeAddMinutes(date, hours * 60) 27 | } 28 | 29 | export function dateTimeAddDays(date: Date, days: number) { 30 | return dateTimeAddHours(date, days * 24) 31 | } 32 | 33 | export function secondsTimestampInFutureDays(clock: Clock, days: number): number { 34 | return dateToTimestampSeconds(dateTimeAddDays(clock.now(), days)) 35 | } 36 | 37 | export function dateToTimestampSeconds(date: Date) { 38 | return Math.floor(date.valueOf() / 1000) 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/util/errors.ts: -------------------------------------------------------------------------------- 1 | export function throwError(message?: string) { 2 | return () => { 3 | throw new Error(message) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collections' 2 | export * from './dateAndTime' 3 | export * from './errors' 4 | export * from './logger' 5 | export * from './types' 6 | -------------------------------------------------------------------------------- /src/lib/util/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The table-backed store takes a logger for generating debug log messages, at StoreConfiguration.logger . 3 | * Since the library doesn't want to make any assumptions about an application's logging design or dependencies it 4 | * supplies this interface, and two basic implementations that have no library dependencies. 5 | * In your usage you can either use one of these two basic implementations, or supply your own. 6 | * For example a Powertools Logging interface might look as follows: 7 | * 8 | * function createPowertoolsEntityStoreLogger(logger: Logger): EntityStoreLogger { 9 | * return { 10 | * getLevelName() { 11 | * return logger.getLevelName() 12 | * }, 13 | * debug(input: LogItemMessage, ...extraInput) { 14 | * logger.debug(input, ...extraInput) 15 | * } 16 | * } 17 | * } 18 | */ 19 | export interface EntityStoreLogger { 20 | /** 21 | * Should return 'DEBUG' if debug logging is enabled, otherwise any other uppercase string 22 | */ 23 | getLevelName(): Uppercase 24 | 25 | /** 26 | * Log the given content, if debug logging is enabled, otherwise return silently 27 | * This interface is the same as AWS Powertools logging - see https://docs.powertools.aws.dev/lambda/typescript/latest/api/classes/_aws_lambda_powertools_logger.index.Logger.html#debug 28 | * @param input 29 | * @param extraInput 30 | */ 31 | debug(input: EntityStoreLogItemMessage, ...extraInput: EntityStoreLogItemExtraInput): void 32 | } 33 | 34 | // These are copied from AWS Powertools logging, and this logging interface is directly compatible with that library 35 | export type EntityStoreLogItemMessage = string | EntityStoreLogAttributesWithMessage 36 | export type EntityStoreLogAttributesWithMessage = EntityStoreLogAttributes & { 37 | message: string 38 | } 39 | export type EntityStoreLogAttributes = { 40 | [key: string]: EntityStoreLogAttributeValue 41 | } 42 | export type EntityStoreLogAttributeValue = unknown 43 | export type EntityStoreLogItemExtraInput = [Error | string] | EntityStoreLogAttributes[] 44 | 45 | export function isDebugLoggingEnabled(logger: EntityStoreLogger) { 46 | return logger.getLevelName() === 'DEBUG' 47 | } 48 | 49 | /** 50 | * Logger where debug logging is disabled, and any calls to debug() are ignored 51 | */ 52 | export const noopLogger: EntityStoreLogger = { 53 | getLevelName() { 54 | return 'SILENT' 55 | }, 56 | debug() { 57 | return 58 | } 59 | } 60 | 61 | /** 62 | * Logger where debug logging is enabled, and any calls to debug() are written to the console 63 | */ 64 | export const consoleLogger: EntityStoreLogger = { 65 | debug(input: EntityStoreLogItemMessage, ...extraInput) { 66 | console.log( 67 | `DynamoDB Entity Store DEBUG - ${typeof input === 'string' ? input : JSON.stringify(input)} ${ 68 | extraInput && extraInput.length > 0 ? JSON.stringify(extraInput) : '' 69 | }` 70 | ) 71 | }, 72 | getLevelName() { 73 | return 'DEBUG' 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/lib/util/types.ts: -------------------------------------------------------------------------------- 1 | export type Optional = Pick, K> & Omit 2 | 3 | export type Mandatory = Pick, K> & Omit 4 | -------------------------------------------------------------------------------- /test/examples/catTypeAndEntity.ts: -------------------------------------------------------------------------------- 1 | import { typePredicateParser } from '../../src/lib/support/entitySupport' 2 | import { Entity } from '../../src/lib/entities' 3 | 4 | export interface Cat { 5 | farm: string 6 | name: string 7 | ageInYears: number 8 | } 9 | 10 | export function isCat(x: unknown): x is Cat { 11 | const candidate = x as Cat 12 | return candidate.farm !== undefined && candidate.name !== undefined && candidate.ageInYears !== undefined 13 | } 14 | 15 | export const CAT_ENTITY: Entity, Pick> = { 16 | type: 'cat', 17 | // TODO - use custom type predicate parser to differentiate dog and cat 18 | parse: typePredicateParser(isCat, 'cat'), 19 | pk({ farm }: Pick): string { 20 | return `FARM#${farm}` 21 | }, 22 | sk({ name }: Pick): string { 23 | return sk(name) 24 | } 25 | } 26 | 27 | function sk(name: string) { 28 | return `CAT#NAME#${name}` 29 | } 30 | -------------------------------------------------------------------------------- /test/examples/chickenTypeAndEntity.ts: -------------------------------------------------------------------------------- 1 | import { typePredicateParser } from '../../src/lib/support/entitySupport' 2 | import { 3 | rangeWhereSkBeginsWith, 4 | rangeWhereSkGreaterThan, 5 | rangeWhereSkLessThan 6 | } from '../../src/lib/support/querySupport' 7 | import { Entity } from '../../src/lib/entities' 8 | 9 | export interface Chicken { 10 | breed: string 11 | name: string 12 | dateOfBirth: string 13 | coop: string 14 | } 15 | 16 | export function isChicken(x: unknown): x is Chicken { 17 | const candidate = x as Chicken 18 | return ( 19 | candidate.breed !== undefined && 20 | candidate.name !== undefined && 21 | candidate.dateOfBirth !== undefined && 22 | candidate.coop !== undefined 23 | ) 24 | } 25 | 26 | export const CHICKEN_ENTITY: Entity< 27 | Chicken, 28 | Pick, 29 | Pick 30 | > = { 31 | type: 'chicken', 32 | parse: typePredicateParser(isChicken, 'chicken'), 33 | pk({ breed }: Pick): string { 34 | return `CHICKEN#BREED#${breed}` 35 | }, 36 | sk({ name, dateOfBirth }: Pick): string { 37 | return sk(name, dateOfBirth) 38 | }, 39 | gsis: { 40 | gsi: { 41 | pk({ coop }: Pick): string { 42 | return `COOP#${coop}` 43 | }, 44 | sk({ breed, dateOfBirth }: Pick): string { 45 | return gsiSk(breed, dateOfBirth) 46 | } 47 | } 48 | } 49 | } 50 | 51 | export const TWO_GSI_CHICKEN_ENTITY: Entity< 52 | Chicken, 53 | Pick, 54 | Pick 55 | > = { 56 | ...CHICKEN_ENTITY, 57 | gsis: { 58 | gsi1: { 59 | pk({ coop }: Pick): string { 60 | return `COOP#${coop}` 61 | }, 62 | sk({ breed, dateOfBirth }: Pick): string { 63 | return gsiSk(breed, dateOfBirth) 64 | } 65 | }, 66 | gsi2: { 67 | pk(): string { 68 | return `CHICKEN` 69 | }, 70 | sk({ dateOfBirth }: Pick): string { 71 | return `DATEOFBIRTH#${dateOfBirth}` 72 | } 73 | } 74 | } 75 | } 76 | 77 | function sk(name: string, dateOfBirth: string) { 78 | return `DATEOFBIRTH#${dateOfBirth}#NAME#${name}` 79 | } 80 | 81 | function gsiSk(breed: string, dateOfBirth: string) { 82 | return `CHICKEN#BREED#${breed}#DATEOFBIRTH#${dateOfBirth}` 83 | } 84 | 85 | export function findOlderThan(dateOfBirthStart: string) { 86 | return rangeWhereSkLessThan(`DATEOFBIRTH#${dateOfBirthStart}`) 87 | } 88 | 89 | export function findYoungerThan(dateOfBirthStart: string) { 90 | return rangeWhereSkGreaterThan(`DATEOFBIRTH#${dateOfBirthStart}`) 91 | } 92 | 93 | export function gsiBreed(breed: string) { 94 | return rangeWhereSkBeginsWith(`CHICKEN#BREED#${breed}`) 95 | } 96 | -------------------------------------------------------------------------------- /test/examples/dogTypeAndEntity.ts: -------------------------------------------------------------------------------- 1 | import { typePredicateParser } from '../../src/lib/support/entitySupport' 2 | import { Entity } from '../../src/lib/entities' 3 | 4 | export interface Dog { 5 | farm: string 6 | name: string 7 | ageInYears: number 8 | } 9 | 10 | export function isDog(x: unknown): x is Dog { 11 | const candidate = x as Dog 12 | return candidate.farm !== undefined && candidate.name !== undefined && candidate.ageInYears !== undefined 13 | } 14 | 15 | export const DOG_ENTITY: Entity, Pick> = { 16 | type: 'dog', 17 | // TODO - use custom type predicate parser to differentiate dog and cat 18 | parse: typePredicateParser(isDog, 'dog'), 19 | pk({ farm }: Pick): string { 20 | return `FARM#${farm}` 21 | }, 22 | sk({ name }: Pick): string { 23 | return sk(name) 24 | } 25 | } 26 | 27 | function sk(name: string) { 28 | return `DOG#NAME#${name}` 29 | } 30 | -------------------------------------------------------------------------------- /test/examples/duckTypeAndEntity.ts: -------------------------------------------------------------------------------- 1 | import { typePredicateParser } from '../../src/lib/support/entitySupport' 2 | import { Entity } from '../../src/lib/entities' 3 | 4 | export interface Duck { 5 | breed: string 6 | name: string 7 | dateOfBirth: string 8 | coop: string 9 | } 10 | 11 | export function isDuck(x: unknown): x is Duck { 12 | const candidate = x as Duck 13 | return ( 14 | candidate.breed !== undefined && 15 | candidate.name !== undefined && 16 | candidate.dateOfBirth !== undefined && 17 | candidate.coop !== undefined 18 | ) 19 | } 20 | 21 | export const DUCK_ENTITY: Entity, Pick> = { 22 | type: 'duck', 23 | parse: typePredicateParser(isDuck, 'duck'), 24 | pk({ breed }: Pick): string { 25 | return `DUCK#BREED#${breed}` 26 | }, 27 | sk({ name, dateOfBirth }: Pick): string { 28 | return sk(name, dateOfBirth) 29 | }, 30 | gsis: { 31 | gsi: { 32 | pk({ coop }: Pick): string { 33 | return `COOP#${coop}` 34 | }, 35 | sk({ breed, dateOfBirth }: Pick): string { 36 | return gsiSk(breed, dateOfBirth) 37 | } 38 | } 39 | } 40 | } 41 | 42 | function sk(name: string, dateOfBirth: string) { 43 | return `DATEOFBIRTH#${dateOfBirth}#NAME#${name}` 44 | } 45 | 46 | function gsiSk(breed: string, dateOfBirth: string) { 47 | return `DUCK#BREED#${breed}#DATEOFBIRTH#${dateOfBirth}` 48 | } 49 | -------------------------------------------------------------------------------- /test/examples/farmTypeAndEntity.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBValues, MetaAttributeNames } from '../../src/lib/entities' 2 | import { entityFromPkOnlyEntity, getPKValue, keyOnlyFormatter } from '../../src/lib/support/entitySupport' 3 | 4 | export interface Farm { 5 | name: string 6 | } 7 | 8 | export const FARM_ENTITY = entityFromPkOnlyEntity({ 9 | type: 'farm', 10 | parse: (item: DynamoDBValues, _: string[], metaAttributeNames: MetaAttributeNames): Farm => ({ 11 | // TODO - getPKValue returns 'any' - can we do better? 12 | name: getPKValue(item, metaAttributeNames) 13 | }), 14 | convertToDynamoFormat: keyOnlyFormatter, 15 | pk({ name }: Pick): string { 16 | return name 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /test/examples/sheepTypeAndEntity.ts: -------------------------------------------------------------------------------- 1 | import { createEntity } from '../../src/lib/support/entitySupport' 2 | import { rangeWhereSkBetween } from '../../src/lib/support/querySupport' 3 | import { DynamoDBValues } from '../../src/lib' 4 | 5 | export interface Sheep { 6 | breed: string 7 | name: string 8 | ageInYears: number 9 | } 10 | 11 | export function isSheep(x: DynamoDBValues): x is Sheep { 12 | const candidate = x as Sheep 13 | return candidate.breed !== undefined && candidate.name !== undefined && candidate.ageInYears !== undefined 14 | } 15 | 16 | export const SHEEP_ENTITY = createEntity( 17 | 'sheep', 18 | isSheep, 19 | ({ breed }: Pick) => `SHEEP#BREED#${breed}`, 20 | ({ name }: Pick) => sk(name) 21 | ) 22 | 23 | function sk(name: string) { 24 | return `NAME#${name}` 25 | } 26 | 27 | export function rangeWhereNameBetween(nameRangeStart: string, nameRangeEnd: string) { 28 | return rangeWhereSkBetween(sk(nameRangeStart), sk(nameRangeEnd)) 29 | } 30 | -------------------------------------------------------------------------------- /test/examples/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Transform: AWS::Serverless-2016-10-31 3 | 4 | Outputs: 5 | TableName: 6 | Value: !Ref TestTable 7 | 8 | TableTwoName: 9 | Value: !Ref TestTableTwo 10 | 11 | TwoGSITableName: 12 | Value: !Ref TwoGSITable 13 | 14 | CustomTableName: 15 | Value: !Ref CustomTable 16 | 17 | FarmTableName: 18 | Value: !Ref FarmTable 19 | 20 | Resources: 21 | TestTable: 22 | Type: AWS::DynamoDB::Table 23 | Properties: 24 | BillingMode: PAY_PER_REQUEST 25 | TimeToLiveSpecification: 26 | AttributeName: ttl 27 | Enabled: true 28 | AttributeDefinitions: 29 | - AttributeName: PK 30 | AttributeType: S 31 | - AttributeName: SK 32 | AttributeType: S 33 | - AttributeName: GSIPK 34 | AttributeType: S 35 | - AttributeName: GSISK 36 | AttributeType: S 37 | KeySchema: 38 | - AttributeName: PK 39 | KeyType: HASH 40 | - AttributeName: SK 41 | KeyType: RANGE 42 | GlobalSecondaryIndexes: 43 | - IndexName: "GSI" 44 | Projection: 45 | ProjectionType: ALL 46 | KeySchema: 47 | - AttributeName: GSIPK 48 | KeyType: HASH 49 | - AttributeName: GSISK 50 | KeyType: RANGE 51 | 52 | TestTableTwo: 53 | Type: AWS::DynamoDB::Table 54 | Properties: 55 | BillingMode: PAY_PER_REQUEST 56 | TimeToLiveSpecification: 57 | AttributeName: ttl 58 | Enabled: true 59 | AttributeDefinitions: 60 | - AttributeName: PK 61 | AttributeType: S 62 | - AttributeName: SK 63 | AttributeType: S 64 | - AttributeName: GSIPK 65 | AttributeType: S 66 | - AttributeName: GSISK 67 | AttributeType: S 68 | KeySchema: 69 | - AttributeName: PK 70 | KeyType: HASH 71 | - AttributeName: SK 72 | KeyType: RANGE 73 | GlobalSecondaryIndexes: 74 | - IndexName: "GSI" 75 | Projection: 76 | ProjectionType: ALL 77 | KeySchema: 78 | - AttributeName: GSIPK 79 | KeyType: HASH 80 | - AttributeName: GSISK 81 | KeyType: RANGE 82 | 83 | TwoGSITable: 84 | Type: AWS::DynamoDB::Table 85 | Properties: 86 | BillingMode: PAY_PER_REQUEST 87 | TimeToLiveSpecification: 88 | AttributeName: ttl 89 | Enabled: true 90 | AttributeDefinitions: 91 | - AttributeName: PK 92 | AttributeType: S 93 | - AttributeName: SK 94 | AttributeType: S 95 | - AttributeName: GSI1PK 96 | AttributeType: S 97 | - AttributeName: GSI1SK 98 | AttributeType: S 99 | - AttributeName: GSI2PK 100 | AttributeType: S 101 | - AttributeName: GSI2SK 102 | AttributeType: S 103 | KeySchema: 104 | - AttributeName: PK 105 | KeyType: HASH 106 | - AttributeName: SK 107 | KeyType: RANGE 108 | GlobalSecondaryIndexes: 109 | - IndexName: "GSI1" 110 | Projection: 111 | ProjectionType: ALL 112 | KeySchema: 113 | - AttributeName: GSI1PK 114 | KeyType: HASH 115 | - AttributeName: GSI1SK 116 | KeyType: RANGE 117 | - IndexName: "GSI2" 118 | Projection: 119 | ProjectionType: ALL 120 | KeySchema: 121 | - AttributeName: GSI2PK 122 | KeyType: HASH 123 | - AttributeName: GSI2SK 124 | KeyType: RANGE 125 | 126 | CustomTable: 127 | Type: AWS::DynamoDB::Table 128 | Properties: 129 | BillingMode: PAY_PER_REQUEST 130 | AttributeDefinitions: 131 | - AttributeName: CustomPK 132 | AttributeType: S 133 | - AttributeName: CustomSK 134 | AttributeType: S 135 | KeySchema: 136 | - AttributeName: CustomPK 137 | KeyType: HASH 138 | - AttributeName: CustomSK 139 | KeyType: RANGE 140 | 141 | FarmTable: 142 | Type: AWS::DynamoDB::Table 143 | Properties: 144 | BillingMode: PAY_PER_REQUEST 145 | AttributeDefinitions: 146 | - AttributeName: Name 147 | AttributeType: S 148 | KeySchema: 149 | - AttributeName: Name 150 | KeyType: HASH 151 | -------------------------------------------------------------------------------- /test/examples/testData.ts: -------------------------------------------------------------------------------- 1 | import { Chicken } from './chickenTypeAndEntity' 2 | import { Duck } from './duckTypeAndEntity' 3 | import { Sheep } from './sheepTypeAndEntity' 4 | import { Dog } from './dogTypeAndEntity' 5 | import { Cat } from './catTypeAndEntity' 6 | import { Farm } from './farmTypeAndEntity' 7 | 8 | export const shaunIdentifier = { breed: 'merino', name: 'shaun' } 9 | export const shaunTheSheep: Sheep = { ...shaunIdentifier, ageInYears: 3 } 10 | export const bobIdentifier = { breed: 'merino', name: 'bob' } 11 | export const bobTheSheep: Sheep = { ...bobIdentifier, ageInYears: 4 } 12 | export const alisonIdentifier = { breed: 'alpaca', name: 'alison' } 13 | export const alisonTheAlpaca: Sheep = { ...alisonIdentifier, ageInYears: 2 } 14 | 15 | export const chesterDog: Dog = { farm: 'Sunflower Farm', name: 'Chester', ageInYears: 4 } 16 | export const peggyCat: Cat = { farm: 'Sunflower Farm', name: 'Peggy', ageInYears: 7 } 17 | 18 | export const sunflowerFarm: Farm = { name: 'Sunflower Farm' } 19 | 20 | export const gingerIdentifier = { 21 | breed: 'sussex', 22 | name: 'ginger', 23 | dateOfBirth: '2021-07-01' 24 | } 25 | export const ginger: Chicken = { 26 | ...gingerIdentifier, 27 | coop: 'bristol' 28 | } 29 | export const babs: Chicken = { 30 | breed: 'sussex', 31 | name: 'babs', 32 | dateOfBirth: '2021-09-01', 33 | coop: 'bristol' 34 | } 35 | export const bunty: Chicken = { 36 | breed: 'sussex', 37 | name: 'bunty', 38 | dateOfBirth: '2021-11-01', 39 | coop: 'bristol' 40 | } 41 | export const yolko: Chicken = { 42 | breed: 'sussex', 43 | name: 'yolko', 44 | dateOfBirth: '2020-12-01', 45 | coop: 'dakota' 46 | } 47 | export const cluck: Chicken = { 48 | breed: 'orpington', 49 | name: 'cluck', 50 | dateOfBirth: '2022-03-01', 51 | coop: 'dakota' 52 | } 53 | 54 | export const waddles: Duck = { 55 | breed: 'mallard', 56 | name: 'waddles', 57 | dateOfBirth: '2021-04-01', 58 | coop: 'bristol' 59 | } 60 | -------------------------------------------------------------------------------- /test/integration/testSupportCode/integrationTestEnvironment.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation' 2 | import { throwError } from '../../../src/lib' 3 | import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb' 4 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb' 5 | 6 | export async function initAWSResources() { 7 | const documentClient = DynamoDBDocumentClient.from(new DynamoDBClient({})) 8 | 9 | const cloudformationStacks = await new CloudFormationClient({}).send( 10 | new DescribeStacksCommand({ StackName: 'entity-store-test-stack' }) 11 | ) 12 | 13 | if (!cloudformationStacks.Stacks || cloudformationStacks.Stacks.length < 1) { 14 | throw new Error('Unable to find stack') 15 | } 16 | 17 | const stackOutputs = cloudformationStacks.Stacks?.[0].Outputs ?? [] 18 | 19 | function findTableName(outputKey: string) { 20 | const tableName = stackOutputs.find((output) => output.OutputKey === outputKey)?.OutputValue 21 | return tableName ?? throwError('Unable to find table name')() 22 | } 23 | 24 | const tableNames = { 25 | testTableName: findTableName('TableName'), 26 | testTableTwoName: findTableName('TableTwoName'), 27 | twoGSITableName: findTableName('TwoGSITableName'), 28 | customTableName: findTableName('CustomTableName'), 29 | farmTableName: findTableName('FarmTableName') 30 | } 31 | 32 | console.log(JSON.stringify(tableNames)) 33 | 34 | return { 35 | documentClient, 36 | ...tableNames 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/integration/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | threads: false, 6 | testTimeout: 10000, 7 | hookTimeout: 10000 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /test/unit/dateAndTime.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { 3 | dateTimeAddDays, 4 | dateTimeAddHours, 5 | dateTimeAddMinutes, 6 | dateTimeAddSeconds 7 | } from '../../src/lib/util/dateAndTime' 8 | 9 | test('dateTimeAdd', () => { 10 | expect(dateTimeAddDays(new Date('2023-03-01T01:23:45'), 3)).toEqual(new Date('2023-03-04T01:23:45')) 11 | expect(dateTimeAddHours(new Date('2023-03-01T01:23:45'), 3)).toEqual(new Date('2023-03-01T04:23:45')) 12 | expect(dateTimeAddMinutes(new Date('2023-03-01T01:23:45'), 3)).toEqual(new Date('2023-03-01T01:26:45')) 13 | expect(dateTimeAddSeconds(new Date('2023-03-01T01:23:45'), 3)).toEqual(new Date('2023-03-01T01:23:48')) 14 | }) 15 | -------------------------------------------------------------------------------- /test/unit/internal/entityContext.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { 3 | calcAllMetaAttributeNames, 4 | createEntityContext, 5 | EntityContext 6 | } from '../../../src/lib/internal/entityContext' 7 | import { FakeClock } from '../testSupportCode/fakes/fakeClock' 8 | import { fakeDynamoDBInterface } from '../testSupportCode/fakes/fakeDynamoDBInterface' 9 | import { Sheep, SHEEP_ENTITY } from '../../examples/sheepTypeAndEntity' 10 | import { 11 | NoGSIStandardMetaAttributeNames, 12 | noopLogger, 13 | SingleGSIStandardMetaAttributeNames 14 | } from '../../../src/lib' 15 | 16 | test('calcAllMetaDataAttributeNames', () => { 17 | expect(calcAllMetaAttributeNames(NoGSIStandardMetaAttributeNames)).toEqual([ 18 | 'PK', 19 | 'SK', 20 | 'ttl', 21 | '_et', 22 | '_lastUpdated' 23 | ]) 24 | expect(calcAllMetaAttributeNames(SingleGSIStandardMetaAttributeNames)).toEqual([ 25 | 'PK', 26 | 'SK', 27 | 'ttl', 28 | '_et', 29 | '_lastUpdated', 30 | 'GSIPK', 31 | 'GSISK' 32 | ]) 33 | }) 34 | 35 | const clock = new FakeClock() 36 | const logger = noopLogger 37 | const dynamoDB = fakeDynamoDBInterface() 38 | const entity = SHEEP_ENTITY 39 | 40 | test('createContextForStandardEntity', () => { 41 | const actual = createEntityContext( 42 | { 43 | storeContext: { clock, logger, dynamoDB }, 44 | table: { 45 | tableName: 'testTable', 46 | metaAttributeNames: SingleGSIStandardMetaAttributeNames, 47 | gsiNames: { gsi: 'GSI' } 48 | } 49 | }, 50 | entity 51 | ) 52 | const expected: EntityContext = { 53 | dynamoDB, 54 | clock, 55 | logger, 56 | entity, 57 | tableName: 'testTable', 58 | tableGsiNames: { 59 | gsi: 'GSI' 60 | }, 61 | metaAttributeNames: SingleGSIStandardMetaAttributeNames, 62 | allMetaAttributeNames: ['PK', 'SK', 'ttl', '_et', '_lastUpdated', 'GSIPK', 'GSISK'] 63 | } 64 | expect(actual).toEqual(expected) 65 | }) 66 | 67 | test('createContextForCustomEntity', () => { 68 | const expectedContext2: EntityContext = { 69 | dynamoDB, 70 | clock, 71 | logger, 72 | entity, 73 | allowScans: true, 74 | tableName: 'testTable', 75 | tableGsiNames: {}, 76 | metaAttributeNames: { 77 | pk: 'PK', 78 | gsisById: {}, 79 | lastUpdated: '_lastUpdated' 80 | }, 81 | allMetaAttributeNames: ['PK', '_lastUpdated'] 82 | } 83 | expect( 84 | createEntityContext( 85 | { 86 | storeContext: { 87 | clock, 88 | logger, 89 | dynamoDB 90 | }, 91 | table: { 92 | tableName: 'testTable', 93 | allowScans: true, 94 | metaAttributeNames: { pk: 'PK', lastUpdated: '_lastUpdated' } 95 | } 96 | }, 97 | entity 98 | ) 99 | ).toEqual(expectedContext2) 100 | }) 101 | -------------------------------------------------------------------------------- /test/unit/internal/getOperations.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { SHEEP_ENTITY } from '../../examples/sheepTypeAndEntity' 3 | import { contextFor } from '../testSupportCode/entityContextSupport' 4 | import { createGetItemParams } from '../../../src/lib/internal/singleEntity/getItem' 5 | 6 | const shaunIdentifier = { breed: 'merino', name: 'shaun' } 7 | 8 | describe('createGetItemParams', () => { 9 | test('simple', () => { 10 | expect(createGetItemParams(contextFor(SHEEP_ENTITY), shaunIdentifier)).toEqual({ 11 | TableName: 'testTable', 12 | Key: { 13 | PK: 'SHEEP#BREED#merino', 14 | SK: 'NAME#shaun' 15 | } 16 | }) 17 | }) 18 | 19 | test('set consistent read if specified', () => { 20 | expect(createGetItemParams(contextFor(SHEEP_ENTITY), shaunIdentifier, { consistentRead: true })).toEqual({ 21 | TableName: 'testTable', 22 | Key: { 23 | PK: 'SHEEP#BREED#merino', 24 | SK: 'NAME#shaun' 25 | }, 26 | ConsistentRead: true 27 | }) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /test/unit/internal/operationsCommon.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { FakeClock } from '../testSupportCode/fakes/fakeClock' 3 | import { determineTTL } from '../../../src/lib/internal/common/operationsCommon' 4 | 5 | test('determineTTL', () => { 6 | const clock = new FakeClock() 7 | expect(determineTTL(clock)).toBeUndefined() 8 | expect(determineTTL(clock, {})).toBeUndefined() 9 | expect(determineTTL(clock, { ttl: 100 })).toEqual(100) 10 | expect(determineTTL(clock, { ttlInFutureDays: 10 })).toEqual(1689102000) 11 | expect(determineTTL(clock, { ttl: 100, ttlInFutureDays: 10 })).toEqual(100) 12 | }) 13 | -------------------------------------------------------------------------------- /test/unit/internal/putOperations.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { SHEEP_ENTITY } from '../../examples/sheepTypeAndEntity' 3 | import { Chicken, CHICKEN_ENTITY } from '../../examples/chickenTypeAndEntity' 4 | import { FakeClock } from '../testSupportCode/fakes/fakeClock' 5 | import { METADATA } from '../testSupportCode/fakes/fakeDynamoDBInterface' 6 | import { FARM_ENTITY } from '../../examples/farmTypeAndEntity' 7 | import { fakeLogger } from '../testSupportCode/fakes/fakeLogger' 8 | import { SingleGSIStandardMetaAttributeNames } from '../../../src/lib/support/setupSupport' 9 | import { bareBonesContext, contextFor, fakeDynamoDBFrom } from '../testSupportCode/entityContextSupport' 10 | import { gsiAttributes, putParams, ttlAttribute } from '../../../src/lib/internal/common/putCommon' 11 | import { putItem } from '../../../src/lib/internal/singleEntity/putItem' 12 | 13 | const shaunTheSheep = { breed: 'merino', name: 'shaun', ageInYears: 3 } 14 | const ginger: Chicken = { 15 | breed: 'sussex', 16 | name: 'ginger', 17 | dateOfBirth: '2021-07-01', 18 | coop: 'bristol' 19 | } 20 | 21 | test('basicPutParams', () => { 22 | expect(putParams(contextFor(SHEEP_ENTITY), shaunTheSheep)).toEqual({ 23 | TableName: 'testTable', 24 | Item: { 25 | PK: 'SHEEP#BREED#merino', 26 | SK: 'NAME#shaun', 27 | _et: 'sheep', 28 | _lastUpdated: '2023-07-01T19:00:00.000Z', 29 | breed: 'merino', 30 | name: 'shaun', 31 | ageInYears: 3 32 | } 33 | }) 34 | }) 35 | 36 | test('gsis', () => { 37 | expect(gsiAttributes(SingleGSIStandardMetaAttributeNames, SHEEP_ENTITY, shaunTheSheep)).toEqual({}) 38 | expect(gsiAttributes(SingleGSIStandardMetaAttributeNames, CHICKEN_ENTITY, ginger)).toEqual({ 39 | GSIPK: 'COOP#bristol', 40 | GSISK: 'CHICKEN#BREED#sussex#DATEOFBIRTH#2021-07-01' 41 | }) 42 | }) 43 | 44 | test('set ttl', () => { 45 | expect(putParams(contextFor(SHEEP_ENTITY), shaunTheSheep, { ttl: 100 }).Item?.['ttl']).toEqual(100) 46 | expect(putParams(contextFor(SHEEP_ENTITY), shaunTheSheep, { ttlInFutureDays: 10 }).Item?.['ttl']).toEqual( 47 | 1689102000 48 | ) 49 | }) 50 | 51 | test('ttl attribute', () => { 52 | const clock = new FakeClock() 53 | 54 | expect(ttlAttribute(clock, undefined, { ttl: 100 })).toEqual({}) 55 | expect(ttlAttribute(clock, 'ttl', undefined)).toEqual({}) 56 | expect(ttlAttribute(clock, 'ttl', {})).toEqual({}) 57 | expect(ttlAttribute(clock, 'ttl', { ttl: 100 })).toEqual({ ttl: 100 }) 58 | expect(ttlAttribute(clock, 'ttl', { ttlInFutureDays: 10 })).toEqual({ ttl: 1689102000 }) 59 | expect(ttlAttribute(clock, 'ttl', { ttl: 100, ttlInFutureDays: 10 })).toEqual({ ttl: 100 }) 60 | }) 61 | 62 | test('override standard attribute names', () => { 63 | const context = { 64 | ...contextFor(SHEEP_ENTITY, { 65 | pk: 'altPK', 66 | sk: 'customSK', 67 | ttl: 'timeToLive', 68 | entityType: 'ET', 69 | lastUpdated: 'LastUpdated' 70 | }) 71 | } 72 | 73 | expect(putParams(context, shaunTheSheep)).toEqual({ 74 | TableName: 'testTable', 75 | Item: { 76 | altPK: 'SHEEP#BREED#merino', 77 | customSK: 'NAME#shaun', 78 | ET: 'sheep', 79 | LastUpdated: '2023-07-01T19:00:00.000Z', 80 | breed: 'merino', 81 | name: 'shaun', 82 | ageInYears: 3 83 | } 84 | }) 85 | }) 86 | 87 | test('dont include optional attributes', () => { 88 | const context = { 89 | ...contextFor(SHEEP_ENTITY, { 90 | pk: 'altPK', 91 | sk: 'customSK' 92 | }) 93 | } 94 | 95 | expect(putParams(context, shaunTheSheep)).toEqual({ 96 | TableName: 'testTable', 97 | Item: { 98 | altPK: 'SHEEP#BREED#merino', 99 | customSK: 'NAME#shaun', 100 | breed: 'merino', 101 | name: 'shaun', 102 | ageInYears: 3 103 | } 104 | }) 105 | }) 106 | 107 | test('should call dynamoDB client and return result', async () => { 108 | // Arrange 109 | const context = bareBonesContext(FARM_ENTITY) 110 | const dynamoDB = fakeDynamoDBFrom(context) 111 | const expectedPutParams = { 112 | TableName: 'testTable', 113 | Item: { 114 | PK: 'Sunflower Farm' 115 | } 116 | } 117 | // Eventually do something more here when getting useful responses 118 | const putCommandOutput = { 119 | $metadata: { 120 | httpStatusCode: 200, 121 | requestId: 'ABC', 122 | attempts: 1, 123 | totalRetryDelay: 0 124 | } 125 | } 126 | dynamoDB.stubPuts.addResponse(expectedPutParams, putCommandOutput) 127 | 128 | // Act 129 | await putItem(context, { name: 'Sunflower Farm' }) 130 | 131 | // Assert 132 | expect(dynamoDB.puts.length).toEqual(1) 133 | expect(dynamoDB.puts[0]).toEqual(expectedPutParams) 134 | }) 135 | 136 | test('should call logger if debug logging enabled', async () => { 137 | // Turn on Debug Logging 138 | const logger = fakeLogger('DEBUG') 139 | const context = { 140 | ...bareBonesContext(FARM_ENTITY), 141 | logger 142 | } 143 | 144 | await putItem(context, { name: 'Sunflower Farm' }) 145 | 146 | expect(logger.debugs.length).toEqual(2) 147 | expect(logger.debugs[0][0]).toEqual('Attempting to put farm') 148 | expect(logger.debugs[0][1][0]).toEqual({ 149 | params: { 150 | TableName: 'testTable', 151 | Item: { 152 | PK: 'Sunflower Farm' 153 | } 154 | } 155 | }) 156 | expect(logger.debugs[1][0]).toEqual('Put result') 157 | expect(logger.debugs[1][1][0]).toEqual({ result: METADATA }) 158 | }) 159 | 160 | test('should not call logger if debug logging disabled', async () => { 161 | // Turn off Debug Logging 162 | const logger = fakeLogger('SILENT') 163 | const context = { 164 | ...bareBonesContext(FARM_ENTITY), 165 | logger 166 | } 167 | 168 | await putItem(context, { name: 'Sunflower Farm' }) 169 | 170 | expect(logger.debugs.length).toEqual(0) 171 | }) 172 | -------------------------------------------------------------------------------- /test/unit/internal/updateOperations.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from 'vitest' 2 | import { SHEEP_ENTITY } from '../../examples/sheepTypeAndEntity' 3 | import { excludeKeys } from '../../../src/lib/util/collections' 4 | import { SingleGSIStandardMetaAttributeNames } from '../../../src/lib/support/setupSupport' 5 | import { contextFor } from '../testSupportCode/entityContextSupport' 6 | import { createUpdateParams } from '../../../src/lib/internal/common/updateCommon' 7 | 8 | const shaunIdentifier = { breed: 'merino', name: 'shaun' } 9 | 10 | const sheepContext = contextFor(SHEEP_ENTITY) 11 | const sheepContextWithoutLastUpdated = contextFor( 12 | SHEEP_ENTITY, 13 | excludeKeys(SingleGSIStandardMetaAttributeNames, ['lastUpdated']) 14 | ) 15 | 16 | describe('createUpdateParams', () => { 17 | test('simple SET update with standard table attributes', () => { 18 | expect( 19 | createUpdateParams(sheepContext, shaunIdentifier, { 20 | update: { 21 | set: 'ageInYears = :newAge' 22 | }, 23 | expressionAttributeValues: { 24 | ':newAge': 4 25 | } 26 | }) 27 | ).toEqual({ 28 | TableName: 'testTable', 29 | Key: { 30 | PK: 'SHEEP#BREED#merino', 31 | SK: 'NAME#shaun' 32 | }, 33 | UpdateExpression: 'SET ageInYears = :newAge, #lastUpdated = :newLastUpdated', 34 | ExpressionAttributeNames: { 35 | '#lastUpdated': '_lastUpdated' 36 | }, 37 | ExpressionAttributeValues: { 38 | ':newAge': 4, 39 | ':newLastUpdated': '2023-07-01T19:00:00.000Z' 40 | } 41 | }) 42 | }) 43 | 44 | test('Dont set lastupdated if not in metadata', () => { 45 | expect( 46 | createUpdateParams(sheepContextWithoutLastUpdated, shaunIdentifier, { 47 | update: { 48 | set: 'ageInYears = :newAge' 49 | }, 50 | expressionAttributeValues: { 51 | ':newAge': 4 52 | } 53 | }) 54 | ).toEqual({ 55 | TableName: 'testTable', 56 | Key: { 57 | PK: 'SHEEP#BREED#merino', 58 | SK: 'NAME#shaun' 59 | }, 60 | UpdateExpression: 'SET ageInYears = :newAge', 61 | ExpressionAttributeValues: { 62 | ':newAge': 4 63 | } 64 | }) 65 | }) 66 | 67 | test('ADD', () => { 68 | expect( 69 | createUpdateParams(sheepContext, shaunIdentifier, { 70 | update: { 71 | add: 'Color :c' 72 | }, 73 | expressionAttributeValues: { 74 | ':c': new Set(['Orange', 'Purple']) 75 | } 76 | }) 77 | ).toEqual({ 78 | TableName: 'testTable', 79 | Key: { 80 | PK: 'SHEEP#BREED#merino', 81 | SK: 'NAME#shaun' 82 | }, 83 | UpdateExpression: 'SET #lastUpdated = :newLastUpdated ADD Color :c', 84 | ExpressionAttributeNames: { 85 | '#lastUpdated': '_lastUpdated' 86 | }, 87 | ExpressionAttributeValues: { 88 | ':c': new Set(['Orange', 'Purple']), 89 | ':newLastUpdated': '2023-07-01T19:00:00.000Z' 90 | } 91 | }) 92 | }) 93 | 94 | test('ADD without lastUpdated', () => { 95 | expect( 96 | createUpdateParams(sheepContextWithoutLastUpdated, shaunIdentifier, { 97 | update: { 98 | add: 'Color :c' 99 | }, 100 | expressionAttributeValues: { 101 | ':c': new Set(['Orange', 'Purple']) 102 | } 103 | }) 104 | ).toEqual({ 105 | TableName: 'testTable', 106 | Key: { 107 | PK: 'SHEEP#BREED#merino', 108 | SK: 'NAME#shaun' 109 | }, 110 | UpdateExpression: 'ADD Color :c', 111 | ExpressionAttributeValues: { 112 | ':c': new Set(['Orange', 'Purple']) 113 | } 114 | }) 115 | }) 116 | 117 | test('REMOVE', () => { 118 | expect( 119 | createUpdateParams(sheepContext, shaunIdentifier, { 120 | update: { 121 | remove: 'Color' 122 | } 123 | }) 124 | ).toEqual({ 125 | TableName: 'testTable', 126 | Key: { 127 | PK: 'SHEEP#BREED#merino', 128 | SK: 'NAME#shaun' 129 | }, 130 | UpdateExpression: 'SET #lastUpdated = :newLastUpdated REMOVE Color', 131 | ExpressionAttributeNames: { 132 | '#lastUpdated': '_lastUpdated' 133 | }, 134 | ExpressionAttributeValues: { 135 | ':newLastUpdated': '2023-07-01T19:00:00.000Z' 136 | } 137 | }) 138 | }) 139 | 140 | test('DELETE', () => { 141 | expect( 142 | createUpdateParams(sheepContext, shaunIdentifier, { 143 | update: { 144 | delete: 'Color :c' 145 | }, 146 | expressionAttributeValues: { 147 | ':c': new Set(['Orange', 'Purple']) 148 | } 149 | }) 150 | ).toEqual({ 151 | TableName: 'testTable', 152 | Key: { 153 | PK: 'SHEEP#BREED#merino', 154 | SK: 'NAME#shaun' 155 | }, 156 | UpdateExpression: 'SET #lastUpdated = :newLastUpdated DELETE Color :c', 157 | ExpressionAttributeNames: { 158 | '#lastUpdated': '_lastUpdated' 159 | }, 160 | ExpressionAttributeValues: { 161 | ':c': new Set(['Orange', 'Purple']), 162 | ':newLastUpdated': '2023-07-01T19:00:00.000Z' 163 | } 164 | }) 165 | }) 166 | 167 | test('All elements', () => { 168 | expect( 169 | createUpdateParams(sheepContext, shaunIdentifier, { 170 | update: { 171 | set: 'ageInYears = :newAge', 172 | remove: 'description', 173 | add: 'highlights :h', 174 | delete: 'Color :c' 175 | }, 176 | expressionAttributeValues: { 177 | ':newAge': 4, 178 | ':h': new Set(['yellow']), 179 | ':c': new Set(['Orange', 'Purple']) 180 | } 181 | }) 182 | ).toEqual({ 183 | TableName: 'testTable', 184 | Key: { 185 | PK: 'SHEEP#BREED#merino', 186 | SK: 'NAME#shaun' 187 | }, 188 | UpdateExpression: 189 | 'SET ageInYears = :newAge, #lastUpdated = :newLastUpdated REMOVE description ADD highlights :h DELETE Color :c', 190 | ExpressionAttributeNames: { 191 | '#lastUpdated': '_lastUpdated' 192 | }, 193 | ExpressionAttributeValues: { 194 | ':newAge': 4, 195 | ':h': new Set(['yellow']), 196 | ':c': new Set(['Orange', 'Purple']), 197 | ':newLastUpdated': '2023-07-01T19:00:00.000Z' 198 | } 199 | }) 200 | }) 201 | 202 | test('with condition', () => { 203 | expect( 204 | createUpdateParams(sheepContext, shaunIdentifier, { 205 | update: { 206 | set: 'ageInYears = :newAge' 207 | }, 208 | conditionExpression: '#ageInYears < :invalidAge', 209 | expressionAttributeNames: { 210 | '#ageInYears': 'ageInYears' 211 | }, 212 | expressionAttributeValues: { 213 | ':invalidAge': 3, 214 | ':newAge': 3 215 | } 216 | }) 217 | ).toEqual({ 218 | TableName: 'testTable', 219 | Key: { 220 | PK: 'SHEEP#BREED#merino', 221 | SK: 'NAME#shaun' 222 | }, 223 | UpdateExpression: 'SET ageInYears = :newAge, #lastUpdated = :newLastUpdated', 224 | ConditionExpression: '#ageInYears < :invalidAge', 225 | ExpressionAttributeNames: { 226 | '#lastUpdated': '_lastUpdated', 227 | '#ageInYears': 'ageInYears' 228 | }, 229 | ExpressionAttributeValues: { 230 | ':invalidAge': 3, 231 | ':newAge': 3, 232 | ':newLastUpdated': '2023-07-01T19:00:00.000Z' 233 | } 234 | }) 235 | }) 236 | 237 | test('reset lastupdated, and TTL if specified', () => { 238 | expect( 239 | createUpdateParams(sheepContext, shaunIdentifier, { 240 | update: { 241 | set: 'ageInYears = :newAge' 242 | }, 243 | expressionAttributeValues: { 244 | ':newAge': 4 245 | }, 246 | ttl: 100 247 | }) 248 | ).toEqual({ 249 | TableName: 'testTable', 250 | Key: { 251 | PK: 'SHEEP#BREED#merino', 252 | SK: 'NAME#shaun' 253 | }, 254 | UpdateExpression: 'SET ageInYears = :newAge, #lastUpdated = :newLastUpdated, #ttl = :newTTL', 255 | ExpressionAttributeNames: { 256 | '#lastUpdated': '_lastUpdated', 257 | '#ttl': 'ttl' 258 | }, 259 | ExpressionAttributeValues: { 260 | ':newAge': 4, 261 | ':newLastUpdated': '2023-07-01T19:00:00.000Z', 262 | ':newTTL': 100 263 | } 264 | }) 265 | }) 266 | 267 | test('be able to reset TTL without any other field updates', () => { 268 | expect( 269 | createUpdateParams(sheepContext, shaunIdentifier, { 270 | ttl: 100 271 | }) 272 | ).toEqual({ 273 | TableName: 'testTable', 274 | Key: { 275 | PK: 'SHEEP#BREED#merino', 276 | SK: 'NAME#shaun' 277 | }, 278 | UpdateExpression: 'SET #lastUpdated = :newLastUpdated, #ttl = :newTTL', 279 | ExpressionAttributeNames: { 280 | '#lastUpdated': '_lastUpdated', 281 | '#ttl': 'ttl' 282 | }, 283 | ExpressionAttributeValues: { 284 | ':newLastUpdated': '2023-07-01T19:00:00.000Z', 285 | ':newTTL': 100 286 | } 287 | }) 288 | }) 289 | 290 | test('Dont set lastupdated if not in metadata, but do set TTL if specified', () => { 291 | expect( 292 | createUpdateParams(sheepContextWithoutLastUpdated, shaunIdentifier, { 293 | update: { 294 | set: 'ageInYears = :newAge' 295 | }, 296 | expressionAttributeValues: { 297 | ':newAge': 4 298 | }, 299 | ttlInFutureDays: 10 300 | }) 301 | ).toEqual({ 302 | TableName: 'testTable', 303 | Key: { 304 | PK: 'SHEEP#BREED#merino', 305 | SK: 'NAME#shaun' 306 | }, 307 | UpdateExpression: 'SET ageInYears = :newAge, #ttl = :newTTL', 308 | ExpressionAttributeNames: { 309 | '#ttl': 'ttl' 310 | }, 311 | ExpressionAttributeValues: { 312 | ':newAge': 4, 313 | ':newTTL': 1689102000 314 | } 315 | }) 316 | }) 317 | }) 318 | -------------------------------------------------------------------------------- /test/unit/support/entitySupport.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { typePredicateParser } from '../../../src/lib/support/entitySupport' 3 | import { isSheep } from '../../examples/sheepTypeAndEntity' 4 | 5 | test('typePredicateParser', () => { 6 | const parser = typePredicateParser(isSheep, 'sheep') 7 | 8 | expect( 9 | parser( 10 | { PK: 'aa', SK: 'bb', _et: 'cc', breed: 'merino', name: 'shaun', ageInYears: 4 }, 11 | ['PK', 'SK', '_et'], 12 | { 13 | pk: 'PK', 14 | sk: 'SK', 15 | entityType: '_et' 16 | } 17 | ) 18 | ).toEqual({ 19 | breed: 'merino', 20 | name: 'shaun', 21 | ageInYears: 4 22 | }) 23 | 24 | expect(() => 25 | parser({ PK: 'aa', SK: 'bb', _et: 'cc', name: 'shaun', ageInYears: 4 }, ['PK'], { 26 | pk: 'PK' 27 | }) 28 | ).toThrowError('Failed to parse entity to type sheep') 29 | }) 30 | -------------------------------------------------------------------------------- /test/unit/support/querySupport.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { 3 | rangeWhereSkBeginsWith, 4 | rangeWhereSkBetween, 5 | rangeWhereSkEquals, 6 | rangeWhereSkGreaterThan, 7 | rangeWhereSkGreaterThanOrEquals, 8 | rangeWhereSkLessThan, 9 | rangeWhereSkLessThanOrEquals 10 | } from '../../../src/lib/support/querySupport' 11 | 12 | test('skEquals', () => { 13 | expect(rangeWhereSkEquals('FIELD#aaa')).toEqual({ 14 | skConditionExpressionClause: '#sk = :sk', 15 | expressionAttributeValues: { 16 | ':sk': 'FIELD#aaa' 17 | } 18 | }) 19 | }) 20 | 21 | test('skGreaterThan', () => { 22 | expect(rangeWhereSkGreaterThan('FIELD#aaa')).toEqual({ 23 | skConditionExpressionClause: '#sk > :sk', 24 | expressionAttributeValues: { 25 | ':sk': 'FIELD#aaa' 26 | } 27 | }) 28 | }) 29 | 30 | test('skGreaterThanOrEquals', () => { 31 | expect(rangeWhereSkGreaterThanOrEquals('FIELD#aaa')).toEqual({ 32 | skConditionExpressionClause: '#sk >= :sk', 33 | expressionAttributeValues: { 34 | ':sk': 'FIELD#aaa' 35 | } 36 | }) 37 | }) 38 | 39 | test('skLessThan', () => { 40 | expect(rangeWhereSkLessThan('FIELD#aaa')).toEqual({ 41 | skConditionExpressionClause: '#sk < :sk', 42 | expressionAttributeValues: { 43 | ':sk': 'FIELD#aaa' 44 | } 45 | }) 46 | }) 47 | 48 | test('skLessThanOrEquals', () => { 49 | expect(rangeWhereSkLessThanOrEquals('FIELD#aaa')).toEqual({ 50 | skConditionExpressionClause: '#sk <= :sk', 51 | expressionAttributeValues: { 52 | ':sk': 'FIELD#aaa' 53 | } 54 | }) 55 | }) 56 | 57 | test('skBetween', () => { 58 | expect(rangeWhereSkBetween('FIELD#aaa', 'FIELD#zzz')).toEqual({ 59 | skConditionExpressionClause: '#sk BETWEEN :from AND :to', 60 | expressionAttributeValues: { 61 | ':from': 'FIELD#aaa', 62 | ':to': 'FIELD#zzz' 63 | } 64 | }) 65 | }) 66 | 67 | test('skBeginsWith', () => { 68 | expect(rangeWhereSkBeginsWith('FIELD#abc')).toEqual({ 69 | skConditionExpressionClause: 'begins_with(#sk, :skPrefix)', 70 | expressionAttributeValues: { 71 | ':skPrefix': 'FIELD#abc' 72 | } 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /test/unit/support/setupSupport.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { 3 | createMinimumSingleTableConfig, 4 | createStandardMultiTableConfig, 5 | createStandardSingleTableConfig 6 | } from '../../../src/lib' 7 | 8 | test('createMinimumSingleTableConfig', () => { 9 | expect(createMinimumSingleTableConfig('testTable', { pk: 'TESTPK' })).toEqual({ 10 | tableName: 'testTable', 11 | metaAttributeNames: { 12 | pk: 'TESTPK' 13 | } 14 | }) 15 | }) 16 | 17 | test('createStandardSingleTableConfig', () => { 18 | expect(createStandardSingleTableConfig('testTable')).toEqual({ 19 | tableName: 'testTable', 20 | allowScans: false, 21 | metaAttributeNames: { 22 | pk: 'PK', 23 | sk: 'SK', 24 | ttl: 'ttl', 25 | entityType: '_et', 26 | lastUpdated: '_lastUpdated', 27 | gsisById: { 28 | gsi: { 29 | pk: 'GSIPK', 30 | sk: 'GSISK' 31 | } 32 | } 33 | }, 34 | gsiNames: { 35 | gsi: 'GSI' 36 | } 37 | }) 38 | }) 39 | 40 | test('createStandardMultiTableConfigWithDefaultTable', () => { 41 | expect( 42 | createStandardMultiTableConfig( 43 | { table1: ['table1Entity1', 'table1Entity2'], table2: ['table2Entity'] }, 44 | 'table1' 45 | ) 46 | ).toEqual({ 47 | defaultTableName: 'table1', 48 | entityTables: [ 49 | { 50 | entityTypes: ['table1Entity1', 'table1Entity2'], 51 | tableName: 'table1', 52 | allowScans: false, 53 | metaAttributeNames: { 54 | pk: 'PK', 55 | sk: 'SK', 56 | ttl: 'ttl', 57 | entityType: '_et', 58 | lastUpdated: '_lastUpdated', 59 | gsisById: { 60 | gsi: { 61 | pk: 'GSIPK', 62 | sk: 'GSISK' 63 | } 64 | } 65 | }, 66 | gsiNames: { 67 | gsi: 'GSI' 68 | } 69 | }, 70 | { 71 | entityTypes: ['table2Entity'], 72 | tableName: 'table2', 73 | allowScans: false, 74 | metaAttributeNames: { 75 | pk: 'PK', 76 | sk: 'SK', 77 | ttl: 'ttl', 78 | entityType: '_et', 79 | lastUpdated: '_lastUpdated', 80 | gsisById: { 81 | gsi: { 82 | pk: 'GSIPK', 83 | sk: 'GSISK' 84 | } 85 | } 86 | }, 87 | gsiNames: { 88 | gsi: 'GSI' 89 | } 90 | } 91 | ] 92 | }) 93 | }) 94 | 95 | test('createStandardMultiTableConfigWithoutDefaultTable', () => { 96 | expect( 97 | createStandardMultiTableConfig({ table1: ['table1Entity1', 'table1Entity2'], table2: ['table2Entity'] }) 98 | ).toEqual({ 99 | entityTables: [ 100 | { 101 | entityTypes: ['table1Entity1', 'table1Entity2'], 102 | tableName: 'table1', 103 | allowScans: false, 104 | metaAttributeNames: { 105 | pk: 'PK', 106 | sk: 'SK', 107 | ttl: 'ttl', 108 | entityType: '_et', 109 | lastUpdated: '_lastUpdated', 110 | gsisById: { 111 | gsi: { 112 | pk: 'GSIPK', 113 | sk: 'GSISK' 114 | } 115 | } 116 | }, 117 | gsiNames: { 118 | gsi: 'GSI' 119 | } 120 | }, 121 | { 122 | entityTypes: ['table2Entity'], 123 | tableName: 'table2', 124 | allowScans: false, 125 | metaAttributeNames: { 126 | pk: 'PK', 127 | sk: 'SK', 128 | ttl: 'ttl', 129 | entityType: '_et', 130 | lastUpdated: '_lastUpdated', 131 | gsisById: { 132 | gsi: { 133 | pk: 'GSIPK', 134 | sk: 'GSISK' 135 | } 136 | } 137 | }, 138 | gsiNames: { 139 | gsi: 'GSI' 140 | } 141 | } 142 | ] 143 | }) 144 | }) 145 | 146 | test('createStandardMultiTableConfigWithoutBadDefaultTable', () => { 147 | expect(() => createStandardMultiTableConfig({ table1: ['table1Entity1'] }, 'badDefault')).toThrowError( 148 | 'Default table badDefault is not included in list of tables' 149 | ) 150 | }) 151 | -------------------------------------------------------------------------------- /test/unit/testSupportCode/entityContextSupport.ts: -------------------------------------------------------------------------------- 1 | import { createStandardSingleTableConfig, Entity, MetaAttributeNames, noopLogger } from '../../../src/lib' 2 | import { createEntityContext, EntityContext } from '../../../src/lib/internal/entityContext' 3 | import { FakeDynamoDBInterface, fakeDynamoDBInterface } from './fakes/fakeDynamoDBInterface' 4 | import { FakeClock } from './fakes/fakeClock' 5 | 6 | export function contextFor( 7 | entity: Entity, 8 | customMetaAttributeNames?: MetaAttributeNames 9 | ): EntityContext { 10 | return createEntityContext( 11 | { 12 | storeContext: { 13 | dynamoDB: fakeDynamoDBInterface(), 14 | clock: new FakeClock(), 15 | logger: noopLogger 16 | }, 17 | table: { 18 | ...createStandardSingleTableConfig('testTable'), 19 | allowScans: false, 20 | ...(customMetaAttributeNames ? { metaAttributeNames: customMetaAttributeNames } : {}) 21 | } 22 | }, 23 | entity 24 | ) 25 | } 26 | 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | export function fakeDynamoDBFrom(context: EntityContext) { 29 | const fakeDynamoDB = context.dynamoDB as FakeDynamoDBInterface 30 | if (fakeDynamoDB.stubPuts) return fakeDynamoDB 31 | throw new Error('DynamoDB was not fake') 32 | } 33 | 34 | export function bareBonesContext( 35 | entity: Entity 36 | ): EntityContext { 37 | return contextFor(entity, { pk: 'PK' }) 38 | } 39 | -------------------------------------------------------------------------------- /test/unit/testSupportCode/fakes/fakeClock.ts: -------------------------------------------------------------------------------- 1 | import { Clock } from '../../../../src/lib/util/dateAndTime' 2 | 3 | export class FakeClock implements Clock { 4 | public fakeNowIso: string 5 | 6 | constructor(fakeNowIso = '2023-07-01T19:00:00.000Z') { 7 | this.fakeNowIso = fakeNowIso 8 | } 9 | 10 | now(): Date { 11 | return new Date(Date.parse(this.fakeNowIso)) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/unit/testSupportCode/fakes/fakeDynamoDBInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BatchGetCommandInput, 3 | BatchGetCommandOutput, 4 | BatchWriteCommandInput, 5 | BatchWriteCommandOutput, 6 | DeleteCommandInput, 7 | GetCommandInput, 8 | GetCommandOutput, 9 | PutCommandInput, 10 | PutCommandOutput, 11 | QueryCommandInput, 12 | QueryCommandOutput, 13 | ScanCommandInput, 14 | ScanCommandOutput, 15 | TransactGetCommandInput, 16 | TransactWriteCommandInput, 17 | UpdateCommandInput, 18 | UpdateCommandOutput 19 | } from '@aws-sdk/lib-dynamodb' 20 | import { DynamoDBInterface } from '../../../../src/lib/dynamoDBInterface' 21 | import { arrayStubResponse } from './fakeSupport' 22 | 23 | export const METADATA = { $metadata: {} } 24 | 25 | export function fakeDynamoDBInterface() { 26 | return new FakeDynamoDBInterface() 27 | } 28 | 29 | export class FakeDynamoDBInterface implements DynamoDBInterface { 30 | public puts: PutCommandInput[] = [] 31 | public batchWrites: BatchWriteCommandInput[] = [] 32 | public updates: UpdateCommandInput[] = [] 33 | public deletes: DeleteCommandInput[] = [] 34 | public stubPuts = arrayStubResponse() 35 | public stubGets = arrayStubResponse() 36 | public stubBatchGets = arrayStubResponse() 37 | public stubOnePageQueries = arrayStubResponse() 38 | public stubAllPagesQueries = arrayStubResponse() 39 | public stubOnePageScans = arrayStubResponse() 40 | public stubAllPagesScans = arrayStubResponse() 41 | 42 | constructor() { 43 | // Needed because of Javascript and weird 'this' behavior when using dynamic binding 44 | // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#bound_methods_in_classes 45 | this.queryOnePage = this.queryOnePage.bind(this) 46 | this.queryAllPages = this.queryAllPages.bind(this) 47 | this.scanOnePage = this.scanOnePage.bind(this) 48 | this.scanAllPages = this.scanAllPages.bind(this) 49 | } 50 | 51 | async put(params: PutCommandInput) { 52 | this.puts.push(params) 53 | return this.stubPuts.getResponse(params) ?? METADATA 54 | } 55 | 56 | async batchWrite(params: BatchWriteCommandInput): Promise { 57 | this.batchWrites.push(params) 58 | return METADATA 59 | } 60 | 61 | async update(params: UpdateCommandInput): Promise { 62 | this.updates.push(params) 63 | return METADATA 64 | } 65 | 66 | async get(params: GetCommandInput) { 67 | return this.stubGets.getResponse(params) ?? METADATA 68 | } 69 | 70 | async batchGet(params: BatchGetCommandInput) { 71 | return this.stubBatchGets.getResponse(params) ?? METADATA 72 | } 73 | 74 | async delete(params: DeleteCommandInput) { 75 | this.deletes.push(params) 76 | return METADATA 77 | } 78 | 79 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 80 | async transactionWrite(params: TransactWriteCommandInput) { 81 | return METADATA 82 | } 83 | 84 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 85 | async transactionGet(params: TransactGetCommandInput) { 86 | return METADATA 87 | } 88 | 89 | async queryOnePage(params: QueryCommandInput) { 90 | return this.stubOnePageQueries.getResponse(params) ?? METADATA 91 | } 92 | 93 | async queryAllPages(params: QueryCommandInput) { 94 | return this.stubAllPagesQueries.getResponse(params) ?? [METADATA] 95 | } 96 | 97 | async scanOnePage(params: ScanCommandInput) { 98 | return this.stubOnePageScans.getResponse(params) ?? METADATA 99 | } 100 | 101 | async scanAllPages(params: ScanCommandInput) { 102 | return this.stubAllPagesScans.getResponse(params) ?? [METADATA] 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /test/unit/testSupportCode/fakes/fakeLogger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntityStoreLogger, 3 | EntityStoreLogItemExtraInput, 4 | EntityStoreLogItemMessage 5 | } from '../../../../src/lib/util/logger' 6 | 7 | export function fakeLogger(levelName: Uppercase): EntityStoreLogger & { 8 | debugs: [EntityStoreLogItemMessage, EntityStoreLogItemExtraInput][] 9 | } { 10 | const debugs: [EntityStoreLogItemMessage, EntityStoreLogItemExtraInput][] = [] 11 | return { 12 | debugs, 13 | getLevelName(): Uppercase { 14 | return levelName 15 | }, 16 | debug(input: EntityStoreLogItemMessage, ...extraInput) { 17 | debugs.push([input, extraInput]) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/unit/testSupportCode/fakes/fakeSupport.ts: -------------------------------------------------------------------------------- 1 | import deepEqual from 'deep-equal' 2 | 3 | export interface StubResponse { 4 | addResponse(input: TInput, output: TOutput): void 5 | 6 | getResponse(input: TInput): TOutput | undefined 7 | } 8 | 9 | export function arrayStubResponse(): StubResponse { 10 | const stubs: [TInput, TOutput][] = [] 11 | return { 12 | addResponse(input: TInput, output: TOutput) { 13 | stubs.push([input, output]) 14 | }, 15 | getResponse(input: TInput): TOutput | undefined { 16 | return stubs.find(([stubInput]) => deepEqual(input, stubInput))?.[1] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/unit/util/collections.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { chunk, excludeKeys, removeNullOrUndefined, selectKeys } from '../../../src/lib/util/collections' 3 | 4 | test('omitProperties', () => { 5 | expect(excludeKeys({ a: 1, b: 2, c: 3, d: 4 }, [])).toStrictEqual({ a: 1, b: 2, c: 3, d: 4 }) 6 | expect(excludeKeys({ a: 1, b: 2, c: 3, d: 4 }, ['d'])).toStrictEqual({ a: 1, b: 2, c: 3 }) 7 | expect(excludeKeys({ a: 1, b: 2, c: 3, d: 4 }, ['a', 'c'])).toStrictEqual({ b: 2, d: 4 }) 8 | expect(excludeKeys({ a: 1, b: 2, c: 3, d: 4 }, ['a', 'b', 'c', 'd'])).toStrictEqual({}) 9 | }) 10 | 11 | test('selectProperties', () => { 12 | expect(selectKeys({ a: 1, b: 2, c: 3, d: 4 }, [])).toStrictEqual({}) 13 | expect(selectKeys({ a: 1, b: 2, c: 3, d: 4 }, ['d'])).toStrictEqual({ d: 4 }) 14 | expect(selectKeys({ a: 1, b: 2, c: 3, d: 4 }, ['a', 'c'])).toStrictEqual({ a: 1, c: 3 }) 15 | expect(selectKeys({ a: 1, b: 2, c: 3, d: 4 }, ['a', 'b', 'c', 'd'])).toStrictEqual({ 16 | a: 1, 17 | b: 2, 18 | c: 3, 19 | d: 4 20 | }) 21 | }) 22 | 23 | test('chunk', () => { 24 | expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]) 25 | }) 26 | 27 | test('removeNullOrUndefined', () => { 28 | expect(removeNullOrUndefined([null, 1, 2, undefined, 3, null])).toEqual([1, 2, 3]) 29 | }) 30 | -------------------------------------------------------------------------------- /test/unit/util/errors.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { throwError } from '../../../src/lib/util/errors' 3 | 4 | test('throwError', () => { 5 | expect(throwError('just declare')).toBeDefined() 6 | expect(() => throwError('actually throw')()).toThrowError('actually throw') 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig-build-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["test/**/*.ts"], 4 | "compilerOptions": { 5 | "noEmit": false, 6 | "outDir": "dist/cjs" 7 | } 8 | } -------------------------------------------------------------------------------- /tsconfig-build-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["test/**/*.ts"], 4 | "compilerOptions": { 5 | "module": "es2022", 6 | "noEmit": false, 7 | "outDir": "dist/esm" 8 | } 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | // Tests are excluded in build tsconfigs 4 | "include": ["src/**/*.ts", "test/**/*.ts"], 5 | "exclude": ["node_modules", "dist"], 6 | "compilerOptions": { 7 | // ** Modules ** 8 | "target": "es2021", 9 | // "module"" is overridden in the ESM build config 10 | "module": "commonjs", 11 | // This is default for commonjs, but not for ES Modules, so be explicit here 12 | "moduleResolution": "node", 13 | 14 | // ** Type Checking ** 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | 19 | // ** Interop ** 20 | // Set to false - see https://evertpot.com/universal-commonjs-esm-typescript-packages/ 21 | "esModuleInterop": false, 22 | "allowSyntheticDefaultImports": true, 23 | "forceConsistentCasingInFileNames": true, 24 | 25 | // ** Completeness ** 26 | "skipLibCheck": true, 27 | 28 | // ** Emit ** 29 | // noEmit overridden in build tsconfigs 30 | "noEmit": true, 31 | "declaration": true, 32 | "sourceMap": true, 33 | } 34 | } --------------------------------------------------------------------------------