polystore 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/readme.md +135 -17
- package/src/index.d.ts +12 -8
- package/src/index.js +211 -96
- package/src/index.test.js +87 -17
- package/src/index.types.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polystore",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A small compatibility layer for many popular KV stores like localStorage, Redis, FileSystem, etc.",
|
|
5
5
|
"homepage": "https://github.com/franciscop/polystore",
|
|
6
6
|
"repository": "https://github.com/franciscop/polystore.git",
|
package/readme.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Polystore [](https://www.npmjs.com/package/polystore) [](https://github.com/franciscop/polystore/blob/master/.github/workflows/tests.yml) [](https://www.npmjs.com/package/polystore) [](https://github.com/franciscop/polystore/blob/master/.github/workflows/tests.yml) [](https://github.com/franciscop/polystore/blob/master/src/index.js)
|
|
2
2
|
|
|
3
3
|
A small compatibility layer for many KV stores like localStorage, Redis, FileSystem, etc:
|
|
4
4
|
|
|
@@ -14,9 +14,12 @@ This is the [API](#api) with all of the methods (they are all `async`):
|
|
|
14
14
|
|
|
15
15
|
- `.get(key): any`: retrieve a single value, or `null` if it doesn't exist or is expired.
|
|
16
16
|
- `.set(key, value, options?)`: save a single value, which can be anything that is serializable.
|
|
17
|
+
- `.add(value, options?)`: same as with `.set()`, but auto-generate the key.
|
|
17
18
|
- `.has(key): boolean`: check whether the key is in the store or not.
|
|
18
19
|
- `.del(key)`: delete a single value from the store.
|
|
19
20
|
- `.keys(prefix?): string[]`: get a list of all the available strings in the store.
|
|
21
|
+
- `.values(prefix?): any[]`: get a list of all the values in the store.
|
|
22
|
+
- `.entries(prefix?): [string, any][]`: get a list of all the key-value pairs in the store.
|
|
20
23
|
- `.clear()`: delete ALL of the data in the store, effectively resetting it.
|
|
21
24
|
- `.close()`: (only _some_ stores) ends the connection to the store.
|
|
22
25
|
|
|
@@ -91,7 +94,7 @@ If the value is returned, it can be a simple type like `boolean`, `string` or `n
|
|
|
91
94
|
|
|
92
95
|
### .set()
|
|
93
96
|
|
|
94
|
-
Create or update a value in the store. Will return a promise that resolves when the value has been saved. The value needs to be serializable:
|
|
97
|
+
Create or update a value in the store. Will return a promise that resolves with the key when the value has been saved. The value needs to be serializable:
|
|
95
98
|
|
|
96
99
|
```js
|
|
97
100
|
await store.set(key: string, value: any, options?: { expires: number|string });
|
|
@@ -109,29 +112,53 @@ The value can be a simple type like `boolean`, `string` or `number`, or it can b
|
|
|
109
112
|
|
|
110
113
|
#### Expires
|
|
111
114
|
|
|
112
|
-
When the `expires` option is set, it can be a number (
|
|
115
|
+
When the `expires` option is set, it can be a number (**seconds**) or a string representing some time:
|
|
113
116
|
|
|
114
117
|
```js
|
|
115
118
|
// Valid "expire" values:
|
|
116
119
|
0 - expire immediately (AKA delete it)
|
|
117
|
-
|
|
118
|
-
60 * 60
|
|
119
|
-
|
|
120
|
+
0.1 - expire after 100ms*
|
|
121
|
+
60 * 60 - expire after 1h
|
|
122
|
+
3_600 - expire after 1h
|
|
120
123
|
"10s" - expire after 10 seconds
|
|
121
124
|
"2minutes" - expire after 2 minutes
|
|
122
125
|
"5d" - expire after 5 days
|
|
123
126
|
```
|
|
124
127
|
|
|
128
|
+
\* not all stores support sub-second expirations, notably Redis and Cookies don't, so it's safer to always use an integer or an amount larger than 1s
|
|
129
|
+
|
|
125
130
|
These are all the units available:
|
|
126
131
|
|
|
127
132
|
> "ms", "millisecond", "s", "sec", "second", "m", "min", "minute", "h", "hr", "hour", "d", "day", "w", "wk", "week", "b" (month), "month", "y", "yr", "year"
|
|
128
133
|
|
|
134
|
+
### .add()
|
|
135
|
+
|
|
136
|
+
Create a value in the store with a random key string. Will return a promise that resolves with the key when the value has been saved. The value needs to be serializable:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const key:string = await store.add(value: any, options?: { expires: number|string });
|
|
140
|
+
|
|
141
|
+
const key1 = await store.add("Hello World");
|
|
142
|
+
const key2 = await store.add(["my", "grocery", "list"], { expires: "1h" });
|
|
143
|
+
const key3 = await store.add({ name: "Francisco" }, { expires: 60 * 60 * 1000 });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The generated key is 24 AlphaNumeric characters (including upper and lower case) generated with random cryptography to make sure it's unguessable, high entropy and safe to use in most contexts like URLs, queries, etc. We use [`nanoid`](https://github.com/ai/nanoid/) with a custom dictionary, so you can check the entropy [in this dictionary](https://zelark.github.io/nano-id-cc/) by removing the "\_" and "-", and setting it to 24 characters.
|
|
147
|
+
|
|
148
|
+
Here is the safety: "If you generate 1 million keys/second, it will take ~14 million years in order to have a 1% probability of at least one collision."
|
|
149
|
+
|
|
150
|
+
> Note: please make sure to read the [`.set()`](#set) section for all the details, since `.set()` and `.add()` behave the same way except for the first argument.
|
|
151
|
+
|
|
129
152
|
### .has()
|
|
130
153
|
|
|
131
154
|
Check whether the key is available in the store and not expired:
|
|
132
155
|
|
|
133
156
|
```js
|
|
134
157
|
await store.has(key: string);
|
|
158
|
+
|
|
159
|
+
if (await store.has('cookie-consent')) {
|
|
160
|
+
loadCookies();
|
|
161
|
+
}
|
|
135
162
|
```
|
|
136
163
|
|
|
137
164
|
### .del()
|
|
@@ -150,6 +177,45 @@ Get all of the keys in the store, optionally filtered by a prefix:
|
|
|
150
177
|
await store.keys(filter?: string);
|
|
151
178
|
```
|
|
152
179
|
|
|
180
|
+
> We ensure that all of the keys returned by this method are _not_ expired, while discarding any potentially expired key. See [**expiration explained**](#expiration-explained) for more details.
|
|
181
|
+
|
|
182
|
+
### .values()
|
|
183
|
+
|
|
184
|
+
Get all of the values in the store, optionally filtered by a **key** prefix:
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
await store.values(filter?: string);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
This is useful specially when you already have the id/key within the value as an object, then you can just get a list of all of them:
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
const sessions = await store.values("session:");
|
|
194
|
+
// A list of all the sessions
|
|
195
|
+
|
|
196
|
+
const companies = await store.values("company:");
|
|
197
|
+
// A list of all the companies
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
> We ensure that all of the values returned by this method are _not_ expired, while discarding any potentially expired key. See [**expiration explained**](#expiration-explained) for more details.
|
|
201
|
+
|
|
202
|
+
### .entries()
|
|
203
|
+
|
|
204
|
+
Get all of the entries (key:value tuples) in the store, optionally filtered by a **key** prefix:
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
await store.entries(filter?: string);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
It is in a format that you can easily build an object out of it:
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
const sessionEntries = await store.entries("session:");
|
|
214
|
+
const sessions = Object.fromEntries(sessionEntries);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
> We ensure that all of the entries returned by this method are _not_ expired, while discarding any potentially expired key. See [**expiration explained**](#expiration-explained) for more details.
|
|
218
|
+
|
|
153
219
|
### .clear()
|
|
154
220
|
|
|
155
221
|
Remove all of the data from the store:
|
|
@@ -164,26 +230,26 @@ Create a sub-store where all the operations use the given prefix:
|
|
|
164
230
|
|
|
165
231
|
```js
|
|
166
232
|
const store = kv(new Map());
|
|
167
|
-
const
|
|
233
|
+
const session = store.prefix("session:");
|
|
168
234
|
```
|
|
169
235
|
|
|
170
236
|
Then all of the operations will be converted internally to add the prefix when reading, writing, etc:
|
|
171
237
|
|
|
172
238
|
```js
|
|
173
|
-
const val = await
|
|
174
|
-
await
|
|
175
|
-
const val = await
|
|
176
|
-
await
|
|
177
|
-
await
|
|
239
|
+
const val = await session.get("key1"); // .get('session:key1');
|
|
240
|
+
await session.set("key2", "some data"); // .set('session:key2', ...);
|
|
241
|
+
const val = await session.has("key3"); // .has('session:key3');
|
|
242
|
+
await session.del("key4"); // .del('session:key4');
|
|
243
|
+
await session.keys(); // .keys('session:');
|
|
178
244
|
// ['key1', 'key2', ...] Note no prefix here
|
|
179
|
-
await
|
|
245
|
+
await session.clear(); // delete only keys with the prefix
|
|
180
246
|
```
|
|
181
247
|
|
|
182
248
|
This will probably never be stable given the nature of some engines, so as an alternative please consider using two stores instead of prefixes:
|
|
183
249
|
|
|
184
250
|
```js
|
|
185
251
|
const store = kv(new Map());
|
|
186
|
-
const
|
|
252
|
+
const session = kv(new Map());
|
|
187
253
|
```
|
|
188
254
|
|
|
189
255
|
The main reason this is not stable is because [_some_ store engines don't allow for atomic deletion of keys given a prefix](https://stackoverflow.com/q/4006324/938236). While we do still clear them internally in those cases, that is a non-atomic operation and it could have some trouble if some other thread is reading/writing the data _at the same time_.
|
|
@@ -251,7 +317,7 @@ console.log(await store.get("key1"));
|
|
|
251
317
|
|
|
252
318
|
It is fairly limited for how powerful cookies are, but in exchange it has the same API as any other method or KV store. It works with browser-side Cookies (no http-only).
|
|
253
319
|
|
|
254
|
-
> Note: the cookie expire resolution is in the seconds
|
|
320
|
+
> Note: the cookie expire resolution is in the seconds, so times shorter than 1 second like `expires: 0.02` (20 ms) don't make sense for this storage method and won't properly save them.
|
|
255
321
|
|
|
256
322
|
### Local Forage
|
|
257
323
|
|
|
@@ -280,7 +346,7 @@ await store.set("key1", "Hello world");
|
|
|
280
346
|
console.log(await store.get("key1"));
|
|
281
347
|
```
|
|
282
348
|
|
|
283
|
-
> Note: the Redis client expire resolution is in the seconds
|
|
349
|
+
> Note: the Redis client expire resolution is in the seconds, so times shorter than 1 second like `expires: 0.02` (20 ms) don't make sense for this storage method and won't properly save them.
|
|
284
350
|
|
|
285
351
|
### FS File
|
|
286
352
|
|
|
@@ -317,7 +383,7 @@ export default {
|
|
|
317
383
|
};
|
|
318
384
|
```
|
|
319
385
|
|
|
320
|
-
The Cloudflare native KV store only accepts strings and has you manually calculating timeouts, but as usual with `polystore` you can set/get any serializable value and set the timeout in a familiar format:
|
|
386
|
+
Why? The Cloudflare native KV store only accepts strings and has you manually calculating timeouts, but as usual with `polystore` you can set/get any serializable value and set the timeout in a familiar format:
|
|
321
387
|
|
|
322
388
|
```js
|
|
323
389
|
// GOOD - with polystore
|
|
@@ -330,3 +396,55 @@ await env.YOUR_KV_NAMESPACE.put("user", serialValue, {
|
|
|
330
396
|
expirationTtl: twoDaysInSeconds,
|
|
331
397
|
});
|
|
332
398
|
```
|
|
399
|
+
|
|
400
|
+
## Expiration explained
|
|
401
|
+
|
|
402
|
+
While different engines do expiration slightly differently internally, in creating polystore we want to ensure certain constrains, which _can_ affect performance. For example, if you do this operation:
|
|
403
|
+
|
|
404
|
+
```js
|
|
405
|
+
// in-memory store
|
|
406
|
+
const store = polystore(new Map());
|
|
407
|
+
await store.set("a", "b", { expires: "1s" });
|
|
408
|
+
|
|
409
|
+
// These checks of course work:
|
|
410
|
+
console.log(await store.keys()); // ['a']
|
|
411
|
+
console.log(await store.has("a")); // true
|
|
412
|
+
console.log(await store.get("a")); // 'b'
|
|
413
|
+
|
|
414
|
+
// Make sure the key is expired
|
|
415
|
+
await delay(2000); // 2s
|
|
416
|
+
|
|
417
|
+
// Not only the .get() is null, but `.has()` returns false, and .keys() ignores it
|
|
418
|
+
console.log(await store.keys()); // []
|
|
419
|
+
console.log(await store.has("a")); // false
|
|
420
|
+
console.log(await store.get("a")); // null
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
This is great because with polystore we do ensure that if a key has expired, it doesn't show up in `.keys()`, `.entries()`, `.values()`, `.has()` or `.get()`.
|
|
424
|
+
|
|
425
|
+
However, in some stores this does come with some potential performance disadvantages. For example, both the in-memory example above and localStorage _don't_ have a native expiration/eviction process, so we have to store that information as metadata, meaning that even to check if a key exists we need to read and decode its value. For one or few keys it's not a problem, but for large sets this can become an issue.
|
|
426
|
+
|
|
427
|
+
For other stores like Redis this is not a problem, because the low-level operations already do them natively, so we don't need to worry about this for performance at the user-level. Instead, Redis and cookies have the problem that they only have expiration resolution at the second level. Meaning that 800ms is not a valid Redis expiration time, it has to be 1s, 2s, etc.
|
|
428
|
+
|
|
429
|
+
## Creating a store
|
|
430
|
+
|
|
431
|
+
A store needs at least 4 methods with these signatures:
|
|
432
|
+
|
|
433
|
+
```js
|
|
434
|
+
const store = {};
|
|
435
|
+
store.get = (key: string) => Promise<any>;
|
|
436
|
+
store.set = (key: string, value: any, { expires: number }) => Promise<string>;
|
|
437
|
+
store.entries = (prefix: string = "") => Promise<[key:string, value:any][]>;
|
|
438
|
+
store.clear = () => Promise<null>;
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
All of the other methods will be implemented on top of these if not available, but you can provide those as well for optimizations, incompatible APIs, etc. For example, `.set('a', null)` _should_ delete the key `a`, and for this you may provide a native implementation:
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
const native = myNativeStore();
|
|
445
|
+
|
|
446
|
+
const store = {};
|
|
447
|
+
store.get = (key) => native.getItem(key);
|
|
448
|
+
// ...
|
|
449
|
+
store.del = (key) => native.deleteItem(key);
|
|
450
|
+
```
|
package/src/index.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
type Key = string;
|
|
2
|
+
type Options = { expires?: number | string | null };
|
|
3
|
+
|
|
1
4
|
type Store = {
|
|
2
|
-
get: (key:
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
) => Promise<null>;
|
|
8
|
-
has: (key: string) => Promise<boolean>;
|
|
9
|
-
del: (key: string) => Promise<null>;
|
|
5
|
+
get: (key: Key) => Promise<any>;
|
|
6
|
+
add: (value: any, options?: Options) => Promise<Key>;
|
|
7
|
+
set: (key: Key, value: any, options?: Options) => Promise<Key>;
|
|
8
|
+
has: (key: Key) => Promise<boolean>;
|
|
9
|
+
del: (key: Key) => Promise<null>;
|
|
10
10
|
|
|
11
11
|
keys: (prefix?: string) => Promise<string[]>;
|
|
12
|
+
values: (prefix?: string) => Promise<any[]>;
|
|
13
|
+
entries: (prefix?: string) => Promise<[key: string, value: any][]>;
|
|
14
|
+
|
|
12
15
|
clear: () => Promise<null>;
|
|
16
|
+
close?: () => Promise<null>;
|
|
13
17
|
};
|
|
14
18
|
|
|
15
19
|
export default function (store?: any): Store;
|
package/src/index.js
CHANGED
|
@@ -2,8 +2,8 @@ const layers = {};
|
|
|
2
2
|
|
|
3
3
|
const times = /(-?(?:\d+\.?\d*|\d*\.?\d+)(?:e[-+]?\d+)?)\s*([\p{L}]*)/iu;
|
|
4
4
|
|
|
5
|
-
parse.millisecond = parse.ms =
|
|
6
|
-
parse.second = parse.sec = parse.s = parse[""] =
|
|
5
|
+
parse.millisecond = parse.ms = 0.001;
|
|
6
|
+
parse.second = parse.sec = parse.s = parse[""] = 1;
|
|
7
7
|
parse.minute = parse.min = parse.m = parse.s * 60;
|
|
8
8
|
parse.hour = parse.hr = parse.h = parse.m * 60;
|
|
9
9
|
parse.day = parse.d = parse.h * 24;
|
|
@@ -22,17 +22,53 @@ function parse(str) {
|
|
|
22
22
|
const unitValue = parse[units] || parse[units.replace(/s$/, "")];
|
|
23
23
|
if (!unitValue) return null;
|
|
24
24
|
const result = unitValue * parseFloat(value, 10);
|
|
25
|
-
return Math.abs(Math.round(result));
|
|
25
|
+
return Math.abs(Math.round(result * 1000) / 1000);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// "nanoid" imported manually
|
|
29
|
+
// Something about improved GZIP performance with this string
|
|
30
|
+
const urlAlphabet =
|
|
31
|
+
"useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict";
|
|
32
|
+
|
|
33
|
+
export let random = (bytes) => crypto.getRandomValues(new Uint8Array(bytes));
|
|
34
|
+
|
|
35
|
+
function generateId() {
|
|
36
|
+
let size = 24;
|
|
37
|
+
let id = "";
|
|
38
|
+
let bytes = crypto.getRandomValues(new Uint8Array(size));
|
|
39
|
+
while (size--) {
|
|
40
|
+
// Using the bitwise AND operator to "cap" the value of
|
|
41
|
+
// the random byte from 255 to 63, in that way we can make sure
|
|
42
|
+
// that the value will be a valid index for the "chars" string.
|
|
43
|
+
id += urlAlphabet[bytes[size] & 61];
|
|
44
|
+
}
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
layers.extra = (store) => {
|
|
49
|
+
const add = async (value, options) => store.set(generateId(), value, options);
|
|
50
|
+
const has = async (key) => (await store.get(key)) !== null;
|
|
51
|
+
const del = async (key) => store.set(key, null);
|
|
52
|
+
const keys = async (prefix = "") => {
|
|
53
|
+
const all = await store.entries(prefix);
|
|
54
|
+
return all.map((p) => p[0]);
|
|
55
|
+
};
|
|
56
|
+
const values = async (prefix = "") => {
|
|
57
|
+
const all = await store.entries(prefix);
|
|
58
|
+
return all.map((p) => p[1]);
|
|
59
|
+
};
|
|
60
|
+
return { add, has, del, keys, values, ...store };
|
|
61
|
+
};
|
|
62
|
+
|
|
28
63
|
// Adds an expiration layer to those stores that don't have it;
|
|
29
64
|
// it's not perfect since it's not deleted until it's read, but
|
|
30
65
|
// hey it's better than nothing
|
|
31
66
|
layers.expire = (store) => {
|
|
32
67
|
// Item methods
|
|
33
68
|
const get = async (key) => {
|
|
34
|
-
|
|
35
|
-
|
|
69
|
+
const data = await store.get(key);
|
|
70
|
+
if (!data) return null;
|
|
71
|
+
const { value, expire } = data;
|
|
36
72
|
// It never expires
|
|
37
73
|
if (expire === null) return value;
|
|
38
74
|
const diff = expire - new Date().getTime();
|
|
@@ -42,122 +78,182 @@ layers.expire = (store) => {
|
|
|
42
78
|
const set = async (key, value, { expire, expires } = {}) => {
|
|
43
79
|
const time = parse(expire || expires);
|
|
44
80
|
// Already expired, or do _not_ save it, then delete it
|
|
45
|
-
if (value === null || time === 0) return
|
|
46
|
-
const expDiff = time !== null ? new Date().getTime() + time : null;
|
|
81
|
+
if (value === null || time === 0) return store.set(key, null);
|
|
82
|
+
const expDiff = time !== null ? new Date().getTime() + time * 1000 : null;
|
|
47
83
|
return store.set(key, { expire: expDiff, value });
|
|
48
84
|
};
|
|
49
|
-
const has = async (key) => (await store.get(key)) !== null;
|
|
50
|
-
const del = store.del;
|
|
51
85
|
|
|
52
86
|
// Group methods
|
|
53
|
-
const
|
|
54
|
-
|
|
87
|
+
const entries = async (prefix = "") => {
|
|
88
|
+
const all = await store.entries(prefix);
|
|
89
|
+
const now = new Date().getTime();
|
|
90
|
+
return all
|
|
91
|
+
.filter(([, data]) => {
|
|
92
|
+
// There's no data, so remove this
|
|
93
|
+
if (!data || data === null) return false;
|
|
94
|
+
|
|
95
|
+
// It never expires, so keep it
|
|
96
|
+
const { expire } = data;
|
|
97
|
+
if (expire === null) return true;
|
|
98
|
+
|
|
99
|
+
// It's expired, so remove it
|
|
100
|
+
if (expire - now <= 0) return false;
|
|
101
|
+
|
|
102
|
+
// It's not expired, keep it
|
|
103
|
+
return true;
|
|
104
|
+
})
|
|
105
|
+
.map(([key, data]) => [key, data.value]);
|
|
106
|
+
};
|
|
55
107
|
|
|
56
|
-
|
|
108
|
+
// We want to force overwrite here!
|
|
109
|
+
return { ...store, get, set, entries };
|
|
57
110
|
};
|
|
58
111
|
|
|
59
112
|
layers.memory = (store) => {
|
|
60
113
|
// Item methods
|
|
61
114
|
const get = async (key) => store.get(key) ?? null;
|
|
62
|
-
const set = async (key, data) =>
|
|
63
|
-
|
|
64
|
-
|
|
115
|
+
const set = async (key, data) => {
|
|
116
|
+
if (data === null) {
|
|
117
|
+
await store.delete(key);
|
|
118
|
+
} else {
|
|
119
|
+
await store.set(key, data);
|
|
120
|
+
}
|
|
121
|
+
return key;
|
|
122
|
+
};
|
|
65
123
|
|
|
66
124
|
// Group methods
|
|
67
|
-
const
|
|
68
|
-
[...
|
|
125
|
+
const entries = async (prefix = "") => {
|
|
126
|
+
const entries = [...store.entries()];
|
|
127
|
+
return entries.filter((p) => p[0].startsWith(prefix));
|
|
128
|
+
};
|
|
69
129
|
const clear = () => store.clear();
|
|
70
130
|
|
|
71
|
-
return { get, set,
|
|
131
|
+
return { get, set, entries, clear };
|
|
72
132
|
};
|
|
73
133
|
|
|
74
134
|
layers.storage = (store) => {
|
|
75
135
|
// Item methods
|
|
76
136
|
const get = async (key) => (store[key] ? JSON.parse(store[key]) : null);
|
|
77
|
-
const set = async (key, data) =>
|
|
78
|
-
|
|
79
|
-
|
|
137
|
+
const set = async (key, data) => {
|
|
138
|
+
if (data === null) {
|
|
139
|
+
await store.removeItem(key);
|
|
140
|
+
} else {
|
|
141
|
+
await store.setItem(key, JSON.stringify(data));
|
|
142
|
+
}
|
|
143
|
+
return key;
|
|
144
|
+
};
|
|
80
145
|
|
|
81
146
|
// Group methods
|
|
82
|
-
const
|
|
83
|
-
Object.
|
|
147
|
+
const entries = async (prefix = "") => {
|
|
148
|
+
const entries = Object.entries(store);
|
|
149
|
+
return entries
|
|
150
|
+
.map((p) => [p[0], p[1] ? JSON.parse(p[1]) : null])
|
|
151
|
+
.filter((p) => p[0].startsWith(prefix));
|
|
152
|
+
};
|
|
84
153
|
const clear = () => store.clear();
|
|
85
154
|
|
|
86
|
-
return { get, set,
|
|
155
|
+
return { get, set, entries, clear };
|
|
87
156
|
};
|
|
88
157
|
|
|
158
|
+
// Cookies auto-expire, so we cannot do expiration checks manually
|
|
89
159
|
layers.cookie = () => {
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
160
|
+
const getAll = () => {
|
|
161
|
+
const all = {};
|
|
162
|
+
for (let entry of document.cookie
|
|
163
|
+
.split(";")
|
|
164
|
+
.map((k) => k.trim())
|
|
165
|
+
.filter(Boolean)) {
|
|
166
|
+
const [key, data] = entry.split("=");
|
|
167
|
+
try {
|
|
168
|
+
all[key.trim()] = JSON.parse(decodeURIComponent(data.trim()));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// no-op (some 3rd party can set cookies independently)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return all;
|
|
98
174
|
};
|
|
99
175
|
|
|
176
|
+
const get = async (key) => getAll()[key] ?? null;
|
|
177
|
+
|
|
100
178
|
const set = async (key, data, { expire, expires } = {}) => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
179
|
+
if (data === null) {
|
|
180
|
+
await set(key, "", { expire: -100 });
|
|
181
|
+
} else {
|
|
182
|
+
const time = parse(expire || expires);
|
|
183
|
+
const now = new Date().getTime();
|
|
184
|
+
// NOTE: 0 is already considered here!
|
|
185
|
+
const expireStr =
|
|
186
|
+
time !== null
|
|
187
|
+
? `; expires=${new Date(now + time * 1000).toUTCString()}`
|
|
188
|
+
: "";
|
|
189
|
+
const value = encodeURIComponent(JSON.stringify(data));
|
|
190
|
+
document.cookie = key + "=" + value + expireStr;
|
|
191
|
+
}
|
|
192
|
+
return key;
|
|
108
193
|
};
|
|
109
|
-
const has = async (key) => (await keys()).includes(key);
|
|
110
|
-
const del = async (key) => set(key, "", { expire: -100 });
|
|
111
194
|
|
|
112
195
|
// Group methods
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.filter((k) => k.startsWith(prefix));
|
|
196
|
+
const entries = async (prefix = "") => {
|
|
197
|
+
const all = Object.entries(getAll());
|
|
198
|
+
return all.filter((p) => p[0].startsWith(prefix));
|
|
199
|
+
};
|
|
200
|
+
|
|
119
201
|
const clear = async () => {
|
|
120
|
-
|
|
202
|
+
const keys = Object.keys(getAll());
|
|
203
|
+
await Promise.all(keys.map((key) => set(key, null)));
|
|
121
204
|
};
|
|
122
205
|
|
|
123
|
-
return { get, set,
|
|
206
|
+
return { get, set, entries, clear };
|
|
124
207
|
};
|
|
125
208
|
|
|
209
|
+
// Plain 'redis' and not ioredis or similar
|
|
126
210
|
layers.redis = (store) => {
|
|
127
211
|
const get = async (key) => {
|
|
128
|
-
const
|
|
129
|
-
const value = await client.get(key);
|
|
212
|
+
const value = await store.get(key);
|
|
130
213
|
if (!value) return null;
|
|
131
214
|
return JSON.parse(value);
|
|
132
215
|
};
|
|
133
216
|
const set = async (key, value, { expire, expires } = {}) => {
|
|
134
217
|
const time = parse(expire || expires);
|
|
135
218
|
if (value === null || time === 0) return del(key);
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
return
|
|
219
|
+
const EX = time ? Math.round(time) : undefined;
|
|
220
|
+
await store.set(key, JSON.stringify(value), { EX });
|
|
221
|
+
return key;
|
|
139
222
|
};
|
|
140
|
-
const has = async (key) => Boolean(await
|
|
141
|
-
const del = async (key) =>
|
|
142
|
-
|
|
143
|
-
const keys = async (prefix = "") =>
|
|
144
|
-
const
|
|
145
|
-
|
|
223
|
+
const has = async (key) => Boolean(await store.exists(key));
|
|
224
|
+
const del = async (key) => store.del(key);
|
|
225
|
+
|
|
226
|
+
const keys = async (prefix = "") => store.keys(prefix + "*");
|
|
227
|
+
const entries = async (prefix = "") => {
|
|
228
|
+
const keys = await store.keys(prefix + "*");
|
|
229
|
+
const values = await Promise.all(keys.map((k) => get(k)));
|
|
230
|
+
return keys.map((k, i) => [k, values[i]]);
|
|
231
|
+
};
|
|
232
|
+
const clear = async () => store.flushAll();
|
|
233
|
+
const close = async () => store.quit();
|
|
146
234
|
|
|
147
|
-
return { get, set, has, del, keys, clear, close };
|
|
235
|
+
return { get, set, has, del, keys, entries, clear, close };
|
|
148
236
|
};
|
|
149
237
|
|
|
150
238
|
layers.localForage = (store) => {
|
|
151
|
-
const get = (key) => store.getItem(key);
|
|
152
|
-
const set = (key, value) =>
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
239
|
+
const get = async (key) => store.getItem(key);
|
|
240
|
+
const set = async (key, value) => {
|
|
241
|
+
if (value === null) {
|
|
242
|
+
await store.removeItem(key);
|
|
243
|
+
} else {
|
|
244
|
+
await store.setItem(key, value);
|
|
245
|
+
}
|
|
246
|
+
return key;
|
|
247
|
+
};
|
|
248
|
+
const entries = async (prefix = "") => {
|
|
249
|
+
const all = await store.keys();
|
|
250
|
+
const keys = all.filter((k) => k.startsWith(prefix));
|
|
251
|
+
const values = await Promise.all(keys.map((key) => store.getItem(key)));
|
|
252
|
+
return keys.map((key, i) => [key, values[i]]);
|
|
253
|
+
};
|
|
254
|
+
const clear = async () => store.clear();
|
|
159
255
|
|
|
160
|
-
return { get, set,
|
|
256
|
+
return { get, set, entries, clear };
|
|
161
257
|
};
|
|
162
258
|
|
|
163
259
|
layers.cloudflare = (store) => {
|
|
@@ -170,14 +266,25 @@ layers.cloudflare = (store) => {
|
|
|
170
266
|
const time = parse(expire || expires);
|
|
171
267
|
if (value === null || time === 0) return del(key);
|
|
172
268
|
const client = await store;
|
|
173
|
-
const expirationTtl = time ? Math.round(time
|
|
174
|
-
|
|
269
|
+
const expirationTtl = time ? Math.round(time) : undefined;
|
|
270
|
+
client.put(key, JSON.stringify(value), { expirationTtl });
|
|
271
|
+
return key;
|
|
175
272
|
};
|
|
176
273
|
const has = async (key) => Boolean(await store.get(key));
|
|
177
274
|
const del = (key) => store.delete(key);
|
|
178
|
-
|
|
275
|
+
|
|
276
|
+
// Group methods
|
|
277
|
+
const keys = async (prefix = "") => {
|
|
278
|
+
const raw = await store.list({ prefix });
|
|
279
|
+
return raw.keys;
|
|
280
|
+
};
|
|
281
|
+
const entries = async (prefix = "") => {
|
|
282
|
+
const all = await keys(prefix);
|
|
283
|
+
const values = await Promise.all(all.map((k) => get(k)));
|
|
284
|
+
return all.map((key, i) => [key, values[i]]);
|
|
285
|
+
};
|
|
179
286
|
const clear = () => {};
|
|
180
|
-
return { get, set, has, del, keys, clear };
|
|
287
|
+
return { get, set, has, del, entries, keys, clear };
|
|
181
288
|
};
|
|
182
289
|
|
|
183
290
|
layers.file = (file) => {
|
|
@@ -209,57 +316,60 @@ layers.file = (file) => {
|
|
|
209
316
|
};
|
|
210
317
|
const set = async (key, value) => {
|
|
211
318
|
const data = await getContent();
|
|
212
|
-
|
|
319
|
+
if (value === null) {
|
|
320
|
+
delete data[key];
|
|
321
|
+
} else {
|
|
322
|
+
data[key] = value;
|
|
323
|
+
}
|
|
213
324
|
await setContent(data);
|
|
325
|
+
return key;
|
|
214
326
|
};
|
|
215
327
|
const has = async (key) => (await get(key)) !== null;
|
|
216
|
-
const del = async (key) =>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
};
|
|
221
|
-
const keys = async (prefix = "") => {
|
|
328
|
+
const del = async (key) => set(key, null);
|
|
329
|
+
|
|
330
|
+
// Group methods
|
|
331
|
+
const entries = async (prefix = "") => {
|
|
222
332
|
const data = await getContent();
|
|
223
|
-
return Object.
|
|
333
|
+
return Object.entries(data).filter((p) => p[0].startsWith(prefix));
|
|
224
334
|
};
|
|
225
335
|
const clear = async () => {
|
|
226
336
|
await setContent({});
|
|
227
337
|
};
|
|
228
|
-
return { get, set, has, del,
|
|
338
|
+
return { get, set, has, del, entries, clear };
|
|
229
339
|
};
|
|
230
340
|
|
|
231
341
|
const getStore = async (store) => {
|
|
232
342
|
// Convert it to the normalized kv, then add the expiry layer on top
|
|
233
343
|
if (store instanceof Map) {
|
|
234
|
-
return layers.expire(layers.memory(store));
|
|
344
|
+
return layers.extra(layers.expire(layers.memory(store)));
|
|
235
345
|
}
|
|
236
346
|
|
|
237
347
|
if (typeof localStorage !== "undefined" && store === localStorage) {
|
|
238
|
-
return layers.expire(layers.storage(store));
|
|
348
|
+
return layers.extra(layers.expire(layers.storage(store)));
|
|
239
349
|
}
|
|
240
350
|
|
|
241
351
|
if (typeof sessionStorage !== "undefined" && store === sessionStorage) {
|
|
242
|
-
return layers.expire(layers.storage(store));
|
|
352
|
+
return layers.extra(layers.expire(layers.storage(store)));
|
|
243
353
|
}
|
|
244
354
|
|
|
245
355
|
if (store === "cookie") {
|
|
246
|
-
return layers.cookie();
|
|
356
|
+
return layers.extra(layers.cookie());
|
|
247
357
|
}
|
|
248
358
|
|
|
249
359
|
if (store.defineDriver && store.dropInstance && store.INDEXEDDB) {
|
|
250
|
-
return layers.expire(layers.localForage(store));
|
|
360
|
+
return layers.extra(layers.expire(layers.localForage(store)));
|
|
251
361
|
}
|
|
252
362
|
|
|
253
363
|
if (store.protocol && store.protocol === "file:") {
|
|
254
|
-
return layers.expire(layers.file(store));
|
|
364
|
+
return layers.extra(layers.expire(layers.file(store)));
|
|
255
365
|
}
|
|
256
366
|
|
|
257
367
|
if (store.pSubscribe && store.sSubscribe) {
|
|
258
|
-
return layers.redis(store);
|
|
368
|
+
return layers.extra(layers.redis(store));
|
|
259
369
|
}
|
|
260
370
|
|
|
261
371
|
if (store?.constructor?.name === "KvNamespace") {
|
|
262
|
-
return layers.cloudflare(store);
|
|
372
|
+
return layers.extra(layers.cloudflare(store));
|
|
263
373
|
}
|
|
264
374
|
|
|
265
375
|
// ¯\_(ツ)_/¯
|
|
@@ -270,17 +380,22 @@ export default function compat(storeClient = new Map()) {
|
|
|
270
380
|
return new Proxy(
|
|
271
381
|
{},
|
|
272
382
|
{
|
|
273
|
-
get: (
|
|
383
|
+
get: (instance, key) => {
|
|
274
384
|
return async (...args) => {
|
|
275
|
-
|
|
385
|
+
// Only once, even if called twice in succession, since the
|
|
386
|
+
// second time will go straight to the await
|
|
387
|
+
if (!instance.store && !instance.promise) {
|
|
388
|
+
instance.promise = getStore(await storeClient);
|
|
389
|
+
}
|
|
390
|
+
instance.store = await instance.promise;
|
|
276
391
|
// Throw at the first chance when the store failed to init:
|
|
277
|
-
if (!store) {
|
|
392
|
+
if (!instance.store) {
|
|
278
393
|
throw new Error("Store is not valid");
|
|
279
394
|
}
|
|
280
395
|
// The store.close() is the only one allowed to be called even
|
|
281
396
|
// if it doesn't exist, since it's optional in some stores
|
|
282
|
-
if (!store[key] && key === "close") return null;
|
|
283
|
-
return store[key](...args);
|
|
397
|
+
if (!instance.store[key] && key === "close") return null;
|
|
398
|
+
return instance.store[key](...args);
|
|
284
399
|
};
|
|
285
400
|
},
|
|
286
401
|
}
|
package/src/index.test.js
CHANGED
|
@@ -46,10 +46,20 @@ for (let [name, store] of stores) {
|
|
|
46
46
|
expect(await store.has("a")).toBe(false);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
it("can add() arbitrary values", async () => {
|
|
50
|
+
const key = await store.add("b");
|
|
51
|
+
expect(typeof key).toBe("string");
|
|
52
|
+
expect(await store.get(key)).toBe("b");
|
|
53
|
+
expect(await store.has(key)).toBe(true);
|
|
54
|
+
expect(key.length).toBe(24);
|
|
55
|
+
expect(key).toMatch(/^[a-zA-Z0-9]{24}$/);
|
|
56
|
+
});
|
|
57
|
+
|
|
49
58
|
it("can store values", async () => {
|
|
50
|
-
await store.set("a", "b");
|
|
59
|
+
const key = await store.set("a", "b");
|
|
51
60
|
expect(await store.get("a")).toBe("b");
|
|
52
61
|
expect(await store.has("a")).toBe(true);
|
|
62
|
+
expect(key).toBe("a");
|
|
53
63
|
});
|
|
54
64
|
|
|
55
65
|
it("can store basic types", async () => {
|
|
@@ -62,8 +72,8 @@ for (let [name, store] of stores) {
|
|
|
62
72
|
});
|
|
63
73
|
|
|
64
74
|
it("can store arrays of JSON values", async () => {
|
|
65
|
-
await store.set("a", ["b"]);
|
|
66
|
-
expect(await store.get("a")).toEqual(["b"]);
|
|
75
|
+
await store.set("a", ["b", "c"]);
|
|
76
|
+
expect(await store.get("a")).toEqual(["b", "c"]);
|
|
67
77
|
expect(await store.has("a")).toBe(true);
|
|
68
78
|
});
|
|
69
79
|
|
|
@@ -73,22 +83,83 @@ for (let [name, store] of stores) {
|
|
|
73
83
|
expect(await store.has("a")).toBe(true);
|
|
74
84
|
});
|
|
75
85
|
|
|
76
|
-
it("can
|
|
86
|
+
it("can get the keys", async () => {
|
|
87
|
+
await store.set("a", "b");
|
|
88
|
+
await store.set("c", "d");
|
|
89
|
+
expect(await store.keys()).toEqual(["a", "c"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("can get the values", async () => {
|
|
93
|
+
await store.set("a", "b");
|
|
94
|
+
await store.set("c", "d");
|
|
95
|
+
expect(await store.values()).toEqual(["b", "d"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("can get the entries", async () => {
|
|
99
|
+
await store.set("a", "b");
|
|
100
|
+
await store.set("c", "d");
|
|
101
|
+
expect(await store.entries()).toEqual([
|
|
102
|
+
["a", "b"],
|
|
103
|
+
["c", "d"],
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("can get the keys with a colon prefix", async () => {
|
|
77
108
|
await store.set("a:0", "a0");
|
|
78
109
|
await store.set("a:1", "a1");
|
|
79
110
|
await store.set("b:0", "b0");
|
|
80
|
-
await store.set("a:2", "
|
|
111
|
+
await store.set("a:2", "a2");
|
|
81
112
|
expect((await store.keys("a:")).sort()).toEqual(["a:0", "a:1", "a:2"]);
|
|
82
113
|
});
|
|
83
114
|
|
|
84
|
-
it("can
|
|
115
|
+
it("can get the values with a colon prefix", async () => {
|
|
116
|
+
await store.set("a:0", "a0");
|
|
117
|
+
await store.set("a:1", "a1");
|
|
118
|
+
await store.set("b:0", "b0");
|
|
119
|
+
await store.set("a:2", "a2");
|
|
120
|
+
expect((await store.values("a:")).sort()).toEqual(["a0", "a1", "a2"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("can get the entries with a colon prefix", async () => {
|
|
124
|
+
await store.set("a:0", "a0");
|
|
125
|
+
await store.set("a:1", "a1");
|
|
126
|
+
await store.set("b:0", "b0");
|
|
127
|
+
await store.set("a:2", "a2");
|
|
128
|
+
expect((await store.entries("a:")).sort()).toEqual([
|
|
129
|
+
["a:0", "a0"],
|
|
130
|
+
["a:1", "a1"],
|
|
131
|
+
["a:2", "a2"],
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("can get the keys with a dash prefix", async () => {
|
|
85
136
|
await store.set("a-0", "a0");
|
|
86
137
|
await store.set("a-1", "a1");
|
|
87
138
|
await store.set("b-0", "b0");
|
|
88
|
-
await store.set("a-2", "
|
|
139
|
+
await store.set("a-2", "a2");
|
|
89
140
|
expect((await store.keys("a-")).sort()).toEqual(["a-0", "a-1", "a-2"]);
|
|
90
141
|
});
|
|
91
142
|
|
|
143
|
+
it("can get the values with a dash prefix", async () => {
|
|
144
|
+
await store.set("a-0", "a0");
|
|
145
|
+
await store.set("a-1", "a1");
|
|
146
|
+
await store.set("b-0", "b0");
|
|
147
|
+
await store.set("a-2", "a2");
|
|
148
|
+
expect((await store.values("a-")).sort()).toEqual(["a0", "a1", "a2"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("can get the entries with a dash prefix", async () => {
|
|
152
|
+
await store.set("a-0", "a0");
|
|
153
|
+
await store.set("a-1", "a1");
|
|
154
|
+
await store.set("b-0", "b0");
|
|
155
|
+
await store.set("a-2", "a2");
|
|
156
|
+
expect((await store.entries("a-")).sort()).toEqual([
|
|
157
|
+
["a-0", "a0"],
|
|
158
|
+
["a-1", "a1"],
|
|
159
|
+
["a-2", "a2"],
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
92
163
|
it("can delete the data", async () => {
|
|
93
164
|
await store.set("a", "b");
|
|
94
165
|
expect(await store.get("a")).toBe("b");
|
|
@@ -96,12 +167,6 @@ for (let [name, store] of stores) {
|
|
|
96
167
|
expect(await store.get("a")).toBe(null);
|
|
97
168
|
});
|
|
98
169
|
|
|
99
|
-
it("can get the keys", async () => {
|
|
100
|
-
await store.set("a", "b");
|
|
101
|
-
await store.set("c", "d");
|
|
102
|
-
expect(await store.keys()).toEqual(["a", "c"]);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
170
|
it("can clear all the values", async () => {
|
|
106
171
|
await store.set("a", "b");
|
|
107
172
|
await store.set("c", "d");
|
|
@@ -116,6 +181,10 @@ for (let [name, store] of stores) {
|
|
|
116
181
|
it("expires = 0 means immediately", async () => {
|
|
117
182
|
await store.set("a", "b", { expires: 0 });
|
|
118
183
|
expect(await store.get("a")).toBe(null);
|
|
184
|
+
expect(await store.has("a")).toBe(false);
|
|
185
|
+
expect(await store.keys()).toEqual([]);
|
|
186
|
+
expect(await store.values()).toEqual([]);
|
|
187
|
+
expect(await store.entries()).toEqual([]);
|
|
119
188
|
});
|
|
120
189
|
|
|
121
190
|
it("expires = potato means undefined = forever", async () => {
|
|
@@ -147,8 +216,9 @@ for (let [name, store] of stores) {
|
|
|
147
216
|
});
|
|
148
217
|
|
|
149
218
|
if (name !== "kv('cookie')" && name !== "kv(redis)") {
|
|
150
|
-
it("can use
|
|
151
|
-
|
|
219
|
+
it("can use 0.01 expire", async () => {
|
|
220
|
+
// 10ms
|
|
221
|
+
await store.set("a", "b", { expires: 0.01 });
|
|
152
222
|
expect(await store.get("a")).toBe("b");
|
|
153
223
|
await delay(100);
|
|
154
224
|
expect(await store.get("a")).toBe(null);
|
|
@@ -175,8 +245,8 @@ for (let [name, store] of stores) {
|
|
|
175
245
|
expect(await store.get("a")).toBe(null);
|
|
176
246
|
});
|
|
177
247
|
} else {
|
|
178
|
-
it("can use
|
|
179
|
-
await store.set("a", "b", { expires:
|
|
248
|
+
it("can use 1 (second) expire", async () => {
|
|
249
|
+
await store.set("a", "b", { expires: 1 });
|
|
180
250
|
expect(await store.get("a")).toBe("b");
|
|
181
251
|
await delay(2000);
|
|
182
252
|
expect(await store.get("a")).toBe(null);
|
package/src/index.types.ts
CHANGED
|
@@ -8,6 +8,10 @@ const store = kv();
|
|
|
8
8
|
await store.set("key", "value", {});
|
|
9
9
|
await store.set("key", "value", { expires: 100 });
|
|
10
10
|
await store.set("key", "value", { expires: "100s" });
|
|
11
|
+
const key1: string = await store.add("value");
|
|
12
|
+
const key2: string = await store.add("value", { expires: 100 });
|
|
13
|
+
const key3: string = await store.add("value", { expires: "100s" });
|
|
14
|
+
console.log(key1, key2, key3);
|
|
11
15
|
if (await store.has("key")) {
|
|
12
16
|
}
|
|
13
17
|
})();
|