polystore 0.9.3 → 0.11.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/assets/home.html CHANGED
@@ -14,7 +14,7 @@
14
14
  <a
15
15
  class="pseudo button"
16
16
  href="https://superpeer.com/francisco/-/javascript-and-react-help"
17
- >Professional JS help</a
17
+ >Get JS help</a
18
18
  >
19
19
  </div>
20
20
  </div>
@@ -77,7 +77,8 @@ console.log(await store.get(key));
77
77
  <a href="/documentation#getting-started">Getting started</a>,
78
78
  <a href="/documentation#api">API</a>,
79
79
  <a href="/documentation#clients">Clients</a> and
80
- <a href="/documentation#examples">examples</a> for your convenience.
80
+ <a href="/documentation#creating-a-store">custom stores</a> for your
81
+ convenience.
81
82
  </p>
82
83
  </div>
83
84
  </div>
@@ -152,7 +153,7 @@ console.log(await store.get(key));
152
153
  <p>
153
154
  At
154
155
  <a href="https://bundlephobia.com/package/polystore" target="_blank"
155
- >just <strong>2.4kb</strong></a
156
+ >just <strong>3kb</strong></a
156
157
  >
157
158
  (min+gzip), the impact on your app loading time is minimal.
158
159
  </p>
@@ -179,7 +180,10 @@ console.log(await store.get(key));
179
180
  </svg>
180
181
  <h3>Intuitive expirations</h3>
181
182
  </header>
182
- <p>Write the expiration as <code>100s</code>, <code>1week</code>, etc.</p>
183
+ <p>
184
+ Write the expiration as <code>100s</code>, <code>1week</code>, etc. and
185
+ forget time-related bugs.
186
+ </p>
183
187
  </div>
184
188
  </div>
185
189
  </section>
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "polystore",
3
- "version": "0.9.3",
3
+ "version": "0.11.0",
4
4
  "description": "A small compatibility layer for many popular KV stores like localStorage, Redis, FileSystem, etc.",
5
- "homepage": "https://github.com/franciscop/polystore",
5
+ "homepage": "https://polystore.dev/",
6
6
  "repository": "https://github.com/franciscop/polystore.git",
7
7
  "bugs": "https://github.com/franciscop/polystore/issues",
8
8
  "funding": "https://www.paypal.me/franciscopresencia/19",
@@ -24,11 +24,12 @@
24
24
  "value"
25
25
  ],
26
26
  "license": "MIT",
27
+ "dependencies": {},
27
28
  "devDependencies": {
28
- "check-dts": "^0.7.2",
29
+ "@deno/kv": "^0.8.1",
30
+ "check-dts": "^0.8.0",
29
31
  "dotenv": "^16.3.1",
30
32
  "edge-mock": "^0.0.15",
31
- "esbuild": "^0.19.4",
32
33
  "etcd3": "^1.1.2",
33
34
  "jest": "^29.7.0",
34
35
  "jest-environment-jsdom": "^29.7.0",
@@ -36,6 +37,17 @@
36
37
  "localforage": "^1.10.0",
37
38
  "redis": "^4.6.10"
38
39
  },
