polystore 0.19.0 → 0.20.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.
Files changed (4) hide show
  1. package/index.d.ts +1 -0
  2. package/index.js +115 -26
  3. package/package.json +5 -1
  4. package/readme.md +85 -23
package/index.d.ts CHANGED
@@ -45,6 +45,7 @@ interface ClientNonExpires {
45
45
  values?<T extends Serializable>(prefix: string): Promise<StoreData<T>[]> | StoreData<T>[];
46
46
  entries?<T extends Serializable>(prefix: string): Promise<[string, StoreData<T>][]> | [string, StoreData<T>][];
47
47
  all?<T extends Serializable>(prefix: string): Promise<Record<string, StoreData<T>>> | Record<string, StoreData<T>>;
48
+ prune?(): Promise<any> | any;
48
49
  clear?(prefix: string): Promise<any> | any;
49
50
  clearAll?(): Promise<any> | any;
50
51
  close?(): Promise<any> | any;
package/index.js CHANGED
@@ -244,8 +244,10 @@ var File = class extends Client {
244
244
  }
245
245
  }
246
246
  // Bulk updates are worth creating a custom method here
247
- clearAll = () => this.#withLock(() => this.#write({}));
248
247
  clear = async (prefix = "") => {
248
+ if (!prefix) {
249
+ await this.#withLock(() => this.#write({}));
250
+ }
249
251
  return this.#withLock(async () => {
250
252
  const data = await this.#read();
251
253
  for (let key in data) {
@@ -366,8 +368,10 @@ var Level = class extends Client {
366
368
  list.map(async (k) => [k, await this.get(k)])
367
369
  );
368
370
  };
369
- clearAll = () => this.client.clear();
370
371
  clear = async (prefix = "") => {
372
+ if (!prefix) {
373
+ return await this.client.clear();
374
+ }
371
375
  const keys = await this.client.keys().all();
372
376
  const list = keys.filter((k) => k.startsWith(prefix));
373
377
  return this.client.batch(
@@ -395,6 +399,97 @@ var Memory = class extends Client {
395
399
  clearAll = () => this.client.clear();
396
400
  };
397
401
 
402
+ // src/clients/postgres.ts
403
+ var Postgres = class extends Client {
404
+ TYPE = "POSTGRES";
405
+ // This one is doing manual time management internally even though
406
+ // sqlite does not natively support expirations. This is because it does
407
+ // support creating a `expires_at:Date` column that makes managing
408
+ // expirations much easier, so it's really "somewhere in between"
409
+ HAS_EXPIRATION = true;
410
+ // The table name to use
411
+ table = "kv";
412
+ // Ensure schema exists before any operation
413
+ promise = (async () => {
414
+ if (!/^[a-zA-Z_]+$/.test(this.table)) {
415
+ throw new Error(`Invalid table name ${this.table}`);
416
+ }
417
+ await this.client.query(`
418
+ CREATE TABLE IF NOT EXISTS ${this.table} (
419
+ id TEXT PRIMARY KEY,
420
+ value TEXT NOT NULL,
421
+ expires_at TIMESTAMPTZ
422
+ )
423
+ `);
424
+ await this.client.query(
425
+ `CREATE INDEX IF NOT EXISTS idx_${this.table}_expires_at ON ${this.table} (expires_at)`
426
+ );
427
+ })();
428
+ static test = (client) => {
429
+ return client && client.query && !client.filename;
430
+ };
431
+ get = async (id) => {
432
+ const result = await this.client.query(
433
+ `SELECT value
434
+ FROM ${this.table}
435
+ WHERE id = $1 AND (expires_at IS NULL OR expires_at > NOW())`,
436
+ [id]
437
+ );
438
+ if (!result.rows.length) return null;
439
+ return this.decode(result.rows[0].value);
440
+ };
441
+ set = async (id, data, expires) => {
442
+ const value = this.encode(data);
443
+ const expires_at = expires ? new Date(Date.now() + expires * 1e3) : null;
444
+ await this.client.query(
445
+ `INSERT INTO ${this.table} (id, value, expires_at)
446
+ VALUES ($1, $2, $3)
447
+ ON CONFLICT (id) DO UPDATE
448
+ SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`,
449
+ [id, value, expires_at]
450
+ );
451
+ };
452
+ del = async (id) => {
453
+ await this.client.query(`DELETE FROM ${this.table} WHERE id = $1`, [id]);
454
+ };
455
+ async *iterate(prefix = "") {
456
+ const result = await this.client.query(
457
+ `SELECT id, value FROM ${this.table}
458
+ WHERE (expires_at IS NULL OR expires_at > NOW()) ${prefix ? `AND id LIKE $1` : ""}`,
459
+ prefix ? [`${prefix}%`] : []
460
+ );
461
+ for (const row of result.rows) {
462
+ yield [row.id, this.decode(row.value)];
463
+ }
464
+ }
465
+ async keys(prefix = "") {
466
+ const result = await this.client.query(
467
+ `SELECT id FROM ${this.table}
468
+ WHERE (expires_at IS NULL OR expires_at > NOW())
469
+ ${prefix ? `AND id LIKE $1` : ""}`,
470
+ prefix ? [`${prefix}%`] : []
471
+ );
472
+ return result.rows.map((r) => r.id);
473
+ }
474
+ prune = async () => {
475
+ await this.client.query(
476
+ `DELETE FROM ${this.table}
477
+ WHERE expires_at IS NOT NULL AND expires_at <= NOW()`
478
+ );
479
+ };
480
+ clear = async (prefix = "") => {
481
+ await this.client.query(
482
+ `DELETE FROM ${this.table} ${prefix ? `WHERE id LIKE $1` : ""}`,
483
+ prefix ? [`${prefix}%`] : []
484
+ );
485
+ };
486
+ close = async () => {
487
+ if (this.client.end) {
488
+ await this.client.end();
489
+ }
490
+ };
491
+ };
492
+
398
493
  // src/clients/redis.ts
399
494
  var Redis = class extends Client {
400
495
  TYPE = "REDIS";
@@ -470,13 +565,11 @@ var SQLite = class extends Client {
470
565
  return typeof client?.prepare === "function" && typeof client?.exec === "function";
471
566
  };
472
567
  get = (id) => {
473
- const row = this.client.prepare(`SELECT value, expires_at FROM kv WHERE id = ?`).get(id);
474
- if (!row) return null;
475
- if (row.expires_at && row.expires_at < Date.now()) {
476
- this.del(id);
477
- return null;
478
- }
479
- return this.decode(row.value);
568
+ const value = this.client.prepare(
569
+ `SELECT value, expires_at FROM kv WHERE id = ? AND (expires_at IS NULL OR expires_at > ?)`
570
+ ).get(id, Date.now())?.value;
571
+ if (!value) return null;
572
+ return this.decode(value);
480
573
  };
481
574
  set = (id, data, expires) => {
482
575
  const value = this.encode(data);
@@ -485,8 +578,8 @@ var SQLite = class extends Client {
485
578
  `INSERT INTO kv (id, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`
486
579
  ).run(id, value, expires_at);
487
580
  };
488
- del = async (id) => {
489
- await this.client.prepare(`DELETE FROM kv WHERE id = ?`).run(id);
581
+ del = (id) => {
582
+ this.client.prepare(`DELETE FROM kv WHERE id = ?`).run(id);
490
583
  };
491
584
  has = (id) => {
492
585
  const row = this.client.prepare(`SELECT expires_at FROM kv WHERE id = ?`).get(id);
@@ -498,7 +591,6 @@ var SQLite = class extends Client {
498
591
  return true;
499
592
  };
500
593
  *iterate(prefix = "") {
501
- this.#clearExpired();
502
594
  const sql = `SELECT id, value FROM kv WHERE (expires_at IS NULL OR expires_at > ?) ${prefix ? "AND id LIKE ?" : ""}
503
595
  `;
504
596
  const params = prefix ? [Date.now(), `${prefix}%`] : [Date.now()];
@@ -507,7 +599,6 @@ var SQLite = class extends Client {
507
599
  }
508
600
  }
509
601
  keys = (prefix = "") => {
510
- this.#clearExpired();
511
602
  const sql = `SELECT id FROM kv WHERE (expires_at IS NULL OR expires_at > ?)
512
603
  ${prefix ? "AND id LIKE ?" : ""}
513
604
  `;
@@ -515,11 +606,15 @@ ${prefix ? "AND id LIKE ?" : ""}
515
606
  const rows = this.client.prepare(sql).all(...params);
516
607
  return rows.map((r) => r.id);
517
608
  };
518
- #clearExpired = () => {
519
- this.client.prepare(`DELETE FROM kv WHERE expires_at < ?`).run(Date.now());
609
+ prune = () => {
610
+ this.client.prepare(`DELETE FROM kv WHERE expires_at <= ?`).run(Date.now());
520
611
  };
521
- clearAll = () => {
522
- this.client.exec(`DELETE FROM kv`);
612
+ clear = (prefix = "") => {
613
+ if (!prefix) {
614
+ this.client.prepare(`DELETE FROM ${this.table}`).run();
615
+ return;
616
+ }
617
+ this.client.prepare(`DELETE FROM ${this.table} WHERE id LIKE ?`).run(`${prefix}%`);
523
618
  };
524
619
  close = () => {
525
620
  this.client.close?.();
@@ -564,8 +659,7 @@ var clients_default = {
564
659
  forage: Forage,
565
660
  level: Level,
566
661
  memory: Memory,
567
- // postgres,
568
- // prisma,
662
+ postgres: Postgres,
569
663
  redis: Redis,
570
664
  storage: WebStorage,
571
665
  sqlite: SQLite
@@ -932,13 +1026,8 @@ var Store = class _Store {
932
1026
  async prune() {
933
1027
  await this.promise;
934
1028
  if (this.client.HAS_EXPIRATION) return;
935
- for await (const [name, data] of this.client.iterate(
936
- this.PREFIX
937
- )) {
938
- const key = name.slice(this.PREFIX.length);
939
- if (!this.#isFresh(data, key)) {
940
- await this.del(key);
941
- }
1029
+ if (this.client.prune) {
1030
+ await this.client.prune();
942
1031
  }
943
1032
  }
944
1033
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polystore",
3
- "version": "0.19.0",
3
+ "version": "0.20.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",
@@ -32,6 +32,8 @@
32
32
  "test:bun": "bun test ./test/index.test.ts",
33
33
  "test:jest": "jest ./test/index.test.ts --detectOpenHandles --forceExit",
34
34
  "run:db": "etcd",
35
+ "run:redis": "brew services start redis",
36
+ "run:postgres": "brew services start postgresql",
35
37
  "run:server": "bun ./src/server.ts"
36
38
  },
37
39
  "keywords": [
@@ -49,6 +51,7 @@
49
51
  "@types/bun": "^1.3.3",
50
52
  "@types/jest": "^30.0.0",
51
53
  "@types/jsdom": "^27.0.0",
54
+ "@types/pg": "^8.11.10",
52
55
  "better-sqlite3": "^12.6.0",
53
56
  "check-dts": "^0.8.0",
54
57
  "cross-fetch": "^4.1.0",
@@ -61,6 +64,7 @@
61
64
  "jsdom": "^27.2.0",
62
65
  "level": "^8.0.1",
63
66
  "localforage": "^1.10.0",
67
+ "pg": "^8.13.1",
64
68
  "redis": "^4.6.10",
65
69
  "ts-jest": "^29.4.6",
66
70
  "ts-node": "^10.9.2",
package/readme.md CHANGED
@@ -631,6 +631,25 @@ A client is the library that manages the low-level store operations. For example
631
631
 
632
632
  Polystore provides a unified API you can use `Promises`, `expires` and `.prefix()` even with those stores that do not support these operations natively.
633
633
 
634
+ Quick overview:
635
+
636
+ | Client | Runtime | Persistence | Native expiration | Notes |
637
+ |---|---|---|---|---|
638
+ | [Memory](#memory) | Node.js + Browser | ❌ | ❌ | Great for tests and ephemeral caches |
639
+ | [Local Storage](#local-storage) | Browser | ✅ | ❌ | Persistent browser storage |
640
+ | [Session Storage](#session-storage) | Browser | ❌ | ❌ | Cleared when tab/session ends |
641
+ | [Cookies](#cookies) | Browser | ✅ | ✅ | Browser-side cookies |
642
+ | [Local Forage](#local-forage) | Browser | ✅ | ❓ | Better capacity than localStorage |
643
+ | [Redis](#redis) | Node.js | ✅ | ✅ | Good distributed cache backend |
644
+ | [SQLite](#sqlite) | Node.js | ✅ | ❌ | Simple local persistence |
645
+ | [Fetch API](#fetch-api) | Any with `fetch` | ❓ | ❓ | Bring your own KV HTTP API |
646
+ | [File](#file) | Node.js | ✅ | ❌ | Single JSON file store |
647
+ | [Folder](#folder) | Node.js | ✅ | ❌ | One-file-per-key store |
648
+ | [Cloudflare KV](#cloudflare-kv) | Cloudflare | ✅ | ✅ | Edge-native KV |
649
+ | [Level](#level) | Node.js | ✅ | ❌ | Uses Level ecosystem |
650
+ | [Etcd](#etcd) | Node.js | ✅ | ✅ | Distributed KV |
651
+ | [Postgres](#postgres) | Node.js | ✅ | ❌ | Table-backed KV |
652
+
634
653
  While you can keep a reference to the client and access it directly, we strongly recommend to only access it through `polystore`, since we might add custom serialization and extra properties for e.g. expiration time:
635
654
 
636
655
  ```js
@@ -1164,28 +1183,32 @@ console.log(await store.get("key1"));
1164
1183
  // "Hello world"
1165
1184
  ```
1166
1185
 
1167
- You can also use `pg.Pool` instead of `pg.Client` for connection pooling.
1186
+ Polystore will initialize the schema automatically: it creates the `kv` table and expiration index if they do not exist yet, and does not fail if they already exist.
1168
1187
 
1169
- Your database needs a table with three columns: `id` (text), `value` (text), and `expiresAt` (timestamp, nullable):
1188
+ Required schema (auto-created by Polystore):
1170
1189
 
1171
1190
  ```sql
1172
- CREATE TABLE kv (
1191
+ CREATE TABLE IF NOT EXISTS kv (
1173
1192
  id TEXT PRIMARY KEY,
1174
1193
  value TEXT NOT NULL,
1175
1194
  "expiresAt" TIMESTAMP
1176
1195
  );
1196
+
1197
+ CREATE INDEX IF NOT EXISTS idx_kv_expiresAt
1198
+ ON kv ("expiresAt");
1177
1199
  ```
1178
1200
 
1179
- The default table name is `kv`, but you can use different tables via `.prefix()`:
1201
+ The default table name is `kv`. Key prefixes still work as normal key namespaces:
1180
1202
 
1181
1203
  ```js
1182
- const sessions = store.prefix("session:"); // Uses 'session' table
1183
- const cache = store.prefix("cache:"); // Uses 'cache' table
1204
+ const sessions = store.prefix("session:");
1205
+ const cache = store.prefix("cache:");
1184
1206
 
1185
1207
  await sessions.set("user123", { name: "Alice" });
1208
+ // Stored key in Postgres: "session:user123"
1186
1209
  ```
1187
1210
 
1188
- This maps prefixes to table names for better performance on group operations.
1211
+ This keeps a single table while preserving namespace-style grouping through prefixed keys.
1189
1212
 
1190
1213
  <details>
1191
1214
  <summary>Why use polystore with Postgres?</summary>
@@ -1202,7 +1225,9 @@ This maps prefixes to table names for better performance on group operations.
1202
1225
 
1203
1226
  Please see the [creating a store](#creating-a-store) section for all the details!
1204
1227
 
1205
- ## Performance
1228
+ ## Guides
1229
+
1230
+ ### Performance
1206
1231
 
1207
1232
  > 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! Otherwise, please read on.
1208
1233
 
@@ -1214,7 +1239,7 @@ While all of our stores support `expires`, `.prefix()` and group operations, the
1214
1239
 
1215
1240
  **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.
1216
1241
 
1217
- ## Expirations
1242
+ ### Expirations
1218
1243
 
1219
1244
  > 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.
1220
1245
 
@@ -1258,7 +1283,7 @@ These are all the units available:
1258
1283
 
1259
1284
  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()`.
1260
1285
 
1261
- ### Eviction
1286
+ #### Eviction
1262
1287
 
1263
1288
  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.
1264
1289
 
@@ -1266,7 +1291,7 @@ For other stores like Redis this is not a problem, because the low-level operati
1266
1291
 
1267
1292
  These details are explained in the respective client information.
1268
1293
 
1269
- ## Substores
1294
+ ### Substores
1270
1295
 
1271
1296
  > There's some [basic `.prefix()` API info](#prefix) for everyday usage, this section is the in-depth explanation.
1272
1297
 
@@ -1280,7 +1305,46 @@ When dealing with large or complex amounts of data in a KV store, sometimes it's
1280
1305
 
1281
1306
  For these and more situations, you can use `.prefix()` to simplify your life further.
1282
1307
 
1283
- ## Creating a store
1308
+ ### Error Handling
1309
+
1310
+ Polystore methods return promises and surface errors from the underlying client. A good rule of thumb is to treat errors in three categories:
1311
+
1312
+ 1. **Connectivity/runtime errors**
1313
+ Network/database/filesystem/client runtime failures (for example Redis unavailable, failed fetch, permission denied on files).
1314
+
1315
+ 2. **Data/serialization errors**
1316
+ Invalid JSON payloads, invalid value encoding, or data that was written outside Polystore and cannot be decoded with its metadata expectations.
1317
+
1318
+ 3. **Usage/configuration errors**
1319
+ Invalid client setup, invalid URLs/paths, or unsupported operations in a specific runtime.
1320
+
1321
+ Recommended patterns:
1322
+
1323
+ - Use `try/catch` around all write/read operations in production paths.
1324
+ - Prefer returning safe fallbacks for cache-like usage (`null`, stale response, or refetch).
1325
+ - Log enough context (`client type`, `key`, operation name) without logging sensitive values.
1326
+ - For remote clients, consider retry/backoff only for transient failures.
1327
+ - Call `.close()` during shutdown when the client supports it.
1328
+
1329
+ Example:
1330
+
1331
+ ```js
1332
+ const key = `user:${userId}`;
1333
+
1334
+ try {
1335
+ const cached = await store.get(key);
1336
+ if (cached) return cached;
1337
+
1338
+ const fresh = await fetchUserFromAPI(userId);
1339
+ await store.set(key, fresh, { expires: "10min" });
1340
+ return fresh;
1341
+ } catch (err) {
1342
+ console.error("polystore error", { key, err });
1343
+ return fetchUserFromAPI(userId);
1344
+ }
1345
+ ```
1346
+
1347
+ ### Creating a store
1284
1348
 
1285
1349
  To create a store, you define a class with these properties and methods:
1286
1350
 
@@ -1341,6 +1405,10 @@ client.keys = (prefix) => {
1341
1405
 
1342
1406
  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).
1343
1407
 
1408
+
1409
+
1410
+ ## Examples
1411
+
1344
1412
  ### Plain Object client
1345
1413
 
1346
1414
  This is a good example of how simple a store can be, however do not use it literally since it behaves the same as the already-supported `new Map()`, only use it as the base for your own clients:
@@ -1371,7 +1439,7 @@ class MyClient {
1371
1439
 
1372
1440
  We don't set `HAS_EXPIRATION` to true since plain objects do NOT support expiration natively. So by not adding the `HAS_EXPIRATION` property, it's the same as setting it to `false`, and polystore will manage all the expirations as a layer on top of the data. We could be more explicit and set it to `HAS_EXPIRATION = false`, but it's not needed in this case.
1373
1441
 
1374
- ### Example: custom ID generation
1442
+ ### Custom ID generation
1375
1443
 
1376
1444
  You might want to provide your custom key generation algorithm, which I'm going to call `customId()` for example purposes. The only place where `polystore` generates IDs is in `add`, so you can provide your client with a custom generator:
1377
1445
 
@@ -1404,7 +1472,7 @@ const id2 = await store.prefix("hello:").add({ hello: "world" });
1404
1472
  // this is `hello:{your own custom id}`
1405
1473
  ```
1406
1474
 
1407
- ### Example: serializing the data
1475
+ ### Serializing the data
1408
1476
 
1409
1477
  If you need to serialize the data before storing it, you can do it within your custom client. Here's an example of how you can handle data serialization when setting values:
1410
1478
 
@@ -1429,7 +1497,7 @@ class MyClient {
1429
1497
  }
1430
1498
  ```
1431
1499
 
1432
- ### Example: Cloudflare API calls
1500
+ ### Cloudflare API calls
1433
1501
 
1434
1502
  In this example on one of my projects, I needed to use Cloudflare's REST API since I didn't have access to any KV store I was happy with on Netlify's Edge Functions. So I created it like this:
1435
1503
 
@@ -1501,9 +1569,6 @@ const store = kv(CloudflareCustom);
1501
1569
 
1502
1570
  It's lacking a few things, so make sure to adapt to your needs, but it worked for my very simple cache needs.
1503
1571
 
1504
-
1505
- ## Examples
1506
-
1507
1572
  ### Simple cache
1508
1573
 
1509
1574
  I've used Polystore in many projects as a simple cache. With `fetch()`, it's fairly easy:
@@ -1514,12 +1579,9 @@ async function getProductInfo(id: string) {
1514
1579
  if (data) return data;
1515
1580
 
1516
1581
  const res = await fetch(`https://some-url.com/products/${id}`);
1517
- const raw = await res.json();
1518
-
1519
- // Some processing here
1520
- const clean = raw??;
1582
+ const data = await res.json();
1521
1583
 
1522
- await store.set(id, clean, { expires: "10days" });
1584
+ await store.set(id, data, { expires: "10days" });
1523
1585
  return clean;
1524
1586
  }
1525
1587
  ```