polystore 0.10.0 → 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>
@@ -180,8 +180,10 @@ console.log(await store.get(key));
180
180
  </svg>
181
181
  <h3>Intuitive expirations</h3>
182
182
  </header>
183
- <p>Write the expiration as <code>100s</code>, <code>1week</code>, etc.</p>
184
- and forget time-related bugs.
183
+ <p>
184
+ Write the expiration as <code>100s</code>, <code>1week</code>, etc. and
185
+ forget time-related bugs.
186
+ </p>
185
187
  </div>
186
188
  </div>
187
189
  </section>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polystore",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A small compatibility layer for many popular KV stores like localStorage, Redis, FileSystem, etc.",
5
5
  "homepage": "https://polystore.dev/",
6
6
  "repository": "https://github.com/franciscop/polystore.git",
@@ -24,6 +24,7 @@
24
24
  "value"
25
25
  ],
26
26
  "license": "MIT",
27
+ "dependencies": {},
27
28
  "devDependencies": {
28
29
  "@deno/kv": "^0.8.1",
29
30
  "check-dts": "^0.8.0",
package/readme.md CHANGED
@@ -241,6 +241,26 @@ Remove a single key from the store and return the key itself:
241
241
  await store.del(key: string);
242
242
  ```
243
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
+
244
264
  ### .keys()
245
265
 
246
266
  Get all of the keys in the store, optionally filtered by a prefix:
@@ -615,7 +635,7 @@ class MyClient {
615
635
  // Mandatory methods
616
636
  get (key): Promise<any>;
617
637
  set (key, value, { expires: null|number }): Promise<null>;
618
- entries (prefix): Promise<[string, any][]>;
638
+ iterate(prefix): AyncIterator<[string, any]>
619
639
 
620
640
  // Optional item methods (for optimization or customization)
621
641
  add (prefix, data, { expires: null|number }): Promise<string>;
@@ -623,6 +643,7 @@ class MyClient {
623
643
  del (key): Promise<null>;
624
644
 
625
645
  // Optional group methods
646
+ entries (prefix): Promise<[string, any][]>;
626
647
  keys (prefix): Promise<string[]>;
627
648
  values (prefix): Promise<any[]>;
628
649
  clear (prefix): Promise<null>;
@@ -647,8 +668,9 @@ const value = await store.get("a");
647
668
  // client.get("hello:world:a");
648
669
 
649
670
  // User calls this, then the client is called with that:
650
- const value = await store.entries();
651
- // client.entries("hello:world:");
671
+ for await (const entry of store.iterate()) {
672
+ }
673
+ // client.iterate("hello:world:");
652
674
  ```
653
675
 
654
676
  > Note: all of the _group methods_ that return keys, should return them **with the prefix**:
@@ -680,10 +702,12 @@ class MyClient {
680
702
  }
681
703
 
682
704
  // Filter them by the prefix, note that `prefix` will always be a string
683
- entries(prefix) {
684
- const entries = Object.entries(dataSource);
685
- if (!prefix) return entries;
686
- 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
+ }
687
711
  }
688
712
  }
