diff --git a/docs/features/idempotency.md b/docs/features/idempotency.md index a27a71803a..aa282ebb30 100644 --- a/docs/features/idempotency.md +++ b/docs/features/idempotency.md @@ -913,6 +913,15 @@ Below an example implementation of a custom persistence layer backed by a generi For example, the `_putRecord()` method needs to throw an error if a non-expired record already exists in the data store with a matching key. +???+ tip "Overriding the public methods" + The protected methods above are the recommended extension points for a custom storage implementation. Subclasses that implement only these hooks need no further changes to keep the record identity guarantees of the base class. + + If you override `saveInProgress()`, `saveSuccess()`, or `deleteRecord()` instead: + + * Accept the optional trailing `options` argument (typed as `PersistenceOperationOptions`) and forward it unchanged to `super`, even if your application does not mutate its inputs today. This also preserves any option added in the future. + * The utility resolves the idempotency key and payload hash before your function runs and passes them as `options.identity` to these methods, so completion and cleanup target the record that was created even if the function mutated its input. + * An override that forwards only the original arguments discards the identity and falls back to hashing the payload as it is at that point. + ### Manipulating the Idempotent Response You can set up a `responseHook` in the `IdempotentConfig` class to manipulate the returned data when an operation is idempotent. The hook function will be called with the current deserialized response object and the Idempotency record. diff --git a/packages/idempotency/src/IdempotencyHandler.ts b/packages/idempotency/src/IdempotencyHandler.ts index 456b9ed0ca..f84d5768fc 100644 --- a/packages/idempotency/src/IdempotencyHandler.ts +++ b/packages/idempotency/src/IdempotencyHandler.ts @@ -15,6 +15,7 @@ import { import type { IdempotencyConfig } from './IdempotencyConfig.js'; import type { BasePersistenceLayer } from './persistence/BasePersistenceLayer.js'; import type { IdempotencyRecord } from './persistence/IdempotencyRecord.js'; +import type { IdempotencyRecordIdentity } from './types/BasePersistenceLayer.js'; import type { AnyFunction, IdempotencyHandlerOptions, @@ -43,6 +44,14 @@ export class IdempotencyHandler { * This is the argument that is used for the idempotency. */ #functionPayloadToBeHashed: JSONValue; + /** + * The identity of the idempotency record, resolved from the payload before + * the operation starts. + * + * It is passed to the persistence calls that follow, so they all target the + * same record even if the wrapped function mutated the payload in the meantime. + */ + #recordIdentity?: IdempotencyRecordIdentity; /** * Reference to the function to be made idempotent. */ @@ -344,7 +353,8 @@ export class IdempotencyHandler { readonly #deleteInProgressRecord = async (): Promise => { try { await this.#persistenceStore.deleteRecord( - this.#functionPayloadToBeHashed + this.#functionPayloadToBeHashed, + { identity: this.#recordIdentity } ); } catch (error) { throw new IdempotencyPersistenceLayerError( @@ -376,9 +386,15 @@ export class IdempotencyHandler { result: undefined, }; try { + // Resolve the identity before attempting acquisition so that it is also + // available when the function runs without acquiring a record (replay). + this.#recordIdentity = this.#persistenceStore.getRecordIdentity( + this.#functionPayloadToBeHashed + ); await this.#persistenceStore.saveInProgress( this.#functionPayloadToBeHashed, - this.#idempotencyConfig.lambdaContext?.getRemainingTimeInMillis() + this.#idempotencyConfig.lambdaContext?.getRemainingTimeInMillis(), + { identity: this.#recordIdentity } ); return returnValue; @@ -437,7 +453,8 @@ export class IdempotencyHandler { try { await this.#persistenceStore.saveSuccess( this.#functionPayloadToBeHashed, - result + result, + { identity: this.#recordIdentity } ); } catch (error) { throw new IdempotencyPersistenceLayerError( diff --git a/packages/idempotency/src/persistence/BasePersistenceLayer.ts b/packages/idempotency/src/persistence/BasePersistenceLayer.ts index e4d4d5cb7a..de15c25627 100644 --- a/packages/idempotency/src/persistence/BasePersistenceLayer.ts +++ b/packages/idempotency/src/persistence/BasePersistenceLayer.ts @@ -15,6 +15,8 @@ import type { IdempotencyConfig } from '../IdempotencyConfig.js'; import type { BasePersistenceLayerInterface, BasePersistenceLayerOptions, + IdempotencyRecordIdentity, + PersistenceOperationOptions, } from '../types/BasePersistenceLayer.js'; import { IdempotencyRecord } from './IdempotencyRecord.js'; @@ -158,10 +160,15 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { * Deletes a record from the persistence store for the persistence key generated from the data passed in. * * @param data - the data payload that will be hashed to create the hash portion of the idempotency key + * @param options - operation options; when `options.identity` is provided its key is used instead of hashing `data` again, so the record is deleted even if `data` was mutated after the identity was resolved by {@link BasePersistenceLayer.getRecordIdentity | `getRecordIdentity()`} */ - public async deleteRecord(data: JSONValue): Promise { + public async deleteRecord( + data: JSONValue, + options?: PersistenceOperationOptions + ): Promise { const idempotencyRecord = new IdempotencyRecord({ - idempotencyKey: this.getHashedIdempotencyKey(data), + idempotencyKey: + options?.identity?.idempotencyKey ?? this.getHashedIdempotencyKey(data), status: IdempotencyRecordStatus.EXPIRED, }); @@ -177,6 +184,22 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { return this.expiresAfterSeconds; } + /** + * Resolves the idempotency key and payload hash that identify the record for the provided data. + * + * Call it before the operation starts and pass the result to {@link BasePersistenceLayer.saveInProgress | `saveInProgress()`}, + * {@link BasePersistenceLayer.saveSuccess | `saveSuccess()`}, and {@link BasePersistenceLayer.deleteRecord | `deleteRecord()`}, + * so they all target the same record even if `data` is mutated while the operation runs. + * + * @param data - the data payload that will be hashed to create the hash portion of the idempotency key + */ + public getRecordIdentity(data: JSONValue): IdempotencyRecordIdentity { + return { + idempotencyKey: this.getHashedIdempotencyKey(data), + payloadHash: this.getHashedPayload(data), + }; + } + /** * Retrieves idempotency key for the provided data and fetches data for that key from the persistence store * @@ -233,16 +256,20 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { * * @param data - the data payload that will be hashed to create the hash portion of the idempotency key * @param remainingTimeInMillis - the remaining time left in the lambda execution context + * @param options - operation options; when `options.identity` is provided it is used instead of hashing `data` again, see {@link BasePersistenceLayer.getRecordIdentity | `getRecordIdentity()`} */ public async saveInProgress( data: JSONValue, - remainingTimeInMillis?: number + remainingTimeInMillis?: number, + options?: PersistenceOperationOptions ): Promise { + const { idempotencyKey, payloadHash } = + options?.identity ?? this.getRecordIdentity(data); const idempotencyRecord = new IdempotencyRecord({ - idempotencyKey: this.getHashedIdempotencyKey(data), + idempotencyKey, status: IdempotencyRecordStatus.INPROGRESS, expiryTimestamp: this.getExpiryTimestamp(), - payloadHash: this.getHashedPayload(data), + payloadHash, }); if (remainingTimeInMillis) { @@ -271,14 +298,21 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface { * * @param data - the data payload that will be hashed to create the hash portion of the idempotency key * @param result - the result of the successfully completed function + * @param options - operation options; when `options.identity` is provided its key and payload hash are used instead of hashing `data` again, so the in-progress record is completed even if `data` was mutated after the identity was resolved by {@link BasePersistenceLayer.getRecordIdentity | `getRecordIdentity()`} */ - public async saveSuccess(data: JSONValue, result: JSONValue): Promise { + public async saveSuccess( + data: JSONValue, + result: JSONValue, + options?: PersistenceOperationOptions + ): Promise { + const { idempotencyKey, payloadHash } = + options?.identity ?? this.getRecordIdentity(data); const idempotencyRecord = new IdempotencyRecord({ - idempotencyKey: this.getHashedIdempotencyKey(data), + idempotencyKey, status: IdempotencyRecordStatus.COMPLETED, expiryTimestamp: this.getExpiryTimestamp(), responseData: result, - payloadHash: this.getHashedPayload(data), + payloadHash, }); await this._updateRecord(idempotencyRecord); diff --git a/packages/idempotency/src/types/BasePersistenceLayer.ts b/packages/idempotency/src/types/BasePersistenceLayer.ts index ae11795539..97d388bd61 100644 --- a/packages/idempotency/src/types/BasePersistenceLayer.ts +++ b/packages/idempotency/src/types/BasePersistenceLayer.ts @@ -7,12 +7,43 @@ type BasePersistenceLayerOptions = { keyPrefix?: string; }; +/** + * The idempotency key and payload hash that identify the record of an operation. + * + * It is resolved from the payload before the operation starts, so completion and cleanup + * target the same record even if the payload is mutated while the operation runs. + */ +type IdempotencyRecordIdentity = Pick< + IdempotencyRecord, + 'idempotencyKey' | 'payloadHash' +>; + +/** + * Options accepted by the persistence operations that write or delete a record. + * + * @property identity - the record identity resolved before the operation started; when provided it is used instead of hashing the payload again + */ +type PersistenceOperationOptions = { + identity?: IdempotencyRecordIdentity; +}; + interface BasePersistenceLayerInterface { configure(options?: BasePersistenceLayerOptions): void; isPayloadValidationEnabled(): boolean; - saveInProgress(data: unknown, remainingTimeInMillis?: number): Promise; - saveSuccess(data: unknown, result: unknown): Promise; - deleteRecord(data: unknown): Promise; + saveInProgress( + data: unknown, + remainingTimeInMillis?: number, + options?: PersistenceOperationOptions + ): Promise; + saveSuccess( + data: unknown, + result: unknown, + options?: PersistenceOperationOptions + ): Promise; + deleteRecord( + data: unknown, + options?: PersistenceOperationOptions + ): Promise; getRecord(data: unknown): Promise; } @@ -38,4 +69,6 @@ export type { BasePersistenceAttributes, BasePersistenceLayerInterface, BasePersistenceLayerOptions, + IdempotencyRecordIdentity, + PersistenceOperationOptions, }; diff --git a/packages/idempotency/src/types/index.ts b/packages/idempotency/src/types/index.ts index 1d77754342..f481d539af 100644 --- a/packages/idempotency/src/types/index.ts +++ b/packages/idempotency/src/types/index.ts @@ -2,6 +2,8 @@ export type { BasePersistenceAttributes, BasePersistenceLayerInterface, BasePersistenceLayerOptions, + IdempotencyRecordIdentity, + PersistenceOperationOptions, } from './BasePersistenceLayer.js'; export type { CacheClient, diff --git a/packages/idempotency/tests/unit/makeIdempotent.test.ts b/packages/idempotency/tests/unit/makeIdempotent.test.ts index db9f7c2342..a3740fe4b3 100644 --- a/packages/idempotency/tests/unit/makeIdempotent.test.ts +++ b/packages/idempotency/tests/unit/makeIdempotent.test.ts @@ -1,4 +1,5 @@ import type { DurableContext } from '@aws/durable-execution-sdk-js'; +import type { JSONValue } from '@aws-lambda-powertools/commons/types'; import context from '@aws-lambda-powertools/testing-utils/context'; import middy from '@middy/core'; import type { Context } from 'aws-lambda'; @@ -17,12 +18,30 @@ import { } from '../../src/index.js'; import { makeHandlerIdempotent } from '../../src/middleware/makeHandlerIdempotent.js'; import { IdempotencyRecord } from '../../src/persistence/index.js'; +import type { PersistenceOperationOptions } from '../../src/types/index.js'; import { PersistenceLayerTestClass } from '../helpers/idempotencyUtils.js'; const mockIdempotencyOptions = { persistenceStore: new PersistenceLayerTestClass(), }; const remainingTImeInMillis = 1234; +const withIdentity = { + identity: { + idempotencyKey: expect.any(String), + payloadHash: expect.any(String), + }, +}; +/** + * The subset of a durable context that makeIdempotent inspects to detect it and its execution mode. + */ +type DurableTestContext = Pick & { + durableExecutionMode?: 'ExecutionMode' | 'ReplayMode'; +}; +const durableReplayContext: DurableTestContext = { + step: vi.fn(), + lambdaContext: context, + durableExecutionMode: 'ReplayMode', +}; const fnSuccessfull = async () => true; const fnError = () => { throw new Error('Something went wrong'); @@ -77,10 +96,15 @@ describe('Function: makeIdempotent', () => { expect(saveInProgressSpy).toHaveBeenCalledTimes(1); expect(saveInProgressSpy).toHaveBeenCalledWith( event, - remainingTImeInMillis + remainingTImeInMillis, + withIdentity ); expect(saveSuccessSpy).toHaveBeenCalledTimes(1); - expect(saveSuccessSpy).toHaveBeenCalledWith(event, context.awsRequestId); + expect(saveSuccessSpy).toHaveBeenCalledWith( + event, + context.awsRequestId, + withIdentity + ); }); it.each([ @@ -110,10 +134,11 @@ describe('Function: makeIdempotent', () => { expect(saveInProgressSpy).toHaveBeenCalledTimes(1); expect(saveInProgressSpy).toHaveBeenCalledWith( event, - remainingTImeInMillis + remainingTImeInMillis, + withIdentity ); expect(deleteRecordSpy).toHaveBeenCalledTimes(1); - expect(deleteRecordSpy).toHaveBeenCalledWith(event); + expect(deleteRecordSpy).toHaveBeenCalledWith(event, withIdentity); }); it('handles an execution that throws an early middleware error (middleware)', async () => { @@ -666,10 +691,11 @@ describe('Function: makeIdempotent', () => { expect(saveInProgressSpy).toHaveBeenCalledTimes(1); expect(saveInProgressSpy).toHaveBeenCalledWith( event, - remainingTImeInMillis + remainingTImeInMillis, + withIdentity ); expect(saveSuccessSpy).toHaveBeenCalledTimes(1); - expect(saveSuccessSpy).toHaveBeenCalledWith(event, '123456'); + expect(saveSuccessSpy).toHaveBeenCalledWith(event, '123456', withIdentity); }); it('uses the specified argument as payload when wrapping an arbitrary function', async () => { @@ -702,10 +728,11 @@ describe('Function: makeIdempotent', () => { expect(saveInProgressSpy).toHaveBeenCalledTimes(1); expect(saveInProgressSpy).toHaveBeenCalledWith( '456', - remainingTImeInMillis + remainingTImeInMillis, + withIdentity ); expect(saveSuccessSpy).toHaveBeenCalledTimes(1); - expect(saveSuccessSpy).toHaveBeenCalledWith('456', '123456'); + expect(saveSuccessSpy).toHaveBeenCalledWith('456', '123456', withIdentity); }); it('skips idempotency if error is thrown in the middleware', async () => { @@ -789,13 +816,16 @@ describe('Function: makeIdempotent', () => { 'registerLambdaContext' ); const handler = makeIdempotent( - async (_event: unknown, _context: DurableContext) => {}, + async (_event: unknown, _context: DurableTestContext) => {}, mockIdempotencyOptions ); - const mockDurableContext = { step: vi.fn(), lambdaContext: context }; + const mockDurableContext: DurableTestContext = { + step: vi.fn(), + lambdaContext: context, + }; // Act - await handler(event, mockDurableContext as unknown as DurableContext); + await handler(event, mockDurableContext); // Assess expect(registerLambdaContextSpy).toHaveBeenCalledOnce(); @@ -805,17 +835,12 @@ describe('Function: makeIdempotent', () => { // Prepare const handleSpy = vi.spyOn(IdempotencyHandler.prototype, 'handle'); const handler = makeIdempotent( - async (_event: unknown, _context: DurableContext) => {}, + async (_event: unknown, _context: DurableTestContext) => {}, mockIdempotencyOptions ); - const mockDurableContext = { - step: vi.fn(), - lambdaContext: context, - durableExecutionMode: 'ReplayMode', - }; // Act - await handler(event, mockDurableContext as unknown as DurableContext); + await handler(event, durableReplayContext); // Assess expect(handleSpy).toHaveBeenCalledWith({ isReplay: true }); @@ -825,19 +850,204 @@ describe('Function: makeIdempotent', () => { // Prepare const handleSpy = vi.spyOn(IdempotencyHandler.prototype, 'handle'); const handler = makeIdempotent( - async (_event: unknown, _context: DurableContext) => {}, + async (_event: unknown, _context: DurableTestContext) => {}, mockIdempotencyOptions ); - const mockDurableContext = { + const mockDurableContext: DurableTestContext = { step: vi.fn(), lambdaContext: context, durableExecutionMode: 'ExecutionMode', }; // Act - await handler(event, mockDurableContext as unknown as DurableContext); + await handler(event, mockDurableContext); // Assess expect(handleSpy).toHaveBeenCalledWith({ isReplay: false }); }); + + it('completes the record it acquired when the wrapped function mutates its input', async () => { + // Prepare + const persistenceStore = new PersistenceLayerTestClass(); + const config = new IdempotencyConfig({}); + config.registerLambdaContext(context); + const processOrder = makeIdempotent( + async (order: { id: string; normalized?: boolean }) => { + order.normalized = true; + return { processed: order.id }; + }, + { persistenceStore, config } + ); + + // Act + await processOrder({ id: 'order-1' }); + + // Assess + const [putRecord] = persistenceStore._putRecord.mock.calls[0]; + const [updateRecord] = persistenceStore._updateRecord.mock.calls[0]; + expect(updateRecord.idempotencyKey).toBe(putRecord.idempotencyKey); + }); + + it('deletes the record it acquired when the wrapped function mutates its input and throws', async () => { + // Prepare + const persistenceStore = new PersistenceLayerTestClass(); + const config = new IdempotencyConfig({}); + config.registerLambdaContext(context); + const processOrder = makeIdempotent( + async (order: { id: string; normalized?: boolean }) => { + order.normalized = true; + throw new Error('Something went wrong'); + }, + { persistenceStore, config } + ); + + // Act + await expect(processOrder({ id: 'order-1' })).rejects.toThrow( + 'Something went wrong' + ); + + // Assess + const [putRecord] = persistenceStore._putRecord.mock.calls[0]; + const [deleteRecord] = persistenceStore._deleteRecord.mock.calls[0]; + expect(deleteRecord.idempotencyKey).toBe(putRecord.idempotencyKey); + }); + + it('stores the validation hash it acquired when the wrapped function mutates a validated field', async () => { + // Prepare + const persistenceStore = new PersistenceLayerTestClass(); + const config = new IdempotencyConfig({ + payloadValidationJmesPath: 'amount', + }); + config.registerLambdaContext(context); + const processOrder = makeIdempotent( + async (order: { id: string; amount: number }) => { + order.amount = order.amount * 100; + return { processed: order.id }; + }, + { persistenceStore, config } + ); + + // Act + await processOrder({ id: 'order-1', amount: 10 }); + + // Assess + const [putRecord] = persistenceStore._putRecord.mock.calls[0]; + const [updateRecord] = persistenceStore._updateRecord.mock.calls[0]; + expect(updateRecord.payloadHash).toBe(putRecord.payloadHash); + }); + + it('completes the existing record when the wrapped function mutates its input during a durable replay', async () => { + // Prepare + const persistenceStore = new PersistenceLayerTestClass(); + persistenceStore._putRecord.mockRejectedValueOnce( + new IdempotencyItemAlreadyExistsError('Record is already in progress') + ); + const processOrder = makeIdempotent( + async ( + order: { id: string; normalized?: boolean }, + _context: DurableTestContext + ) => { + order.normalized = true; + return { processed: order.id }; + }, + { persistenceStore } + ); + + // Act + await processOrder({ id: 'order-1' }, durableReplayContext); + + // Assess + const [inProgressRecord] = persistenceStore._putRecord.mock.calls[0]; + const [updateRecord] = persistenceStore._updateRecord.mock.calls[0]; + expect(updateRecord.idempotencyKey).toBe(inProgressRecord.idempotencyKey); + }); + + it('deletes the existing record when the wrapped function mutates its input and throws during a durable replay', async () => { + // Prepare + const persistenceStore = new PersistenceLayerTestClass(); + persistenceStore._putRecord.mockRejectedValueOnce( + new IdempotencyItemAlreadyExistsError('Record is already in progress') + ); + const processOrder = makeIdempotent( + async ( + order: { id: string; normalized?: boolean }, + _context: DurableTestContext + ) => { + order.normalized = true; + throw new Error('Something went wrong'); + }, + { persistenceStore } + ); + + // Act + await expect( + processOrder({ id: 'order-1' }, durableReplayContext) + ).rejects.toThrow('Something went wrong'); + + // Assess + const [inProgressRecord] = persistenceStore._putRecord.mock.calls[0]; + const [deleteRecord] = persistenceStore._deleteRecord.mock.calls[0]; + expect(deleteRecord.idempotencyKey).toBe(inProgressRecord.idempotencyKey); + }); + + it('completes the record it acquired when the persistence layer overrides saveInProgress without the identity', async () => { + // Prepare + class OverridingPersistenceLayer extends PersistenceLayerTestClass { + public async saveInProgress( + data: JSONValue, + remainingTimeInMillis?: number + ): Promise { + await super.saveInProgress(data, remainingTimeInMillis); + } + } + const persistenceStore = new OverridingPersistenceLayer(); + const config = new IdempotencyConfig({}); + config.registerLambdaContext(context); + const processOrder = makeIdempotent( + async (order: { id: string; normalized?: boolean }) => { + order.normalized = true; + return { processed: order.id }; + }, + { persistenceStore, config } + ); + + // Act + await processOrder({ id: 'order-1' }); + + // Assess + const [putRecord] = persistenceStore._putRecord.mock.calls[0]; + const [updateRecord] = persistenceStore._updateRecord.mock.calls[0]; + expect(updateRecord.idempotencyKey).toBe(putRecord.idempotencyKey); + }); + + it('completes the record it acquired when the persistence layer overrides saveSuccess and forwards the options', async () => { + // Prepare + class OverridingPersistenceLayer extends PersistenceLayerTestClass { + public async saveSuccess( + data: JSONValue, + result: JSONValue, + options?: PersistenceOperationOptions + ): Promise { + await super.saveSuccess(data, result, options); + } + } + const persistenceStore = new OverridingPersistenceLayer(); + const config = new IdempotencyConfig({}); + config.registerLambdaContext(context); + const processOrder = makeIdempotent( + async (order: { id: string; normalized?: boolean }) => { + order.normalized = true; + return { processed: order.id }; + }, + { persistenceStore, config } + ); + + // Act + await processOrder({ id: 'order-1' }); + + // Assess + const [putRecord] = persistenceStore._putRecord.mock.calls[0]; + const [updateRecord] = persistenceStore._updateRecord.mock.calls[0]; + expect(updateRecord.idempotencyKey).toBe(putRecord.idempotencyKey); + }); }); diff --git a/packages/idempotency/tests/unit/persistence/BasePersistenceLayer.test.ts b/packages/idempotency/tests/unit/persistence/BasePersistenceLayer.test.ts index 4c5874d933..e4967e3ad1 100644 --- a/packages/idempotency/tests/unit/persistence/BasePersistenceLayer.test.ts +++ b/packages/idempotency/tests/unit/persistence/BasePersistenceLayer.test.ts @@ -380,6 +380,27 @@ describe('Class: BasePersistenceLayer', () => { ); }); + it('uses the provided identity instead of hashing the payload', async () => { + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + const deleteRecordSpy = vi.spyOn(persistenceLayer, '_deleteRecord'); + const identity = { + idempotencyKey: 'my-lambda-function#resolved-hash', + payloadHash: '', + }; + + // Act + await persistenceLayer.deleteRecord({ foo: 'bar' }, { identity }); + + // Assess + expect(deleteRecordSpy).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: 'my-lambda-function#resolved-hash', + status: IdempotencyRecordStatus.EXPIRED, + }) + ); + }); + it('it deletes the record from the local cache', async () => { // Prepare const persistenceLayer = new PersistenceLayerTestClass(); @@ -409,6 +430,41 @@ describe('Class: BasePersistenceLayer', () => { }); }); + describe('Method: getRecordIdentity', () => { + it('returns the idempotency key and the payload hash for the payload', () => { + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + persistenceLayer.configure({ + config: new IdempotencyConfig({ + payloadValidationJmesPath: 'foo', + }), + }); + + // Act + const identity = persistenceLayer.getRecordIdentity({ foo: 'bar' }); + + // Assess + expect(identity).toEqual({ + idempotencyKey: 'my-lambda-function#mocked-hash', + payloadHash: 'mocked-hash', + }); + }); + + it('returns an empty payload hash when payload validation is disabled', () => { + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + + // Act + const identity = persistenceLayer.getRecordIdentity({ foo: 'bar' }); + + // Assess + expect(identity).toEqual({ + idempotencyKey: 'my-lambda-function#mocked-hash', + payloadHash: '', + }); + }); + }); + describe('Method: getRecord', () => { it('calls the _getRecord method with the correct arguments', async () => { // Prepare @@ -573,6 +629,29 @@ describe('Class: BasePersistenceLayer', () => { ); }); + it('uses the provided identity instead of hashing the payload', async () => { + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + const putRecordSpy = vi.spyOn(persistenceLayer, '_putRecord'); + const identity = { + idempotencyKey: 'my-lambda-function#resolved-hash', + payloadHash: 'resolved-payload-hash', + }; + + // Act + await persistenceLayer.saveInProgress({ foo: 'bar' }, 2000, { + identity, + }); + + // Assess + expect(putRecordSpy).toHaveBeenCalledWith( + expect.objectContaining({ + ...identity, + status: IdempotencyRecordStatus.INPROGRESS, + }) + ); + }); + it('logs a warning when unable to call remainingTimeInMillis() from the context', async () => { // Prepare const persistenceLayer = new PersistenceLayerTestClass(); @@ -642,6 +721,31 @@ describe('Class: BasePersistenceLayer', () => { }) ); }); + + it('uses the provided identity instead of hashing the payload', async () => { + // Prepare + const persistenceLayer = new PersistenceLayerTestClass(); + const updateRecordSpy = vi.spyOn(persistenceLayer, '_updateRecord'); + const identity = { + idempotencyKey: 'my-lambda-function#resolved-hash', + payloadHash: 'resolved-payload-hash', + }; + + // Act + await persistenceLayer.saveSuccess( + { foo: 'bar' }, + { bar: 'baz' }, + { identity } + ); + + // Assess + expect(updateRecordSpy).toHaveBeenCalledWith( + expect.objectContaining({ + ...identity, + status: IdempotencyRecordStatus.COMPLETED, + }) + ); + }); }); describe('Method: processExistingRecord', () => {