40
+ "documentation": {
41
+ "title": "🏬 Polystore - A universal library for standardizing any KV-store",
42
+ "home": "assets/home.html",
43
+ "homepage": "https://polystore.dev/",
44
+ "menu": {
45
+ "Documentation": "/documentation",
46
+ "Issues": "https://github.com/franciscop/polystore/issues",
47
+ "Get help": "https://superpeer.com/francisco/-/javascript-and-react-help",
48
+ "Github": "https://github.com/franciscop/polystore"
49
+ }
50
+ },
39
51
  "jest": {
40
52
  "testEnvironment": "jsdom",
41
53
  "setupFiles": [
package/readme.md CHANGED
@@ -36,13 +36,13 @@ Available clients for the KV store:
36
36
  - [**File** `new URL('file:///...')`](#file) (be): store the data in a single JSON file in your FS
37
37
  - [**Redis Client** `redisClient`](#redis-client) (be): use the Redis instance that you connect to
38
38
  - [**Cloudflare KV** `env.KV_NAMESPACE`](#cloudflare-kv) (be): use Cloudflare's KV store
39
- - [**Level** `new Level('example', { valueEncoding: 'json' })`](#level): support the whole Level ecosystem
40
- - [**Etcd** `new Etcd3()`](#etcd): the Microsoft's high performance KV store.
39
+ - [**Level** `new Level('example', { valueEncoding: 'json' })`](#level) (fe+be): support the whole Level ecosystem
40
+ - [**Etcd** `new Etcd3()`](#etcd) (be): the Microsoft's high performance KV store.
41
41
  - [**_Custom_** `{}`](#creating-a-store) (?): create your own store with just 3 methods!
42
42
 
43
43
  > This library should be as performant as the client you use with the item methods (GET/SET/ADD/HAS/DEL). For other and advanced cases, see [the performance considerations](#performance) and read the docs on your client.
44
44
 
45
- I made this library to be used as a "building block" of other libraries, so that _your library_ can accept many cache stores effortlessly! It's isomorphic (Node.js and the Browser) and tiny (~2KB). For example, let's say you create an API library, then you can accept the stores from your client:
45
+ I made this library to be used as a "building block" of other libraries, so that _your library_ can accept many cache stores effortlessly! It's isomorphic (Node.js, Bun and the Browser) and tiny (~2KB). For example, let's say you create an API library, then you can accept the stores from your client:
46
46
 
47
47
  ```js
48
48
  import MyApi from "my-api";
@@ -54,6 +54,38 @@ MyApi({ cache: env.KV_NAMESPACE }); // OR
54
54
  // ...
55
55
  ```
56
56
 
57
+ ## Getting started
58
+
59
+ First, install `polystore` and whatever [supported client](#clients) that you prefer. Let's see Redis as an example here:
60
+
61
+ ```
62
+ npm i polystore redis
63
+ ```
64
+
65
+ Then import both, initialize the Redis client and pass it to Polystore:
66
+
67
+ ```js
68
+ import kv from "polystore";
69
+ import { createClient } from "redis";
70
+
71
+ // Import the Redis configuration
72
+ const REDIS = process.env.REDIS_URL;
73
+
74
+ // Wrap the redis creation with Polystore (kv())
75
+ const store = kv(createClient(REDIS).connect());
76
+ ```
77
+
78
+ Now your store is ready to use! Add, set, get, del different keys. [See full API](#api).
79
+
80
+ ```js
81
+ const key = await store.add("Hello");
82
+
83
+ console.log(await store.get(key));
84
+ // Hello
85
+
86
+ await store.del(key);
87
+ ```
88
+
57
89
  ## API
58
90
 
59
91
  See how to initialize each store [in the Clients list documentation](#clients). But basically for every store, it's like this:
@@ -79,7 +111,7 @@ client.connect();
79
111
  const store = kv(client);
80
112
  ```
81
113
 
82
- While you can keep a reference to the store and access it directly, we strongly recommend if you are going to use a store, to only access it through `polystore`, since we do add custom serialization and extra properties for e.g. expiration time:
114
+ While you can keep a reference to the store and access it directly, we strongly recommend if you are going to use a store, to only access it through `polystore`, since we might add custom serialization and extra properties for e.g. expiration time:
83
115
 
84
116
  ```js
85
117
  const map = new Map();
@@ -209,6 +241,26 @@ Remove a single key from the store and return the key itself:
209
241
  await store.del(key: string);
210
242
  ```
211
243
 
244
+ It will ignore the operation if the key or value don't exist already (but won't thorw).
245
+
246
+ ### _Iterator_
247
+
248
+ You can iterate over the whole store with an async iterator:
249
+
250
+ ```js
251
+ for await (const [key, value] of store) {
252
+ // ...
253
+ }
254
+ ```
255
+
256
+ This is very useful for performance resons. You can also iterate on a subset of the entries with .prefix:
257
+
258
+ ```js
259
+ for await (const [key, value] of store.prefix("session:")) {
260
+ // ...
261
+ }
262
+ ```
263
+
212
264
  ### .keys()
213
265
 
214
266
  Get all of the keys in the store, optionally filtered by a prefix:
@@ -515,19 +567,19 @@ Please see the [creating a store](#creating-a-store) section for more details!
515
567
 
516
568
  ## Performance
517
569
 
518
- > TL;DR: if you only use the item operations (add,set,get,has,del) and your store supports expiration natively, you have nothing to worry about!
570
+ > TL;DR: if you only use the item operations (add,set,get,has,del) and your client supports expiration natively, you have nothing to worry about!
519
571
 
520
572
  While all of our stores support `expires`, `.prefix()` and group operations, the nature of those makes them to have different performance characteristics.
521
573
 
522
- **Expires** we polyfill expiration when the underlying library does not support it. The impact on read/write operations and on data size of each key should be minimal. However, it can have a big impact in storage size, since the expired keys are not evicted automatically. Note that when attempting to read an expired key, polystore **will delete that key**. However, if an expired key is never read, it would remain in the datastore and could create some old-data issues. This is **especially important where sensitive data is involved**! To fix this, the easiest way is calling `await store.entries();` on a cron job and that should evict all of the old keys (this operation is O(n) though, so not suitable for calling it on EVERY API call, see the next point).
574
+ **Expires** we polyfill expiration when the underlying client library does not support it. The impact on read/write operations and on data size of each key should be minimal. However, it can have a big impact in storage size, since the expired keys are not evicted automatically. Note that when attempting to read an expired key, polystore **will delete that key**. However, if an expired key is never read, it would remain in the datastore and could create some old-data issues. This is **especially important where sensitive data is involved**! To fix this, the easiest way is calling `await store.entries();` on a cron job and that should evict all of the old keys (this operation is O(n) though, so not suitable for calling it on EVERY API call, see the next point).
523
575
 
524
- **Group operations** these are there mostly for small datasets only, for one-off scripts or for dev purposes, since by their own nature they can _never_ be high performance. But this is normal if you think about traditional DBs, reading a single record by its ID is O(1), while reading all of the IDs in the DB into an array is going to be O(n). Same applies with polystore.
576
+ **Group operations** these are there mostly for small datasets only, for one-off scripts or for dev purposes, since by their own nature they can _never_ be high performance in the general case. But this is normal if you think about traditional DBs, reading a single record by its ID is O(1), while reading all of the IDs in the DB into an array is going to be O(n). Same applies with polystore.
525
577
 
526
578
  **Substores** when dealing with a `.prefix()` substore, the same applies. Item operations should see no performance degradation from `.prefix()`, but group operations follow the above performance considerations. Some engines might have native prefix support, so performance in those is better for group operations in a substore than the whole store. But in general you should consider `.prefix()` as a convenient way of classifying your keys and not as a performance fix for group operations.
527
579
 
528
580
  ## Expires
529
581
 
530
- > Warning: if a client doesn't support expiration natively, we will hide expired keys on the API calls for a nice DX, but _old data might not be evicted automatically_, which is relevant especially for sensitive information. You'd want to set up a cron job to evict it manually, since for large datasets it might be more expensive (O(n)).
582
+ > Warning: if a client doesn't support expiration natively, we will hide expired keys on the API calls for a nice DX, but _old data might not be evicted automatically_. See [the notes in Performance](#performance) for details on how to work around this.
531
583
 
532
584
  We unify all of the clients diverse expiration methods into a single, easy one with `expires`:
533
585
 
@@ -572,7 +624,7 @@ For these and more situations, you can use `.prefix()` to simplify your life fur
572
624
 
573
625
  ## Creating a store
574
626
 
575
- To create a store, you define a class with these methods:
627
+ To create a store, you define a class with these properties and methods:
576
628
 
577
629
  ```js
578
630
  class MyClient {
@@ -580,10 +632,10 @@ class MyClient {
580
632
  // the `.set()` and `.add()` receive a `expires` that is a `null` or `number`:
581
633
  EXPIRES = false;
582
634
 
583
- // Mandatory methods (2 item-methods, 2 group-methods)
635
+ // Mandatory methods
584
636
  get (key): Promise<any>;
585
637
  set (key, value, { expires: null|number }): Promise<null>;
586
- entries (prefix): Promise<[string, any][]>;
638
+ iterate(prefix): AyncIterator<[string, any]>
587
639
 
588
640
  // Optional item methods (for optimization or customization)
589
641
  add (prefix, data, { expires: null|number }): Promise<string>;
@@ -591,6 +643,7 @@ class MyClient {
591
643
  del (key): Promise<null>;
592
644
 
593
645
  // Optional group methods
646
+ entries (prefix): Promise<[string, any][]>;
594
647
  keys (prefix): Promise<string[]>;
595
648
  values (prefix): Promise<any[]>;
596
649
  clear (prefix): Promise<null>;
@@ -600,7 +653,7 @@ class MyClient {
600
653
  }
601
654
  ```
602
655
 
603
- Note that this is NOT the public API, it's the internal **client** API. It's simpler than the public API since we do some of the heavy lifting as an intermediate layer (e.g. the `expires` will always be a `null` or `number`, never `undefined` or a `string`), but also it differs from polystore's API, like `.add()` has a different signature, and the group methods all take a explicit prefix.
656
+ Note that this is NOT the public API, it's the internal **client** API. It's simpler than the public API since we do some of the heavy lifting as an intermediate layer (e.g. for the client, the `expires` will always be a `null` or `number`, never `undefined` or a `string`), but also it differs from polystore's public API, like `.add()` has a different signature, and the group methods all take a explicit prefix.
604
657
 
605
658
  **Expires**: if you set the `EXPIRES = true`, then you are indicating that the client WILL manage the lifecycle of the data. This includes all methods, for example if an item is expired, then its key should not be returned in `.keys()`, it's value should not be returned in `.values()`, and the method `.has()` will return `false`. The good news is that you will always receive the option `expires`, which is either `null` (no expiration) or a `number` indicating the time when it will expire.
606
659
 
@@ -615,26 +668,20 @@ const value = await store.get("a");
615
668
  // client.get("hello:world:a");
616
669
 
617
670
  // User calls this, then the client is called with that:
618
- const value = await store.entries();
619
- // client.entries("hello:world:");
671
+ for await (const entry of store.iterate()) {
672
+ }
673
+ // client.iterate("hello:world:");
620
674
  ```
621
675
 
622
- > Note: all of the _group methods_ that return keys, should return them **with the prefix stripped**:
676
+ > Note: all of the _group methods_ that return keys, should return them **with the prefix**:
623
677
 
624
678
  ```js
625
- // Example if your client works around a simple object {}, we want to remove
626
- // the `prefix` from the beginning of the keys returned:
627
679
  client.keys = (prefix) => {
628
- return Object.keys(subStore)
629
- .filter((key) => key.startsWith(prefix))
630
- .map((key) => key.slice(prefix.length)); // <= Important!
680
+ // Filter the keys, and return them INCLUDING the prefix!
681
+ return Object.keys(subStore).filter((key) => key.startsWith(prefix));
631
682
  };
632
683
  ```
633
684
 
634
- You can and should just concatenate the `key + options.prefix`. We don't do it for two reasons: in some cases, like `.add()`, there's no key that we can use to concatenate, and also you might
635
-
636
- For example, if the user of `polystore` does `kv(client).prefix('hello:').get('a')`, your store will be directly called with `client.get('a', { prefix: 'hello:' })`. You can safely concatenate `options.prefix + key` since this library always ensures that the prefix is defined and defaults to `''`. We don't concatenate it interally because in some cases (like in `.add()`) it makes more sense that this is handled by the client as an optimization.
637
-
638
685
  While the signatures are different, you can check each entries on the output of Polystore API to see what is expected for the methods of the client to do, e.g. `.clear()` will remove all of the items that match the prefix (or everything if there's no prefix).
639
686
 
640
687
  **Example: Plain Object client**
@@ -655,10 +702,12 @@ class MyClient {
655
702
  }
656
703
 
657
704
  // Filter them by the prefix, note that `prefix` will always be a string
658
- entries(prefix) {
659
- const entries = Object.entries(dataSource);
660
- if (!prefix) return entries;
661
- return entries.filter(([key, value]) => key.startsWith(prefix));
705
+ *iterate(prefix) {
706
+ for (const [key, value] of Object.entries(dataSource)) {
707
+ if (key.startsWith(prefix)) {
708
+ yield [key, value];
709
+ }
710
+ }
662
711
  }
663
712
  }
664
713
  ```
@@ -31,9 +31,33 @@ export default class Cloudflare {
31
31
  return this.client.delete(key);
32
32
  }
33
33
 
34
+ // Since we have pagination, we don't want to get all of the
35
+ // keys at once if we can avoid it
36
+ async *iterate(prefix = "") {
37
+ let cursor;
38
+ do {
39
+ const raw = await this.client.list({ prefix, cursor });
40
+ const keys = raw.keys.map((k) => k.name);
41
+ for (let key of keys) {
42
+ const value = await this.get(key);
43
+ // By the time this specific value is read, it could
44
+ // already be gone!
45
+ if (!value) continue;
46
+ yield [key, value];
47
+ }
48
+ cursor = raw.list_complete ? null : raw.cursor;
49
+ } while (cursor);
50
+ }
51
+
34
52
  async keys(prefix = "") {
35
- const raw = await this.client.list({ prefix });
36
- return raw.keys.map((k) => k.name);
53
+ const keys = [];
54
+ let cursor;
55
+ do {
56
+ const raw = await this.client.list({ prefix, cursor });
57
+ keys.push(...raw.keys.map((k) => k.name));
58
+ cursor = raw.list_complete ? null : raw.cursor;
59
+ } while (cursor);
60
+ return keys;
37
61
  }
38
62
 
39
63
  async entries(prefix = "") {
@@ -8,9 +8,25 @@ export default class Cookie {
8
8
  return client === "cookie" || client === "cookies";
9
9
  }
10
10
 
11
+ // Group methods
12
+ #read() {
13
+ const all = {};
14
+ for (let entry of document.cookie.split(";")) {
15
+ try {
16
+ const [rawKey, rawValue] = entry.split("=");
17
+ const key = decodeURIComponent(rawKey.trim());
18
+ const value = JSON.parse(decodeURIComponent(rawValue.trim()));
19
+ all[key] = value;
20
+ } catch (error) {
21
+ // no-op (some 3rd party can set cookies independently)
22
+ }
23
+ }
24
+ return all;
25
+ }
26
+
11
27
  // For cookies, an empty value is the same as null, even `""`
12
28
  get(key) {
13
- return this.all()[key] || null;
29
+ return this.#read()[key] || null;
14
30
  }
15
31
 
16
32
  set(key, data = null, { expires } = {}) {
@@ -33,23 +49,10 @@ export default class Cookie {
33
49
  return key;
34
50
  }
35
51
 
36
- // Group methods
37
- all(prefix = "") {
38
- const all = {};
39
- for (let entry of document.cookie.split(";")) {
40
- const [key, data] = entry.split("=");
41
- const name = decodeURIComponent(key.trim());
42
- if (!name.startsWith(prefix)) continue;
43
- try {
44
- all[name] = JSON.parse(decodeURIComponent(data.trim()));
45
- } catch (error) {
46
- // no-op (some 3rd party can set cookies independently)
47
- }
52
+ async *iterate(prefix = "") {
53
+ for (let [key, value] of Object.entries(this.#read())) {
54
+ if (!key.startsWith(prefix)) continue;
55
+ yield [key, value];
48
56
  }
49
- return all;
50
- }
51
-
52
- entries(prefix = "") {
53
- return Object.entries(this.all(prefix));
54
57
  }
55
58
  }
@@ -21,6 +21,13 @@ export default class Etcd {
21
21
  await this.client.put(key).value(JSON.stringify(value));
22
22
  }
23
23
 
24
+ async *iterate(prefix = "") {
25
+ const keys = await this.client.getAll().prefix(prefix).keys();
26
+ for (const key of keys) {
27
+ yield [key, await this.get(key)];
28
+ }
29
+ }
30
+
24
31
  async entries(prefix = "") {
25
32
  const keys = await this.client.getAll().prefix(prefix).keys();
26
33
  const values = await Promise.all(keys.map((k) => this.get(k)));
@@ -53,10 +53,12 @@ export default class File {
53
53
  return key;
54
54
  }
55
55
 
56
- // Group methods
57
- async entries(prefix = "") {
56
+ async *iterate(prefix = "") {
58
57
  const data = await this.#read();
59
- return Object.entries(data).filter((p) => p[0].startsWith(prefix));
58
+ const entries = Object.entries(data).filter((p) => p[0].startsWith(prefix));
59
+ for (const entry of entries) {
60
+ yield entry;
61
+ }
60
62
  }
61
63
 
62
64
  async clear(prefix = "") {
@@ -22,12 +22,18 @@ export default class Forage {
22
22
  return key;
23
23
  }
24
24
 
25
+ async *iterate(prefix = "") {
26
+ const keys = await this.client.keys();
27
+ const list = keys.filter((k) => k.startsWith(prefix));
28
+ for (const key of list) {
29
+ yield [key, await this.get(key)];
30
+ }
31
+ }
32
+
25
33
  async entries(prefix = "") {
26
34
  const all = await this.client.keys();
27
35
  const keys = all.filter((k) => k.startsWith(prefix));
28
- const values = await Promise.all(
29
- keys.map((key) => this.client.getItem(key))
30
- );
36
+ const values = await Promise.all(keys.map((key) => this.get(key)));
31
37
  return keys.map((key, i) => [key, values[i]]);
32
38
  }
33
39
 
@@ -26,6 +26,14 @@ export default class Level {
26
26
  return this.client.del(key);
27
27
  }
28
28
 
29
+ async *iterate(prefix = "") {
30
+ const keys = await this.client.keys().all();
31
+ const list = keys.filter((k) => k.startsWith(prefix));
32
+ for (const key of list) {
33
+ yield [key, await this.get(key)];
34
+ }
35
+ }
36
+
29
37
  async entries(prefix = "") {
30
38
  const keys = await this.client.keys().all();
31
39
  const list = keys.filter((k) => k.startsWith(prefix));
@@ -21,6 +21,14 @@ export default class Memory {
21
21
  this.client.delete(key);
22
22
  }
23
23
 
24
+ *iterate(prefix = "") {
25
+ const entries = this.entries();
26
+ for (const entry of entries) {
27
+ if (!entry[0].startsWith(prefix)) continue;
28
+ yield entry;
29
+ }
30
+ }
31
+
24
32
  // Group methods
25
33
  entries(prefix = "") {
26
34
  const entries = [...this.client.entries()];
@@ -37,12 +37,33 @@ export default class Redis {
37
37
  return this.client.keys(prefix + "*");
38
38
  }
39
39
 
40
+ // Go through each of the [key, value] in the set
41
+ async *iterate(prefix = "") {
42
+ const MATCH = prefix + "*";
43
+ for await (const key of this.client.scanIterator({ MATCH })) {
44
+ const value = await this.get(key);
45
+ yield [key, value];
46
+ }
47
+ }
48
+
49
+ // Optimizing the retrieval of them all in bulk by loading the values
50
+ // in parallel
40
51
  async entries(prefix = "") {
41
- const keys = await this.client.keys(prefix + "*");
52
+ const keys = await this.keys(prefix);
42
53
  const values = await Promise.all(keys.map((k) => this.get(k)));
43
54
  return keys.map((k, i) => [k, values[i]]);
44
55
  }
45
56
 
57
+ // Optimizing the retrieval of them by not getting their values
58
+ async keys(prefix = "") {
59
+ const MATCH = prefix + "*";
60
+ const keys = [];
61
+ for await (const key of this.client.scanIterator({ MATCH })) {
62
+ keys.push(key);
63
+ }
64
+ return keys;
65
+ }
66
+
46
67
  async clear(prefix = "") {
47
68
  if (!prefix) return this.client.flushAll();
48
69
 
@@ -25,6 +25,13 @@ export default class WebStorage {
25
25
  return key;
26
26
  }
27
27
 
28
+ *iterate(prefix = "") {
29
+ const entries = this.entries(prefix);
30
+ for (const entry of entries) {
31
+ yield entry;
32
+ }
33
+ }
34
+
28
35
  // Group methods
29
36
  entries(prefix = "") {
30
37
  const entries = Object.entries(this.client);
package/src/index.d.ts CHANGED
@@ -1,21 +1,181 @@
1
1
  type Options = { expires?: number | string | null };
2
2
  type Value = null | string | { [key: string]: Value } | Value[];
3
3
 
4
- type Store = {
4
+ interface Store {
5
+ /**
6
+ * Save the data on an autogenerated key, can add expiration as well:
7
+ *
8
+ * ```js
9
+ * const key1 = await store.add("value1");
10
+ * const key2 = await store.add({ hello: "world" });
11
+ * const key3 = await store.add("value3", { expires: "1h" });
12
+ * ```
13
+ *
14
+ * **[→ Full .add() Docs](https://polystore.dev/documentation#add)**
15
+ */
16
+ add: (value: Value, options?: Options) => Promise<string>;
17
+
18
+ /**
19
+ * Save the data on the given key, can add expiration as well:
20
+ *
21
+ * ```js
22
+ * const key = await store.set("key1", "value1");
23
+ * await store.set("key2", { hello: "world" });
24
+ * await store.set("key3", "value3", { expires: "1h" });
25
+ * ```
26
+ *
27
+ * **[→ Full .set() Docs](https://polystore.dev/documentation#set)**
28
+ */
29
+ set: (key: string, value: Value, options?: Options) => Promise<string>;
30
+
31
+ /**
32
+ * Read a single value from the KV store:
33
+ *
34
+ * ```js
35
+ * const value1 = await store.get("key1");
36
+ * // null (doesn't exist or has expired)
37
+ * const value2 = await store.get("key2");
38
+ * // "value2"
39
+ * const value3 = await store.get("key3");
40
+ * // { hello: "world" }
41
+ * ```
42
+ *
43
+ * **[→ Full .get() Docs](https://polystore.dev/documentation#get)**
44
+ */
5
45
  get: (key: string) => Promise<Value>;
6
- add: (value: any, options?: Options) => Promise<string>;
7
- set: (key: string, value: any, options?: Options) => Promise<string>;
46
+
47
+ /**
48
+ * Check whether a key exists or not:
49
+ *
50
+ * ```js
51
+ * if (await store.has("key1")) { ... }
52
+ * ```
53
+ *
54
+ * If you are going to use the value, it's better to just read it:
55
+ *
56
+ * ```js
57
+ * const val = await store.get("key1");
58
+ * if (val) { ... }
59
+ * ```
60
+ *
61
+ *
62
+ * **[→ Full .has() Docs](https://polystore.dev/documentation#has)**
63
+ */
8
64
  has: (key: string) => Promise<boolean>;
65
+
66
+ /**
67
+ * Remove a single key and its value from the store:
68
+ *
69
+ * ```js
70
+ * const key = await store.del("key1");
71
+ * ```
72
+ *
73
+ * **[→ Full .del() Docs](https://polystore.dev/documentation#del)**
74
+ */
9
75
  del: (key: string) => Promise<null>;
10
76
 
77
+ /**
78
+ * Return an array of the entries, in the [key, value] format:
79
+ *
80
+ * ```js
81
+ * const entries = await store.entries();
82
+ * // [["key1", "value1"], ["key2", { hello: "world" }], ...]
83
+ *
84
+ * // To limit it to a given prefix, use `.prefix()`:
85
+ * const sessions = await store.prefix("session:").entries();
86
+ * ```
87
+ *
88
+ * **[→ Full .entries() Docs](https://polystore.dev/documentation#entries)**
89
+ */
90
+ entries: () => Promise<[key: string, value: Value][]>;
91
+
92
+ /**
93
+ * Return an array of the keys in the store:
94
+ *
95
+ * ```js
96
+ * const keys = await store.keys();
97
+ * // ["key1", "key2", ...]
98
+ *
99
+ * // To limit it to a given prefix, use `.prefix()`:
100
+ * const sessions = await store.prefix("session:").keys();
101
+ * ```
102
+ *
103
+ * **[→ Full .keys() Docs](https://polystore.dev/documentation#keys)**
104
+ */
11
105
  keys: () => Promise<string[]>;
12
- values: () => Promise<any[]>;
13
- entries: () => Promise<[key: string, value: any][]>;
14
106
 
15
- prefix: (prefix: string) => Store;
107
+ /**
108
+ * Return an array of the values in the store:
109
+ *
110
+ * ```js
111
+ * const values = await store.values();
112
+ * // ["value1", { hello: "world" }, ...]
113
+ *
114
+ * // To limit it to a given prefix, use `.prefix()`:
115
+ * const sessions = await store.prefix("session:").values();
116
+ * ```
117
+ *
118
+ * **[→ Full .values() Docs](https://polystore.dev/documentation#values)**
119
+ */
120
+ values: () => Promise<Value[]>;
121
+
122
+ /**
123
+ * Return an object with the keys:values in the store:
124
+ *
125
+ * ```js
126
+ * const obj = await store.all();
127
+ * // { key1: "value1", key2: { hello: "world" }, ... }
128
+ *
129
+ * // To limit it to a given prefix, use `.prefix()`:
130
+ * const sessions = await store.prefix("session:").all();
131
+ * ```
132
+ *
133
+ * **[→ Full .all() Docs](https://polystore.dev/documentation#all)**
134
+ */
135
+ all: () => Promise<{ [key: string]: Value }>;
16
136
 
137
+ /**
138
+ * Delete all of the records of the store:
139
+ *
140
+ * ```js
141
+ * await store.clear();
142
+ * ```
143
+ *
144
+ * It's useful for cache invalidation, clearing the data, and testing.
145
+ *
146
+ * **[→ Full .clear() Docs](https://polystore.dev/documentation#clear)**
147
+ */
17
148
  clear: () => Promise<null>;
149
+
150
+ /**
151
+ * Create a substore where all the keys are stored with
152
+ * the given prefix:
153
+ *
154
+ * ```js
155
+ * const session = store.prefix("session:");
156
+ * await session.set("key1", "value1");
157
+ * console.log(await session.entries()); // session.
158
+ * // [["key1", "value1"]]
159
+ * console.log(await store.entries()); // store.
160
+ * // [["session:key1", "value1"]]
161
+ * ```
162
+ *
163
+ * **[→ Full .prefix() Docs](https://polystore.dev/documentation#prefix)**
164
+ */
165
+ prefix: (prefix: string) => Store;
166
+
167
+ /**
168
+ * Stop the connection to the store, if any:
169
+ *
170
+ * ```js
171
+ * await session.set("key1", "value1");
172
+ * await store.close();
173
+ * await session.set("key2", "value2"); // error
174
+ * ```
175
+ *
176
+ * **[→ Full .close() Docs](https://polystore.dev/documentation#close)**
177
+ */
18
178
  close?: () => Promise<null>;
19
- };
179
+ }
20
180
 
21
181
  export default function (store?: any): Store;
package/src/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * A number, or a string containing a number.
3
+ * @typedef {(number|string|object|array)} Value
4
+ */
5
+
1
6
  import clients from "./clients/index.js";
2
7
  import { createId, isClass, parse } from "./utils.js";
3
8
 
@@ -38,9 +43,9 @@ class Store {
38
43
 
39
44
  // #region #validate()
40
45
  #validate(client) {
41
- if (!client.set || !client.get || !client.entries) {
46
+ if (!client.set || !client.get || !client.iterate) {
42
47
  throw new Error(
43
- "A client should have at least a .get(), .set() and .entries()"
48
+ "A client should have at least a .get(), .set() and .iterate()"
44
49
  );
45
50
  }
46
51
 
@@ -63,6 +68,31 @@ class Store {
63
68
  }
64
69
  }
65
70
 
71
+ #unix(expires) {
72
+ const now = new Date().getTime();
73
+ return expires === null ? null : now + expires * 1000;
74
+ }
75
+
76
+ // Check if the given data is fresh or not; if
77
+ #isFresh(data, key) {
78
+ // Should never happen, but COULD happen; schedule it for
79
+ // removal and mark it as stale
80
+ if (!data || !data.value || typeof data !== "object") {
81
+ if (key) this.del(key);
82
+ return false;
83
+ }
84
+
85
+ // It never expires, so keep it
86
+ if (data.expires === null) return true;
87
+
88
+ // It's fresh, keep it
89
+ if (data.expires > Date.now()) return true;
90
+
91
+ // It's expired, remove it
92
+ if (key) this.del(key);
93
+ return false;
94
+ }
95
+
66
96
  // #region .add()
67
97
  /**
68
98
  * Save the data on an autogenerated key, can add expiration as well:
@@ -89,9 +119,10 @@ class Store {
89
119
  }
90
120
 
91
121
  // In the data we need the timestamp since we need it "absolute":
92
- const now = new Date().getTime();
93
- const expDiff = expires === null ? null : now + expires * 1000;
94
- return this.client.add(this.PREFIX, { expires: expDiff, value });
122
+ return this.client.add(this.PREFIX, {
123
+ expires: this.#unix(expires),
124
+ value,
125
+ });
95
126
  }
96
127
 
97
128
  const id = createId();
@@ -121,7 +152,7 @@ class Store {
121
152
  const expires = parse(options.expire ?? options.expires);
122
153
 
123
154
  // Quick delete
124
- if (value === null) {
155
+ if (value === null || (typeof expires === "number" && expires <= 0)) {
125
156
  await this.del(id);
126
157
  return key;
127
158
  }
@@ -132,16 +163,8 @@ class Store {
132
163
  return key;
133
164
  }
134
165
 
135
- // Already expired, then delete it
136
- if (expires === 0) {
137
- await this.del(id);
138
- return key;
139
- }
140
-
141
166
  // In the data we need the timestamp since we need it "absolute":
142
- const now = new Date().getTime();
143
- const expDiff = expires === null ? null : now + expires * 1000;
144
- await this.client.set(id, { expires: expDiff, value });
167
+ await this.client.set(id, { expires: this.#unix(expires), value });
145
168
  return key;
146
169
  }
147
170
 
@@ -151,7 +174,7 @@ class Store {
151
174
  *
152
175
  * ```js
153
176
  * const value1 = await store.get("key1");
154
- * // null (it doesn't exist, or it has expired)
177
+ * // null (doesn't exist or has expired)
155
178
  * const value2 = await store.get("key2");
156
179
  * // "value2"
157
180
  * const value3 = await store.get("key3");
@@ -175,23 +198,8 @@ class Store {
175
198
  // so we can assume it's the raw user data
176
199
  if (this.client.EXPIRES) return data;
177
200
 
178
- // Make sure that if there's no data by now, empty is returned
179
- if (!data) return null;
180
-
181
- // We manage expiration manually, so we know it should have this structure
182
- // TODO: ADD A CHECK HERE
183
- const { expires, value } = data;
184
-
185
- // It never expires
186
- if (expires === null) return value ?? null;
187
-
188
- // Already expired! Return nothing, and remove the whole key
189
- if (expires <= new Date().getTime()) {
190
- await this.del(key);
191
- return null;
192
- }
193
-
194
- return value;
201
+ if (!this.#isFresh(data, key)) return null;
202
+ return data.value;
195
203
  }
196
204
 
197
205
  // #region .has()
@@ -251,6 +259,19 @@ class Store {
251
259
  return key;
252
260
  }
253
261
 
262
+ async *[Symbol.asyncIterator]() {
263
+ await this.promise;
264
+
265
+ for await (const [name, data] of this.client.iterate(this.PREFIX)) {
266
+ const key = name.slice(this.PREFIX.length);
267
+ if (this.client.EXPIRES) {
268
+ yield [key, data];
269
+ } else if (this.#isFresh(data, key)) {
270
+ yield [key, data.value];
271
+ }
272
+ }
273
+ }
274
+
254
275
  // #region .entries()
255
276
  /**
256
277
  * Return an array of the entries, in the [key, value] format:
@@ -269,110 +290,84 @@ class Store {
269
290
  async entries() {
270
291
  await this.promise;
271
292
 
272
- const entries = await this.client.entries(this.PREFIX);
273
- const list = entries.map(([key, data]) => [
274
- key.slice(this.PREFIX.length),
275
- data,
276
- ]);
293
+ let list = [];
294
+ if (this.client.entries) {
295
+ list = (await this.client.entries(this.PREFIX)).map(([key, value]) => [
296
+ key.slice(this.PREFIX.length),
297
+ value,
298
+ ]);
299
+ } else {
300
+ for await (const [key, value] of this.client.iterate(this.PREFIX)) {
301
+ list.push([key.slice(this.PREFIX.length), value]);
302
+ }
303
+ }
277
304
 
278
305
  // The client already manages the expiration, so we can assume
279
306
  // that at this point, all entries are not-expired
280
307
  if (this.client.EXPIRES) return list;
281
308
 
282
309
  // We need to do manual expiration checking
283
- const now = new Date().getTime();
284
310
  return list
285
- .filter(([key, data]) => {
286
- // Should never happen
287
- if (!data || data.value === null) return false;
288
-
289
- // It never expires, so keep it
290
- const { expires } = data;
291
- if (expires === null) return true;
292
-
293
- // It's expired, so remove it
294
- if (expires <= now) {
295
- this.del(key);
296
- return false;
297
- }
298
-
299
- // It's not expired, keep it
300
- return true;
301
- })
311
+ .filter(([key, data]) => this.#isFresh(data, key))
302
312
  .map(([key, data]) => [key, data.value]);
303
313
  }
304
314
 
305
- // #region .values()
315
+ // #region .keys()
306
316
  /**
307
- * Return an array of the values in the store:
317
+ * Return an array of the keys in the store:
308
318
  *
309
319
  * ```js
310
- * const values = await store.values();
311
- * // ["value1", { hello: "world" }, ...]
320
+ * const keys = await store.keys();
321
+ * // ["key1", "key2", ...]
312
322
  *
313
323
  * // To limit it to a given prefix, use `.prefix()`:
314
- * const sessions = await store.prefix("session:").values();
324
+ * const sessions = await store.prefix("session:").keys();
315
325
  * ```
316
326
  *
317
- * **[→ Full .values() Docs](https://polystore.dev/documentation#values)**
318
- * @returns {Promise<Value[]>}
327
+ * **[→ Full .keys() Docs](https://polystore.dev/documentation#keys)**
328
+ * @returns {Promise<string[]>}
319
329
  */
320
- async values() {
330
+ async keys() {
321
331
  await this.promise;
322
332
 
323
- if (this.client.values) {
324
- const list = this.client.values(this.PREFIX);
325
- if (this.client.EXPIRES) return list;
326
- const now = new Date().getTime();
327
- return list
328
- .filter((data) => {
329
- // There's no data, so remove this
330
- if (!data || data.value === null) return false;
331
-
332
- // It never expires, so keep it
333
- const { expires } = data;
334
- if (expires === null) return true;
335
-
336
- // It's expired, so remove it
337
- // We cannot unfortunately evict it since we don't know the key!
338
- if (expires <= now) return false;
339
-
340
- // It's not expired, keep it
341
- return true;
342
- })
343
- .map((data) => data.value);
333
+ if (this.client.keys) {
334
+ const list = await this.client.keys(this.PREFIX);
335
+ if (!this.PREFIX) return list;
336
+ return list.map((k) => k.slice(this.PREFIX.length));
344
337
  }
345
338
 
346
339
  const entries = await this.entries();
347
- return entries.map((e) => e[1]);
340
+ return entries.map((e) => e[0]);
348
341
  }
349
342
 
350
- // #region .keys()
343
+ // #region .values()
351
344
  /**
352
- * Return an array of the keys in the store:
345
+ * Return an array of the values in the store:
353
346
  *
354
347
  * ```js
355
- * const keys = await store.keys();
356
- * // ["key1", "key2", ...]
348
+ * const values = await store.values();
349
+ * // ["value1", { hello: "world" }, ...]
357
350
  *
358
351
  * // To limit it to a given prefix, use `.prefix()`:
359
- * const sessions = await store.prefix("session:").keys();
352
+ * const sessions = await store.prefix("session:").values();
360
353
  * ```
361
354
  *
362
- * **[→ Full .keys() Docs](https://polystore.dev/documentation#keys)**
363
- * @returns {Promise<string[]>}
355
+ * **[→ Full .values() Docs](https://polystore.dev/documentation#values)**
356
+ * @returns {Promise<Value[]>}
364
357
  */
365
- async keys() {
358
+ async values() {
366
359
  await this.promise;
367
360
 
368
- if (this.client.keys) {
369
- const list = await this.client.keys(this.PREFIX);
370
- if (!this.PREFIX) return list;
371
- return list.map((k) => k.slice(this.PREFIX.length));
361
+ if (this.client.values) {
362
+ const list = this.client.values(this.PREFIX);
363
+ if (this.client.EXPIRES) return list;
364
+ return list
365
+ .filter((data) => this.#isFresh(data))
366
+ .map((data) => data.value);
372
367
  }
373
368
 
374
369
  const entries = await this.entries();
375
- return entries.map((e) => e[0]);
370
+ return entries.map((e) => e[1]);
376
371
  }
377
372
 
378
373
  // #region .all()
@@ -458,6 +453,18 @@ class Store {
458
453
  }
459
454
 
460
455
  // #region .close()
456
+ /**
457
+ * Stop the connection to the store, if any:
458
+ *
459
+ * ```js
460
+ * await session.set("key1", "value1");
461
+ * await store.close();
462
+ * await session.set("key2", "value2"); // error
463
+ * ```
464
+ *
465
+ * **[→ Full .close() Docs](https://polystore.dev/documentation#close)**
466
+ * @returns {Store}
467
+ */
461
468
  async close() {
462
469
  await this.promise;
463
470
 
package/src/index.test.js CHANGED
@@ -11,8 +11,6 @@ import kv from "./index.js";
11
11
  import customFull from "./test/customFull.js";
12
12
  import customSimple from "./test/customSimple.js";
13
13
 
14
- global.setImmediate = global.setImmediate || ((cb) => setTimeout(cb, 0));
15
-
16
14
  const stores = [];
17
15
  stores.push(["kv()", kv()]);
18
16
  stores.push(["kv(new Map())", kv(new Map())]);
@@ -30,6 +28,7 @@ if (process.env.REDIS) {
30
28
  stores.push(["kv(redis)", kv(createClient())]);
31
29
  }
32
30
  if (process.env.ETCD) {
31
+ // Note: need to add to .env "ETCD=true" and run `etcd` in the terminal
33
32
  stores.push(["kv(new Etcd3())", kv(new Etcd3())]);
34
33
  }
35
34
 
@@ -41,7 +40,7 @@ const delay = (t) => new Promise((done) => setTimeout(done, t));
41
40
  class Base {
42
41
  get() {}
43
42
  set() {}
44
- entries() {}
43
+ *iterate() {}
45
44
  }
46
45
 
47
46
  global.console = {
@@ -56,7 +55,7 @@ describe("potato", () => {
56
55
 
57
56
  it("an empty object is not a valid store", async () => {
58
57
  await expect(() => kv({}).get("any")).rejects.toThrow({
59
- message: "A client should have at least a .get(), .set() and .entries()",
58
+ message: "A client should have at least a .get(), .set() and .iterate()",
60
59
  });
61
60
  });
62
61
 
@@ -343,6 +342,42 @@ for (let [name, store] of stores) {
343
342
  expect(await store.get("a")).toBe("b");
344
343
  });
345
344
 
345
+ describe("iteration", () => {
346
+ beforeEach(async () => {
347
+ await store.clear();
348
+ });
349
+
350
+ it("supports raw iteration", async () => {
351
+ await store.set("a", "b");
352
+ await store.set("c", "d");
353
+
354
+ const entries = [];
355
+ for await (const entry of store) {
356
+ entries.push(entry);
357
+ }
358
+ expect(entries).toEqual([
359
+ ["a", "b"],
360
+ ["c", "d"],
361
+ ]);
362
+ });
363
+
364
+ it("supports raw prefix iteration", async () => {
365
+ await store.set("a:a", "b");
366
+ await store.set("b:a", "d");
367
+ await store.set("a:c", "d");
368
+ await store.set("b:c", "d");
369
+
370
+ const entries = [];
371
+ for await (const entry of store.prefix("a:")) {
372
+ entries.push(entry);
373
+ }
374
+ expect(entries.sort()).toEqual([
375
+ ["a", "b"],
376
+ ["c", "d"],
377
+ ]);
378
+ });
379
+ });
380
+
346
381
  describe("expires", () => {
347
382
  // The mock implementation does NOT support expiration 😪
348
383
  if (name === "kv(new KVNamespace())") return;
@@ -19,6 +19,13 @@ export default class MyClient {
19
19
  delete dataSource[key];
20
20
  }
21
21
 
22
+ *iterate(prefix) {
23
+ const entries = this.entries(prefix);
24
+ for (const entry of entries) {
25
+ yield entry;
26
+ }
27
+ }
28
+
22
29
  // Filter them by the prefix, note that `prefix` will always be a string
23
30
  entries(prefix) {
24
31
  const entries = Object.entries(dataSource);
@@ -15,9 +15,13 @@ export default class MyClient {
15
15
  }
16
16
 
17
17
  // Filter them by the prefix, note that `prefix` will always be a string
18
- entries(prefix) {
19
- const entries = Object.entries(dataSource);
20
- if (!prefix) return entries;
21
- return entries.filter(([key, value]) => key.startsWith(prefix));
18
+ *iterate(prefix) {
19
+ const raw = Object.entries(dataSource);
20
+ const entries = prefix
21
+ ? raw.filter(([key, value]) => key.startsWith(prefix))
22
+ : raw;
23
+ for (const entry of entries) {
24
+ yield entry;
25
+ }
22
26
  }
23
27
  }
package/src/test/setup.js CHANGED
@@ -2,11 +2,22 @@ import * as util from "util";
2
2
 
3
3
  // ref: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
4
4
  // ref: https://github.com/jsdom/jsdom/issues/2524
5
- Object.defineProperty(window, "TextEncoder", {
6
- writable: true,
7
- value: util.TextEncoder,
8
- });
9
- Object.defineProperty(window, "TextDecoder", {
10
- writable: true,
11
- value: util.TextDecoder,
12
- });
5
+ if (typeof TextEncoder === "undefined") {
6
+ Object.defineProperty(window, "TextEncoder", {
7
+ writable: true,
8
+ value: util.TextEncoder,
9
+ });
10
+ }
11
+ if (typeof TextDecoder === "undefined") {
12
+ Object.defineProperty(window, "TextDecoder", {
13
+ writable: true,
14
+ value: util.TextDecoder,
15
+ });
16
+ }
17
+
18
+ if (typeof setImmediate === "undefined") {
19
+ Object.defineProperty(window, "setImmediate", {
20
+ writable: true,
21
+ value: (cb) => setTimeout(cb, 0),
22
+ });
23
+ }