689
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.js CHANGED
@@ -43,9 +43,9 @@ class Store {
43
43
 
44
44
  // #region #validate()
45
45
  #validate(client) {
46
- if (!client.set || !client.get || !client.entries) {
46
+ if (!client.set || !client.get || !client.iterate) {
47
47
  throw new Error(
48
- "A client should have at least a .get(), .set() and .entries()"
48
+ "A client should have at least a .get(), .set() and .iterate()"
49
49
  );
50
50
  }
51
51
 
@@ -68,6 +68,31 @@ class Store {
68
68
  }
69
69
  }
70
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
+
71
96
  // #region .add()
72
97
  /**
73
98
  * Save the data on an autogenerated key, can add expiration as well:
@@ -94,9 +119,10 @@ class Store {
94
119
  }
95
120
 
96
121
  // In the data we need the timestamp since we need it "absolute":
97
- const now = new Date().getTime();
98
- const expDiff = expires === null ? null : now + expires * 1000;
99
- return this.client.add(this.PREFIX, { expires: expDiff, value });
122
+ return this.client.add(this.PREFIX, {
123
+ expires: this.#unix(expires),
124
+ value,
125
+ });
100
126
  }
101
127
 
102
128
  const id = createId();
@@ -126,7 +152,7 @@ class Store {
126
152
  const expires = parse(options.expire ?? options.expires);
127
153
 
128
154
  // Quick delete
129
- if (value === null) {
155
+ if (value === null || (typeof expires === "number" && expires <= 0)) {
130
156
  await this.del(id);
131
157
  return key;
132
158
  }
@@ -137,16 +163,8 @@ class Store {
137
163
  return key;
138
164
  }
139
165
 
140
- // Already expired, then delete it
141
- if (expires === 0) {
142
- await this.del(id);
143
- return key;
144
- }
145
-
146
166
  // In the data we need the timestamp since we need it "absolute":
147
- const now = new Date().getTime();
148
- const expDiff = expires === null ? null : now + expires * 1000;
149
- await this.client.set(id, { expires: expDiff, value });
167
+ await this.client.set(id, { expires: this.#unix(expires), value });
150
168
  return key;
151
169
  }
152
170
 
@@ -180,23 +198,8 @@ class Store {
180
198
  // so we can assume it's the raw user data
181
199
  if (this.client.EXPIRES) return data;
182
200
 
183
- // Make sure that if there's no data by now, empty is returned
184
- if (!data) return null;
185
-
186
- // We manage expiration manually, so we know it should have this structure
187
- // TODO: ADD A CHECK HERE
188
- const { expires, value } = data;
189
-
190
- // It never expires
191
- if (expires === null) return value ?? null;
192
-
193
- // Already expired! Return nothing, and remove the whole key
194
- if (expires <= new Date().getTime()) {
195
- await this.del(key);
196
- return null;
197
- }
198
-
199
- return value;
201
+ if (!this.#isFresh(data, key)) return null;
202
+ return data.value;
200
203
  }
201
204
 
202
205
  // #region .has()
@@ -256,6 +259,19 @@ class Store {
256
259
  return key;
257
260
  }
258
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
+
259
275
  // #region .entries()
260
276
  /**
261
277
  * Return an array of the entries, in the [key, value] format:
@@ -274,36 +290,25 @@ class Store {
274
290
  async entries() {
275
291
  await this.promise;
276
292
 
277
- const entries = await this.client.entries(this.PREFIX);
278
- const list = entries.map(([key, data]) => [
279
- key.slice(this.PREFIX.length),
280
- data,
281
- ]);
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
+ }
282
304
 
283
305
  // The client already manages the expiration, so we can assume
284
306
  // that at this point, all entries are not-expired
285
307
  if (this.client.EXPIRES) return list;
286
308
 
287
309
  // We need to do manual expiration checking
288
- const now = new Date().getTime();
289
310
  return list
290
- .filter(([key, data]) => {
291
- // Should never happen
292
- if (!data || data.value === null) return false;
293
-
294
- // It never expires, so keep it
295
- const { expires } = data;
296
- if (expires === null) return true;
297
-
298
- // It's expired, so remove it
299
- if (expires <= now) {
300
- this.del(key);
301
- return false;
302
- }
303
-
304
- // It's not expired, keep it
305
- return true;
306
- })
311
+ .filter(([key, data]) => this.#isFresh(data, key))
307
312
  .map(([key, data]) => [key, data.value]);
308
313
  }
309
314
 
@@ -356,23 +361,8 @@ class Store {
356
361
  if (this.client.values) {
357
362
  const list = this.client.values(this.PREFIX);
358
363
  if (this.client.EXPIRES) return list;
359
- const now = new Date().getTime();
360
364
  return list
361
- .filter((data) => {
362
- // There's no data, so remove this
363
- if (!data || data.value === null) return false;
364
-
365
- // It never expires, so keep it
366
- const { expires } = data;
367
- if (expires === null) return true;
368
-
369
- // It's expired, so remove it
370
- // We cannot unfortunately evict it since we don't know the key!
371
- if (expires <= now) return false;
372
-
373
- // It's not expired, keep it
374
- return true;
375
- })
365
+ .filter((data) => this.#isFresh(data))
376
366
  .map((data) => data.value);
377
367
  }
378
368
 
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
@@ -14,3 +14,10 @@ if (typeof TextDecoder === "undefined") {
14
14
  value: util.TextDecoder,
15
15
  });
16
16
  }
17
+
18
+ if (typeof setImmediate === "undefined") {
19
+ Object.defineProperty(window, "setImmediate", {
20
+ writable: true,
21
+ value: (cb) => setTimeout(cb, 0),
22
+ });
23
+ }