Skip to content

Commit 2a84bcc

Browse files
authored
feat!: DeleteAll match pattern and v5 adjustments (#174)
* feat: delete all match pattern * test coverage * make sure all valid chars are supported * safeguard require deleteAll (breaking change) * fix!: hide 404s on container operations (#175)
1 parent 709330b commit 2a84bcc

File tree

6 files changed

+171
-74
lines changed

6 files changed

+171
-74
lines changed

doc/api.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ Cloud State Management
6060
* *[.getRegionalEndpoint(endpoint, region)](#AdobeState+getRegionalEndpoint) ⇒ <code>string</code>*
6161
* *[.get(key)](#AdobeState+get)[<code>Promise.&lt;AdobeStateGetReturnValue&gt;</code>](#AdobeStateGetReturnValue)*
6262
* *[.put(key, value, [options])](#AdobeState+put) ⇒ <code>Promise.&lt;string&gt;</code>*
63-
* *[.delete(key)](#AdobeState+delete) ⇒ <code>Promise.&lt;string&gt;</code>*
64-
* *[.deleteAll()](#AdobeState+deleteAll) ⇒ <code>Promise.&lt;boolean&gt;</code>*
63+
* *[.delete(key)](#AdobeState+delete) ⇒ <code>Promise.&lt;(string\|null)&gt;</code>*
64+
* *[.deleteAll(options)](#AdobeState+deleteAll) ⇒ <code>Promise.&lt;{keys: number}&gt;</code>*
6565
* *[.any()](#AdobeState+any) ⇒ <code>Promise.&lt;boolean&gt;</code>*
66-
* *[.stats()](#AdobeState+stats) ⇒ <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|boolean)&gt;</code>*
66+
* *[.stats()](#AdobeState+stats) ⇒ <code>Promise.&lt;{bytesKeys: number, bytesValues: number, keys: number}&gt;</code>*
6767
* *[.list(options)](#AdobeState+list) ⇒ <code>AsyncGenerator.&lt;{keys: Array.&lt;string&gt;}&gt;</code>*
6868

6969
<a name="AdobeState+getRegionalEndpoint"></a>
@@ -108,37 +108,48 @@ Creates or updates a state key-value pair
108108

109109
<a name="AdobeState+delete"></a>
110110

111-
### *adobeState.delete(key) ⇒ <code>Promise.&lt;string&gt;</code>*
111+
### *adobeState.delete(key) ⇒ <code>Promise.&lt;(string\|null)&gt;</code>*
112112
Deletes a state key-value pair
113113

114114
**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
115-
**Returns**: <code>Promise.&lt;string&gt;</code> - key of deleted state or `null` if state does not exist
115+
**Returns**: <code>Promise.&lt;(string\|null)&gt;</code> - key of deleted state or `null` if state does not exist
116116

117117
| Param | Type | Description |
118118
| --- | --- | --- |
119119
| key | <code>string</code> | state key identifier |
120120

121121
<a name="AdobeState+deleteAll"></a>
122122

123-
### *adobeState.deleteAll() ⇒ <code>Promise.&lt;boolean&gt;</code>*
124-
Deletes all key-values
123+
### *adobeState.deleteAll(options) ⇒ <code>Promise.&lt;{keys: number}&gt;</code>*
124+
Deletes multiple key-values. The match option is required as a safeguard.
125+
CAUTION: use `{ match: '*' }` to delete all key-values.
125126

126127
**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
127-
**Returns**: <code>Promise.&lt;boolean&gt;</code> - true if deleted, false if not
128+
**Returns**: <code>Promise.&lt;{keys: number}&gt;</code> - returns an object with the number of deleted keys.
129+
130+
| Param | Type | Description |
131+
| --- | --- | --- |
132+
| options | <code>object</code> | deleteAll options. |
133+
| options.match | <code>string</code> | REQUIRED, a glob pattern to specify which keys to delete. |
134+
135+
**Example**
136+
```js
137+
await state.deleteAll({ match: 'abc*' })
138+
```
128139
<a name="AdobeState+any"></a>
129140

130141
### *adobeState.any() ⇒ <code>Promise.&lt;boolean&gt;</code>*
131-
There exists key-values.
142+
There exists key-values in the region.
132143

133144
**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
134145
**Returns**: <code>Promise.&lt;boolean&gt;</code> - true if exists, false if not
135146
<a name="AdobeState+stats"></a>
136147

137-
### *adobeState.stats() ⇒ <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|boolean)&gt;</code>*
148+
### *adobeState.stats() ⇒ <code>Promise.&lt;{bytesKeys: number, bytesValues: number, keys: number}&gt;</code>*
138149
Get stats.
139150

140151
**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
141-
**Returns**: <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|boolean)&gt;</code> - namespace stats or false if not exists
152+
**Returns**: <code>Promise.&lt;{bytesKeys: number, bytesValues: number, keys: number}&gt;</code> - State container stats.
142153
<a name="AdobeState+list"></a>
143154

144155
### *adobeState.list(options) ⇒ <code>AsyncGenerator.&lt;{keys: Array.&lt;string&gt;}&gt;</code>*

e2e/e2e.js

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const { MAX_TTL_SECONDS } = require('../lib/constants')
2222
const stateLib = require('../index')
2323
const { randomInt } = require('node:crypto')
2424

25-
const uniquePrefix = `${Date.now()}.${randomInt(10)}`
25+
const uniquePrefix = `${Date.now()}.${randomInt(100)}`
2626
const testKey = `${uniquePrefix}__e2e_test_state_key`
2727
const testKey2 = `${uniquePrefix}__e2e_test_state_key2`
2828

@@ -34,13 +34,33 @@ const initStateEnv = async (n = 1) => {
3434
process.env.__OW_API_KEY = process.env[`TEST_AUTH_${n}`]
3535
process.env.__OW_NAMESPACE = process.env[`TEST_NAMESPACE_${n}`]
3636
const state = await stateLib.init()
37-
// // make sure we cleanup the namespace, note that delete might fail as it is an op under test
38-
// await state.delete(`${uniquePrefix}*`)
39-
await state.delete(testKey)
40-
await state.delete(testKey2)
37+
// make sure we cleanup the namespace, note that delete might fail as it is an op under test
38+
await state.deleteAll({ match: `${uniquePrefix}*` })
4139
return state
4240
}
4341

42+
// helpers
43+
const genKeyStrings = (n, identifier) => {
44+
return (new Array(n).fill(0).map((_, idx) => {
45+
const char = String.fromCharCode(97 + idx % 26)
46+
// list-[a-z]-[0-(N-1)]
47+
return `${uniquePrefix}__${identifier}_${char}_${idx}`
48+
}))
49+
}
50+
const putKeys = async (state, keys, ttl) => {
51+
const _putKeys = async (keys, ttl) => {
52+
await Promise.all(keys.map(async (k, idx) => await state.put(k, `value-${idx}`, { ttl })))
53+
}
54+
55+
const batchSize = 20
56+
let i = 0
57+
while (i < keys.length - batchSize) {
58+
await _putKeys(keys.slice(i, i + batchSize), ttl)
59+
i += batchSize
60+
}
61+
// final call
62+
await _putKeys(keys.slice(i), ttl)
63+
}
4464
const waitFor = (ms) => new Promise(resolve => setTimeout(resolve, ms))
4565

4666
test('env vars', () => {
@@ -146,34 +166,12 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
146166
await expect(state.put(testKey, testValue, { ttl: -1 })).rejects.toThrow()
147167
})
148168

149-
test('listKeys test: few < 128 keys, many, and expired entries', async () => {
169+
test('listKeys test: few, many, and expired entries', async () => {
150170
const state = await initStateEnv()
151171

152-
const genKeyStrings = (n) => {
153-
return (new Array(n).fill(0).map((_, idx) => {
154-
const char = String.fromCharCode(97 + idx % 26)
155-
// list-[a-z]-[0-(N-1)]
156-
return `${uniquePrefix}__list_${char}_${idx}`
157-
}))
158-
}
159-
const putKeys = async (keys, ttl) => {
160-
const _putKeys = async (keys, ttl) => {
161-
await Promise.all(keys.map(async (k, idx) => await state.put(k, `value-${idx}`, { ttl })))
162-
}
163-
164-
const batchSize = 20
165-
let i = 0
166-
while (i < keys.length - batchSize) {
167-
await _putKeys(keys.slice(i, i + batchSize), ttl)
168-
i += batchSize
169-
}
170-
// final call
171-
await _putKeys(keys.slice(i), ttl)
172-
}
173-
174172
// 1. test with not many elements, one iteration should return all
175-
const keys90 = genKeyStrings(90).sort()
176-
await putKeys(keys90, 60)
173+
const keys90 = genKeyStrings(90, 'list').sort()
174+
await putKeys(state, keys90, 60)
177175

178176
let it, ret
179177

@@ -203,8 +201,8 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
203201
expect(await it.next()).toEqual({ done: true, value: undefined })
204202

205203
// 2. test with many elements and large countHint
206-
const keys900 = genKeyStrings(900)
207-
await putKeys(keys900, 60)
204+
const keys900 = genKeyStrings(900, 'list')
205+
await putKeys(state, keys900, 60)
208206

209207
// note: we can't list in isolation without prefix
210208
it = state.list({ countHint: 1000 })
@@ -255,7 +253,7 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
255253
expect(retArray.length).toEqual(1)
256254

257255
// 4. make sure expired keys aren't listed
258-
await putKeys(keys90, 1)
256+
await putKeys(state, keys90, 1)
259257
await waitFor(2000)
260258

261259
it = state.list({ countHint: 1000, match: `${uniquePrefix}__list_*` })
@@ -264,6 +262,23 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
264262
expect(await it.next()).toEqual({ done: true, value: undefined })
265263
})
266264

265+
test('deleteAll test', async () => {
266+
const state = await initStateEnv()
267+
268+
// < 100 keys
269+
const keys90 = genKeyStrings(90, 'deleteAll').sort()
270+
await putKeys(state, keys90, 60)
271+
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_a*` })).toEqual({ keys: 4 })
272+
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*` })).toEqual({ keys: 86 })
273+
274+
// > 1000 keys
275+
const keys1100 = genKeyStrings(1100, 'deleteAll').sort()
276+
await putKeys(state, keys1100, 60)
277+
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*_1` })).toEqual({ keys: 1 })
278+
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*_1*0` })).toEqual({ keys: 21 }) // 10, 100 - 190, 1000-1090
279+
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*` })).toEqual({ keys: 1078 })
280+
})
281+
267282
test('throw error when get/put with invalid keys', async () => {
268283
const invalidKey = 'some/invalid:key'
269284
const state = await initStateEnv()

lib/AdobeState.js

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const {
2828
MAX_LIST_COUNT_HINT,
2929
REQUEST_ID_HEADER,
3030
MIN_LIST_COUNT_HINT,
31-
REGEX_PATTERN_LIST_KEY_MATCH,
31+
REGEX_PATTERN_MATCH_KEY,
3232
MAX_TTL_SECONDS
3333
} = require('./constants')
3434

@@ -370,7 +370,7 @@ class AdobeState {
370370
* Deletes a state key-value pair
371371
*
372372
* @param {string} key state key identifier
373-
* @returns {Promise<string>} key of deleted state or `null` if state does not exist
373+
* @returns {Promise<string|null>} key of deleted state or `null` if state does not exist
374374
* @memberof AdobeState
375375
*/
376376
async delete (key) {
@@ -383,6 +383,23 @@ class AdobeState {
383383
}
384384
}
385385

386+
const schema = {
387+
type: 'object',
388+
properties: {
389+
key: {
390+
type: 'string',
391+
pattern: REGEX_PATTERN_STORE_KEY
392+
}
393+
}
394+
}
395+
const { valid, errors } = validate(schema, { key })
396+
if (!valid) {
397+
logAndThrow(new codes.ERROR_BAD_ARGUMENT({
398+
messageValues: utils.formatAjvErrors(errors),
399+
sdkDetails: { key, errors }
400+
}))
401+
}
402+
386403
logger.debug('delete', requestOptions)
387404

388405
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(`/data/${key}`), requestOptions)
@@ -395,12 +412,16 @@ class AdobeState {
395412
}
396413

397414
/**
398-
* Deletes all key-values
399-
*
400-
* @returns {Promise<boolean>} true if deleted, false if not
415+
* Deletes multiple key-values. The match option is required as a safeguard.
416+
* CAUTION: use `{ match: '*' }` to delete all key-values.
417+
* @example
418+
* await state.deleteAll({ match: 'abc*' })
419+
* @param {object} options deleteAll options.
420+
* @param {string} options.match REQUIRED, a glob pattern to specify which keys to delete.
421+
* @returns {Promise<{ keys: number }>} returns an object with the number of deleted keys.
401422
* @memberof AdobeState
402423
*/
403-
async deleteAll () {
424+
async deleteAll (options = {}) {
404425
const requestOptions = {
405426
method: 'DELETE',
406427
headers: {
@@ -410,13 +431,37 @@ class AdobeState {
410431

411432
logger.debug('deleteAll', requestOptions)
412433

413-
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions)
434+
const schema = {
435+
type: 'object',
436+
properties: {
437+
match: { type: 'string', pattern: REGEX_PATTERN_MATCH_KEY }
438+
},
439+
required: ['match'] // safeguard, you cannot call deleteAll without matching specific keys!
440+
}
441+
const { valid, errors } = validate(schema, options)
442+
if (!valid) {
443+
logAndThrow(new codes.ERROR_BAD_ARGUMENT({
444+
messageValues: utils.formatAjvErrors(errors),
445+
sdkDetails: { options, errors }
446+
}))
447+
}
448+
449+
const queryParams = { matchData: options.match }
450+
451+
// ! be extra cautious, if the `matchData` param is not specified the whole container will be deleted
452+
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl('', queryParams), requestOptions)
414453
const response = await _wrap(promise, {})
415-
return (response.status !== 404)
454+
455+
if (response.status === 404) {
456+
return { keys: 0 }
457+
} else {
458+
const { keys } = await response.json()
459+
return { keys }
460+
}
416461
}
417462

418463
/**
419-
* There exists key-values.
464+
* There exists key-values in the region.
420465
*
421466
* @returns {Promise<boolean>} true if exists, false if not
422467
* @memberof AdobeState
@@ -439,7 +484,7 @@ class AdobeState {
439484
/**
440485
* Get stats.
441486
*
442-
* @returns {Promise<{ bytesKeys: number, bytesValues: number, keys: number} | boolean>} namespace stats or false if not exists
487+
* @returns {Promise<{ bytesKeys: number, bytesValues: number, keys: number }>} State container stats.
443488
* @memberof AdobeState
444489
*/
445490
async stats () {
@@ -455,9 +500,10 @@ class AdobeState {
455500
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions)
456501
const response = await _wrap(promise, {})
457502
if (response.status === 404) {
458-
return false
503+
return { keys: 0, bytesKeys: 0, bytesValues: 0 }
459504
} else {
460-
return response.json()
505+
const { keys, bytesKeys, bytesValues } = await response.json()
506+
return { keys, bytesKeys, bytesValues }
461507
}
462508
}
463509

@@ -504,7 +550,7 @@ class AdobeState {
504550
const schema = {
505551
type: 'object',
506552
properties: {
507-
match: { type: 'string', pattern: REGEX_PATTERN_LIST_KEY_MATCH }, // this is an important check
553+
match: { type: 'string', pattern: REGEX_PATTERN_MATCH_KEY },
508554
countHint: { type: 'integer' }
509555
}
510556
}

lib/constants.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ const HEADER_KEY_EXPIRES = 'x-key-expires-ms'
4646
const REGEX_PATTERN_STORE_NAMESPACE = '^(development-)?([0-9]{3,10})-([a-z0-9]{1,20})(-([a-z0-9]{1,20}))?$'
4747
// The regex for keys, allowed chars are alphanumerical with _ - .
4848
const REGEX_PATTERN_STORE_KEY = `^[a-zA-Z0-9-_.]{1,${MAX_KEY_SIZE}}$`
49-
// The regex for list key pattern, allowed chars are alphanumerical with _ - . and * for glob matching
50-
const REGEX_PATTERN_LIST_KEY_MATCH = `^[a-zA-Z0-9-_.*]{1,${MAX_KEY_SIZE}}$`
49+
// Same as REGEX_PATTERN_STORE_KEY with an added * to support glob-style matching
50+
const REGEX_PATTERN_MATCH_KEY = `^[a-zA-Z0-9-_.*]{1,${MAX_KEY_SIZE}}$`
5151
const MAX_LIST_COUNT_HINT = 1000
5252
const MIN_LIST_COUNT_HINT = 100
5353

@@ -62,7 +62,7 @@ module.exports = {
6262
REGEX_PATTERN_STORE_NAMESPACE,
6363
REGEX_PATTERN_STORE_KEY,
6464
HEADER_KEY_EXPIRES,
65-
REGEX_PATTERN_LIST_KEY_MATCH,
65+
REGEX_PATTERN_MATCH_KEY,
6666
MAX_LIST_COUNT_HINT,
6767
MIN_LIST_COUNT_HINT,
6868
REQUEST_ID_HEADER,

0 commit comments

Comments
 (0)