diff --git a/doc/api.md b/doc/api.md
index 389d299..454f8b0 100644
--- a/doc/api.md
+++ b/doc/api.md
@@ -60,10 +60,10 @@ Cloud State Management
* *[.getRegionalEndpoint(endpoint, region)](#AdobeState+getRegionalEndpoint) ⇒ string
*
* *[.get(key)](#AdobeState+get) ⇒ [Promise.<AdobeStateGetReturnValue>
](#AdobeStateGetReturnValue)*
* *[.put(key, value, [options])](#AdobeState+put) ⇒ Promise.<string>
*
- * *[.delete(key)](#AdobeState+delete) ⇒ Promise.<string>
*
- * *[.deleteAll()](#AdobeState+deleteAll) ⇒ Promise.<boolean>
*
+ * *[.delete(key)](#AdobeState+delete) ⇒ Promise.<(string\|null)>
*
+ * *[.deleteAll(options)](#AdobeState+deleteAll) ⇒ Promise.<{keys: number}>
*
* *[.any()](#AdobeState+any) ⇒ Promise.<boolean>
*
- * *[.stats()](#AdobeState+stats) ⇒ Promise.<({bytesKeys: number, bytesValues: number, keys: number}\|boolean)>
*
+ * *[.stats()](#AdobeState+stats) ⇒ Promise.<{bytesKeys: number, bytesValues: number, keys: number}>
*
* *[.list(options)](#AdobeState+list) ⇒ AsyncGenerator.<{keys: Array.<string>}>
*
@@ -108,11 +108,11 @@ Creates or updates a state key-value pair
-### *adobeState.delete(key) ⇒ Promise.<string>
*
+### *adobeState.delete(key) ⇒ Promise.<(string\|null)>
*
Deletes a state key-value pair
**Kind**: instance method of [AdobeState
](#AdobeState)
-**Returns**: Promise.<string>
- key of deleted state or `null` if state does not exist
+**Returns**: Promise.<(string\|null)>
- key of deleted state or `null` if state does not exist
| Param | Type | Description |
| --- | --- | --- |
@@ -120,25 +120,36 @@ Deletes a state key-value pair
-### *adobeState.deleteAll() ⇒ Promise.<boolean>
*
-Deletes all key-values
+### *adobeState.deleteAll(options) ⇒ Promise.<{keys: number}>
*
+Deletes multiple key-values. The match option is required as a safeguard.
+CAUTION: use `{ match: '*' }` to delete all key-values.
**Kind**: instance method of [AdobeState
](#AdobeState)
-**Returns**: Promise.<boolean>
- true if deleted, false if not
+**Returns**: Promise.<{keys: number}>
- returns an object with the number of deleted keys.
+
+| Param | Type | Description |
+| --- | --- | --- |
+| options | object
| deleteAll options. |
+| options.match | string
| REQUIRED, a glob pattern to specify which keys to delete. |
+
+**Example**
+```js
+await state.deleteAll({ match: 'abc*' })
+```
### *adobeState.any() ⇒ Promise.<boolean>
*
-There exists key-values.
+There exists key-values in the region.
**Kind**: instance method of [AdobeState
](#AdobeState)
**Returns**: Promise.<boolean>
- true if exists, false if not
-### *adobeState.stats() ⇒ Promise.<({bytesKeys: number, bytesValues: number, keys: number}\|boolean)>
*
+### *adobeState.stats() ⇒ Promise.<{bytesKeys: number, bytesValues: number, keys: number}>
*
Get stats.
**Kind**: instance method of [AdobeState
](#AdobeState)
-**Returns**: Promise.<({bytesKeys: number, bytesValues: number, keys: number}\|boolean)>
- namespace stats or false if not exists
+**Returns**: Promise.<{bytesKeys: number, bytesValues: number, keys: number}>
- State container stats.
### *adobeState.list(options) ⇒ AsyncGenerator.<{keys: Array.<string>}>
*
diff --git a/e2e/e2e.js b/e2e/e2e.js
index 1bc4b67..c6be371 100644
--- a/e2e/e2e.js
+++ b/e2e/e2e.js
@@ -22,7 +22,7 @@ const { MAX_TTL_SECONDS } = require('../lib/constants')
const stateLib = require('../index')
const { randomInt } = require('node:crypto')
-const uniquePrefix = `${Date.now()}.${randomInt(10)}`
+const uniquePrefix = `${Date.now()}.${randomInt(100)}`
const testKey = `${uniquePrefix}__e2e_test_state_key`
const testKey2 = `${uniquePrefix}__e2e_test_state_key2`
@@ -34,13 +34,33 @@ const initStateEnv = async (n = 1) => {
process.env.__OW_API_KEY = process.env[`TEST_AUTH_${n}`]
process.env.__OW_NAMESPACE = process.env[`TEST_NAMESPACE_${n}`]
const state = await stateLib.init()
- // // make sure we cleanup the namespace, note that delete might fail as it is an op under test
- // await state.delete(`${uniquePrefix}*`)
- await state.delete(testKey)
- await state.delete(testKey2)
+ // make sure we cleanup the namespace, note that delete might fail as it is an op under test
+ await state.deleteAll({ match: `${uniquePrefix}*` })
return state
}
+// helpers
+const genKeyStrings = (n, identifier) => {
+ return (new Array(n).fill(0).map((_, idx) => {
+ const char = String.fromCharCode(97 + idx % 26)
+ // list-[a-z]-[0-(N-1)]
+ return `${uniquePrefix}__${identifier}_${char}_${idx}`
+ }))
+}
+const putKeys = async (state, keys, ttl) => {
+ const _putKeys = async (keys, ttl) => {
+ await Promise.all(keys.map(async (k, idx) => await state.put(k, `value-${idx}`, { ttl })))
+ }
+
+ const batchSize = 20
+ let i = 0
+ while (i < keys.length - batchSize) {
+ await _putKeys(keys.slice(i, i + batchSize), ttl)
+ i += batchSize
+ }
+ // final call
+ await _putKeys(keys.slice(i), ttl)
+}
const waitFor = (ms) => new Promise(resolve => setTimeout(resolve, ms))
test('env vars', () => {
@@ -146,34 +166,12 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
await expect(state.put(testKey, testValue, { ttl: -1 })).rejects.toThrow()
})
- test('listKeys test: few < 128 keys, many, and expired entries', async () => {
+ test('listKeys test: few, many, and expired entries', async () => {
const state = await initStateEnv()
- const genKeyStrings = (n) => {
- return (new Array(n).fill(0).map((_, idx) => {
- const char = String.fromCharCode(97 + idx % 26)
- // list-[a-z]-[0-(N-1)]
- return `${uniquePrefix}__list_${char}_${idx}`
- }))
- }
- const putKeys = async (keys, ttl) => {
- const _putKeys = async (keys, ttl) => {
- await Promise.all(keys.map(async (k, idx) => await state.put(k, `value-${idx}`, { ttl })))
- }
-
- const batchSize = 20
- let i = 0
- while (i < keys.length - batchSize) {
- await _putKeys(keys.slice(i, i + batchSize), ttl)
- i += batchSize
- }
- // final call
- await _putKeys(keys.slice(i), ttl)
- }
-
// 1. test with not many elements, one iteration should return all
- const keys90 = genKeyStrings(90).sort()
- await putKeys(keys90, 60)
+ const keys90 = genKeyStrings(90, 'list').sort()
+ await putKeys(state, keys90, 60)
let it, ret
@@ -203,8 +201,8 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
expect(await it.next()).toEqual({ done: true, value: undefined })
// 2. test with many elements and large countHint
- const keys900 = genKeyStrings(900)
- await putKeys(keys900, 60)
+ const keys900 = genKeyStrings(900, 'list')
+ await putKeys(state, keys900, 60)
// note: we can't list in isolation without prefix
it = state.list({ countHint: 1000 })
@@ -255,7 +253,7 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
expect(retArray.length).toEqual(1)
// 4. make sure expired keys aren't listed
- await putKeys(keys90, 1)
+ await putKeys(state, keys90, 1)
await waitFor(2000)
it = state.list({ countHint: 1000, match: `${uniquePrefix}__list_*` })
@@ -264,6 +262,23 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
expect(await it.next()).toEqual({ done: true, value: undefined })
})
+ test('deleteAll test', async () => {
+ const state = await initStateEnv()
+
+ // < 100 keys
+ const keys90 = genKeyStrings(90, 'deleteAll').sort()
+ await putKeys(state, keys90, 60)
+ expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_a*` })).toEqual({ keys: 4 })
+ expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*` })).toEqual({ keys: 86 })
+
+ // > 1000 keys
+ const keys1100 = genKeyStrings(1100, 'deleteAll').sort()
+ await putKeys(state, keys1100, 60)
+ expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*_1` })).toEqual({ keys: 1 })
+ expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*_1*0` })).toEqual({ keys: 21 }) // 10, 100 - 190, 1000-1090
+ expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*` })).toEqual({ keys: 1078 })
+ })
+
test('throw error when get/put with invalid keys', async () => {
const invalidKey = 'some/invalid:key'
const state = await initStateEnv()
diff --git a/lib/AdobeState.js b/lib/AdobeState.js
index 3cb0ba7..052f8f3 100644
--- a/lib/AdobeState.js
+++ b/lib/AdobeState.js
@@ -28,7 +28,7 @@ const {
MAX_LIST_COUNT_HINT,
REQUEST_ID_HEADER,
MIN_LIST_COUNT_HINT,
- REGEX_PATTERN_LIST_KEY_MATCH,
+ REGEX_PATTERN_MATCH_KEY,
MAX_TTL_SECONDS
} = require('./constants')
@@ -370,7 +370,7 @@ class AdobeState {
* Deletes a state key-value pair
*
* @param {string} key state key identifier
- * @returns {Promise} key of deleted state or `null` if state does not exist
+ * @returns {Promise} key of deleted state or `null` if state does not exist
* @memberof AdobeState
*/
async delete (key) {
@@ -383,6 +383,23 @@ class AdobeState {
}
}
+ const schema = {
+ type: 'object',
+ properties: {
+ key: {
+ type: 'string',
+ pattern: REGEX_PATTERN_STORE_KEY
+ }
+ }
+ }
+ const { valid, errors } = validate(schema, { key })
+ if (!valid) {
+ logAndThrow(new codes.ERROR_BAD_ARGUMENT({
+ messageValues: utils.formatAjvErrors(errors),
+ sdkDetails: { key, errors }
+ }))
+ }
+
logger.debug('delete', requestOptions)
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(`/data/${key}`), requestOptions)
@@ -395,12 +412,16 @@ class AdobeState {
}
/**
- * Deletes all key-values
- *
- * @returns {Promise} true if deleted, false if not
+ * Deletes multiple key-values. The match option is required as a safeguard.
+ * CAUTION: use `{ match: '*' }` to delete all key-values.
+ * @example
+ * await state.deleteAll({ match: 'abc*' })
+ * @param {object} options deleteAll options.
+ * @param {string} options.match REQUIRED, a glob pattern to specify which keys to delete.
+ * @returns {Promise<{ keys: number }>} returns an object with the number of deleted keys.
* @memberof AdobeState
*/
- async deleteAll () {
+ async deleteAll (options = {}) {
const requestOptions = {
method: 'DELETE',
headers: {
@@ -410,13 +431,37 @@ class AdobeState {
logger.debug('deleteAll', requestOptions)
- const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions)
+ const schema = {
+ type: 'object',
+ properties: {
+ match: { type: 'string', pattern: REGEX_PATTERN_MATCH_KEY }
+ },
+ required: ['match'] // safeguard, you cannot call deleteAll without matching specific keys!
+ }
+ const { valid, errors } = validate(schema, options)
+ if (!valid) {
+ logAndThrow(new codes.ERROR_BAD_ARGUMENT({
+ messageValues: utils.formatAjvErrors(errors),
+ sdkDetails: { options, errors }
+ }))
+ }
+
+ const queryParams = { matchData: options.match }
+
+ // ! be extra cautious, if the `matchData` param is not specified the whole container will be deleted
+ const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl('', queryParams), requestOptions)
const response = await _wrap(promise, {})
- return (response.status !== 404)
+
+ if (response.status === 404) {
+ return { keys: 0 }
+ } else {
+ const { keys } = await response.json()
+ return { keys }
+ }
}
/**
- * There exists key-values.
+ * There exists key-values in the region.
*
* @returns {Promise} true if exists, false if not
* @memberof AdobeState
@@ -439,7 +484,7 @@ class AdobeState {
/**
* Get stats.
*
- * @returns {Promise<{ bytesKeys: number, bytesValues: number, keys: number} | boolean>} namespace stats or false if not exists
+ * @returns {Promise<{ bytesKeys: number, bytesValues: number, keys: number }>} State container stats.
* @memberof AdobeState
*/
async stats () {
@@ -455,9 +500,10 @@ class AdobeState {
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions)
const response = await _wrap(promise, {})
if (response.status === 404) {
- return false
+ return { keys: 0, bytesKeys: 0, bytesValues: 0 }
} else {
- return response.json()
+ const { keys, bytesKeys, bytesValues } = await response.json()
+ return { keys, bytesKeys, bytesValues }
}
}
@@ -504,7 +550,7 @@ class AdobeState {
const schema = {
type: 'object',
properties: {
- match: { type: 'string', pattern: REGEX_PATTERN_LIST_KEY_MATCH }, // this is an important check
+ match: { type: 'string', pattern: REGEX_PATTERN_MATCH_KEY },
countHint: { type: 'integer' }
}
}
diff --git a/lib/constants.js b/lib/constants.js
index 918fb7a..4167c55 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -46,8 +46,8 @@ const HEADER_KEY_EXPIRES = 'x-key-expires-ms'
const REGEX_PATTERN_STORE_NAMESPACE = '^(development-)?([0-9]{3,10})-([a-z0-9]{1,20})(-([a-z0-9]{1,20}))?$'
// The regex for keys, allowed chars are alphanumerical with _ - .
const REGEX_PATTERN_STORE_KEY = `^[a-zA-Z0-9-_.]{1,${MAX_KEY_SIZE}}$`
-// The regex for list key pattern, allowed chars are alphanumerical with _ - . and * for glob matching
-const REGEX_PATTERN_LIST_KEY_MATCH = `^[a-zA-Z0-9-_.*]{1,${MAX_KEY_SIZE}}$`
+// Same as REGEX_PATTERN_STORE_KEY with an added * to support glob-style matching
+const REGEX_PATTERN_MATCH_KEY = `^[a-zA-Z0-9-_.*]{1,${MAX_KEY_SIZE}}$`
const MAX_LIST_COUNT_HINT = 1000
const MIN_LIST_COUNT_HINT = 100
@@ -62,7 +62,7 @@ module.exports = {
REGEX_PATTERN_STORE_NAMESPACE,
REGEX_PATTERN_STORE_KEY,
HEADER_KEY_EXPIRES,
- REGEX_PATTERN_LIST_KEY_MATCH,
+ REGEX_PATTERN_MATCH_KEY,
MAX_LIST_COUNT_HINT,
MIN_LIST_COUNT_HINT,
REQUEST_ID_HEADER,
diff --git a/test/AdobeState.test.js b/test/AdobeState.test.js
index 79687b1..fefc5b1 100644
--- a/test/AdobeState.test.js
+++ b/test/AdobeState.test.js
@@ -344,6 +344,14 @@ describe('delete', () => {
store = await AdobeState.init(fakeCredentials)
})
+ test('failure (invalid key)', async () => {
+ const key = 'invalid/key'
+ const value = 'some-value'
+
+ await expect(store.delete(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] /key must match pattern "^[a-zA-Z0-9-_.]{1,1024}$"')
+ expect(mockExponentialBackoff).not.toHaveBeenCalled()
+ })
+
test('success', async () => {
const key = 'valid-key'
const fetchResponseJson = {}
@@ -377,16 +385,26 @@ describe('deleteAll', () => {
store = await AdobeState.init(fakeCredentials)
})
+ test('safeguard: match option is required', async () => {
+ await expect(store.deleteAll()).rejects.toThrow('must have required properties: match')
+ })
+
+ test('invalid match option', async () => {
+ await expect(store.deleteAll({ match: ':isaninvalidchar' })).rejects.toThrow('/match must match pattern')
+ await expect(store.deleteAll({ match: '{isaninvalidchar' })).rejects.toThrow('/match must match pattern')
+ await expect(store.deleteAll({ match: '}isaninvalidchar' })).rejects.toThrow('/match must match pattern')
+ })
+
test('success', async () => {
- const fetchResponseJson = {}
+ const fetchResponseJson = JSON.stringify({ keys: 10 })
mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson))
- const value = await store.deleteAll()
- expect(value).toEqual(true)
+ const value = await store.deleteAll({ match: '*' })
+ expect(value).toEqual({ keys: 10 })
expect(mockExponentialBackoff)
.toHaveBeenCalledWith(
- 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace',
+ 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace?matchData=*',
expect.objectContaining({ method: 'DELETE' })
)
})
@@ -394,8 +412,8 @@ describe('deleteAll', () => {
test('not found', async () => {
mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404))
- const value = await store.deleteAll()
- expect(value).toEqual(false)
+ const value = await store.deleteAll({ match: '*' })
+ expect(value).toEqual({ keys: 0 })
})
})
@@ -424,7 +442,7 @@ describe('stats()', () => {
mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404))
const value = await store.stats()
- expect(value).toEqual(false)
+ expect(value).toEqual({ keys: 0, bytesValues: 0, bytesKeys: 0 })
})
})
diff --git a/types.d.ts b/types.d.ts
index f99b621..33dd5be 100644
--- a/types.d.ts
+++ b/types.d.ts
@@ -62,22 +62,29 @@ export class AdobeState {
* @param key - state key identifier
* @returns key of deleted state or `null` if state does not exist
*/
- delete(key: string): Promise;
+ delete(key: string): Promise;
/**
- * Deletes all key-values
- * @returns true if deleted, false if not
+ * Deletes multiple key-values. The match option is required as a safeguard.
+ * CAUTION: use `{ match: '*' }` to delete all key-values.
+ * @example
+ * await state.deleteAll({ match: 'abc*' })
+ * @param options - deleteAll options.
+ * @param options.match - REQUIRED, a glob pattern to specify which keys to delete.
+ * @returns returns an object with the number of deleted keys.
*/
- deleteAll(): Promise;
+ deleteAll(options: {
+ match: string;
+ }): Promise<{ keys: number; }>;
/**
- * There exists key-values.
+ * There exists key-values in the region.
* @returns true if exists, false if not
*/
any(): Promise;
/**
* Get stats.
- * @returns namespace stats or false if not exists
+ * @returns State container stats.
*/
- stats(): Promise<{ bytesKeys: number; bytesValues: number; keys: number; } | boolean>;
+ stats(): Promise<{ bytesKeys: number; bytesValues: number; keys: number; }>;
/**
* List keys, returns an iterator. Every iteration returns a batch of
* approximately `countHint` keys.