pixl-server-storage 4.2.1 → 4.3.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/README.md CHANGED
@@ -56,10 +56,16 @@ Here is the table of contents for this current document:
56
56
  + [S3 Cache](#s3-cache)
57
57
  + [S3 Compatible Services](#s3-compatible-services)
58
58
  * [Redis](#redis)
59
+ + [Redis Auth and TLS](#redis-auth-and-tls)
59
60
  + [RedisCluster](#rediscluster)
61
+ - [RedisCluster Auth and TLS](#rediscluster-auth-and-tls)
60
62
  * [SQLite](#sqlite)
61
63
  * [Postgres](#postgres)
64
+ + [Postgres SSL](#postgres-ssl)
65
+ + [AWS RDS](#aws-rds)
66
+ + [Postgres Password Plugin](#postgres-password-plugin)
62
67
  * [Hybrid](#hybrid)
68
+ - [Cache Buffer Copying](#cache-buffer-copying)
63
69
  - [Key Normalization](#key-normalization)
64
70
  - [Basic Functions](#basic-functions)
65
71
  * [Storing Records](#storing-records)
@@ -412,8 +418,9 @@ The properties are as follows:
412
418
  | `enabled` | Boolean | Set this to `true` to enable the filesystem caching system. |
413
419
  | `maxItems` | Integer | This is the maximum number of objects to allow in the cache. |
414
420
  | `maxBytes` | Integer | This is the maximum number of bytes to allow in the cache. |
421
+ | `copyBuffers` | Boolean | Defaults to `true`. Stores independent, unpooled copies of cached Buffers. Set to `false` to opt out. See [Cache Buffer Copying](#cache-buffer-copying). |
415
422
 
416
- The cache will automatically expire objects in LRU fashion when either of the limits are exceeded (whichever is hit first). Set the properties to `0` for no limit.
423
+ The cache will automatically expire objects in LRU fashion when either of the limits are exceeded (whichever is hit first). Set either limit to `0` for no limit.
417
424
 
418
425
  Note that binary records are **not** cached. This system is for JSON records only.
419
426
 
@@ -513,8 +520,9 @@ The properties are as follows:
513
520
  | `enabled` | Boolean | Set this to `true` to enable the S3 caching system. |
514
521
  | `maxItems` | Integer | This is the maximum number of objects to allow in the cache. |
515
522
  | `maxBytes` | Integer | This is the maximum number of bytes to allow in the cache. |
523
+ | `copyBuffers` | Boolean | Defaults to `true`. Stores independent, unpooled copies of cached Buffers. Set to `false` to opt out. See [Cache Buffer Copying](#cache-buffer-copying). |
516
524
 
517
- The cache will automatically expire objects in LRU fashion when either of the limits are met (whichever is reached first). Set the properties to `0` for no limit.
525
+ The cache will automatically expire objects in LRU fashion when either of the limits are met (whichever is reached first). Set either limit to `0` for no limit.
518
526
 
519
527
  It is recommended that you set the `maxItems` and `maxBytes` high enough to allow new data written to live for *at least* several seconds before getting expired out of the cache. This depends on the overall storage throughput of your application, but 1,000 max items and 10 MB max bytes is probably fine for most use cases.
520
528
 
@@ -590,6 +598,8 @@ Then configure your storage thusly:
590
598
  "Redis": {
591
599
  "host": "127.0.0.1",
592
600
  "port": 6379,
601
+ "username": "",
602
+ "password": "",
593
603
  "keyPrefix": "",
594
604
  "keyTemplate": "",
595
605
  "cache": {
@@ -603,6 +613,58 @@ Then configure your storage thusly:
603
613
 
604
614
  Set the `host` and `port` for your own Redis server setup. Please see [Common Redis Options](https://redis.github.io/ioredis/interfaces/CommonRedisOptions.html) for other things you can include here, such as timeouts, authentication and database selection.
605
615
 
616
+ ### Redis Auth and TLS
617
+
618
+ For production, Redis is usually deployed on a private network, firewalled to trusted clients, and protected with authentication as an extra layer of safety. Modern Redis ACL users use both `username` and `password`.
619
+
620
+ Configure `username` and `password` like this:
621
+
622
+ ```json
623
+ {
624
+ "engine": "Redis",
625
+ "Redis": {
626
+ "host": "redis.example.com",
627
+ "port": 6379,
628
+ "username": "default",
629
+ "password": "your-redis-password"
630
+ }
631
+ }
632
+ ```
633
+
634
+ Redis `AUTH` is sent over the Redis connection itself, so use TLS too when traffic can cross an untrusted network, a shared network, or a managed provider endpoint that requires encryption. The Redis engine passes extra config properties through to [ioredis](https://github.com/redis/ioredis), so you can enable TLS with the `tls` option:
635
+
636
+ ```json
637
+ {
638
+ "engine": "Redis",
639
+ "Redis": {
640
+ "host": "redis.example.com",
641
+ "port": 6380,
642
+ "username": "default",
643
+ "password": "your-redis-password",
644
+ "tls": {}
645
+ }
646
+ }
647
+ ```
648
+
649
+ For a private CA or self-signed certificate, pass TLS options such as `ca`, `cert`, `key` or `servername` inside the `tls` object. These are passed through to Node.js TLS. If you store PEM text directly in JSON, preserve line breaks as `\n`:
650
+
651
+ ```json
652
+ {
653
+ "engine": "Redis",
654
+ "Redis": {
655
+ "host": "redis.example.com",
656
+ "port": 6380,
657
+ "username": "default",
658
+ "password": "your-redis-password",
659
+ "tls": {
660
+ "ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
661
+ }
662
+ }
663
+ }
664
+ ```
665
+
666
+ Please avoid `tls: { "rejectUnauthorized": false }` for production use. It can be useful as a short diagnostic test, but it disables certificate verification.
667
+
606
668
  The optional `keyPrefix` property works similarly to the [S3 Key Prefix](#s3-key-prefix) feature. It allows you to prefix all the Redis keys with a common string, to separate your application's data in a shared database situation.
607
669
 
608
670
  The optional `keyTemplate` property works similarly to the [S3 Key Template](#s3-key-template) feature. It allows you to specify an exact layout of MD5 hash characters, which can be prefixed, mixed in with or postfixed after the key.
@@ -648,6 +710,53 @@ Set the `host` and `port` for your own Redis cluster setup. The `host` should p
648
710
 
649
711
  The `clusterOpts` property can hold several different cluster configuration options. Please see [Cluster Options](https://redis.github.io/ioredis/interfaces/ClusterOptions.html) for other things you can include here, such as `scaleReads` and `redisOptions`. For the `redisOptions` object, please see [Common Redis Options](https://redis.github.io/ioredis/interfaces/CommonRedisOptions.html) for other things you can include, such as timeouts, authentication and database selection.
650
712
 
713
+ #### RedisCluster Auth and TLS
714
+
715
+ For RedisCluster, shared authentication and TLS options should go inside `clusterOpts.redisOptions`, because those options are passed to every Redis node connection created by ioredis:
716
+
717
+ ```json
718
+ {
719
+ "engine": "RedisCluster",
720
+ "RedisCluster": {
721
+ "host": "clustercfg.redis.example.com",
722
+ "port": 6379,
723
+ "clusterOpts": {
724
+ "scaleReads": "master",
725
+ "redisOptions": {
726
+ "commandTimeout": 5000,
727
+ "connectTimeout": 5000,
728
+ "username": "default",
729
+ "password": "your-cluster-password"
730
+ }
731
+ }
732
+ }
733
+ }
734
+ ```
735
+
736
+ To connect to a TLS-enabled Redis cluster, add `tls` under `redisOptions`:
737
+
738
+ ```json
739
+ {
740
+ "engine": "RedisCluster",
741
+ "RedisCluster": {
742
+ "host": "clustercfg.redis.example.com",
743
+ "port": 6380,
744
+ "clusterOpts": {
745
+ "scaleReads": "master",
746
+ "redisOptions": {
747
+ "commandTimeout": 5000,
748
+ "connectTimeout": 5000,
749
+ "username": "default",
750
+ "password": "your-cluster-password",
751
+ "tls": {}
752
+ }
753
+ }
754
+ }
755
+ }
756
+ ```
757
+
758
+ AWS ElastiCache for Redis commonly uses this pattern when authentication and in-transit encryption are enabled. Use `username` and `password` for ACL users.
759
+
651
760
  It is **highly recommended** that you keep the `scaleReads` property set to `"master"`, for immediate consistency (required for [Lists](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Lists.md), [Hashes](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Hashes.md), [Transactions](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Transactions.md) and the [Indexer](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Indexer.md)).
652
761
 
653
762
  The optional `keyPrefix` property works similarly to the [S3 Key Prefix](#s3-key-prefix) feature. It allows you to prefix all the Redis keys with a common string, to separate your application's data in a shared database situation.
@@ -752,7 +861,156 @@ You can customize the table name via the `table` configuration property. It def
752
861
 
753
862
  [Connection Pooling](https://node-postgres.com/features/pooling) is utilized to open many simultaneous connections to greatly increase throughput. Set the `max` property to the maximum number of concurrent connections your database can handle. Ideally this should be set to the global [concurrency](#concurrency) setting.
754
863
 
755
- See [S3 Cache](#s3-cache) for details on the `cache` section, as it works in the same way.
864
+ ### Postgres SSL
865
+
866
+ The Postgres engine passes all extra properties in the `Postgres` configuration block directly into the [pg.Pool constructor](https://node-postgres.com/apis/pool), except for `table`, `cache`, `passwordPlugin`, and `passwordPluginTimeout`, which are used by pixl-server-storage itself. This means you can use the standard [node-postgres SSL options](https://node-postgres.com/features/ssl) without any special pixl-server-storage code.
867
+
868
+ For a server with a certificate trusted by Node.js already, you can enable SSL by simply adding `"ssl":true` like this:
869
+
870
+ ```json
871
+ {
872
+ "engine": "Postgres",
873
+ "Postgres": {
874
+ "host": "postgres.example.com",
875
+ "database": "postgres",
876
+ "user": "postgres",
877
+ "password": "postgres",
878
+ "port": 5432,
879
+ "ssl": true
880
+ }
881
+ }
882
+ ```
883
+
884
+ For a private CA, self-signed server certificate, or hosted database that requires its own CA bundle, pass an `ssl` object. This object is passed through to Node.js TLS, so you can provide `ca`, `cert`, `key`, `servername`, and other supported TLS options. If you store PEM text directly in JSON, preserve line breaks as `\n` (standard JSON string encoding):
885
+
886
+ ```json
887
+ {
888
+ "engine": "Postgres",
889
+ "Postgres": {
890
+ "host": "postgres.example.com",
891
+ "database": "postgres",
892
+ "user": "postgres",
893
+ "password": "postgres",
894
+ "port": 5432,
895
+ "ssl": {
896
+ "ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
897
+ }
898
+ }
899
+ }
900
+ ```
901
+
902
+ If you need to load the CA certificate from disk, you can also use a `connectionString` with the standard Postgres SSL parameters. The `pg` module will read `sslrootcert` and use it as the TLS CA:
903
+
904
+ ```json
905
+ {
906
+ "engine": "Postgres",
907
+ "Postgres": {
908
+ "connectionString": "postgres://postgres:postgres@postgres.example.com:5432/postgres?sslmode=verify-full&sslrootcert=/path/to/root-ca.pem",
909
+ "max": 32,
910
+ "table": "items"
911
+ }
912
+ }
913
+ ```
914
+
915
+ If you put SSL parameters such as `sslmode`, `sslcert`, `sslkey`, or `sslrootcert` in the connection string, avoid also setting a separate `ssl` object. The `pg` connection string parser replaces the `ssl` object when those URL parameters are present.
916
+
917
+ Please avoid `ssl: { "rejectUnauthorized": false }` for production use. It can be useful as a short diagnostic test, but it disables certificate verification.
918
+
919
+ ### AWS RDS
920
+
921
+ Amazon RDS for PostgreSQL works well with this setup. Download the appropriate RDS CA bundle from the [AWS RDS SSL/TLS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html), place it somewhere readable by your app, and use `sslmode=verify-full` with `sslrootcert`:
922
+
923
+ ```json
924
+ {
925
+ "engine": "Postgres",
926
+ "Postgres": {
927
+ "connectionString": "postgres://xyops:secret@mydb.abc123.us-east-1.rds.amazonaws.com:5432/xyops?sslmode=verify-full&sslrootcert=/opt/xyops/conf/rds-ca.pem",
928
+ "max": 32,
929
+ "table": "items"
930
+ }
931
+ }
932
+ ```
933
+
934
+ AWS has a few quirks worth knowing:
935
+
936
+ * RDS for PostgreSQL 15 and newer enables `rds.force_ssl` by default, so non-SSL connections may be rejected with an error similar to `no pg_hba.conf entry ... SSL off`.
937
+ * `sslmode=verify-full` verifies both the certificate chain and the hostname. Use the actual RDS endpoint hostname in your connection string when possible.
938
+ * If you connect through a custom DNS name, SSH tunnel, or local proxy, hostname verification may need the original RDS endpoint as the TLS `servername` option. In that case, prefer explicit config properties and an `ssl` object instead of URL SSL parameters.
939
+ * AWS recommends trusting the root RDS CA certificate. Avoid pinning intermediate certificates, as that can cause trouble when RDS rotates server certificates.
940
+
941
+ ### Postgres Password Plugin
942
+
943
+ If your Postgres password needs to be generated dynamically, such as with a cloud provider token service, you can configure a password plugin command. This uses the [node-postgres dynamic password callback](https://node-postgres.com/features/connecting), so the command is called whenever the pool needs a new physical database connection.
944
+
945
+ ```json
946
+ {
947
+ "engine": "Postgres",
948
+ "Postgres": {
949
+ "host": "mypg.postgres.example.com",
950
+ "database": "mydb",
951
+ "user": "my-app-user",
952
+ "ssl": true,
953
+ "passwordPlugin": "npx -y pxpg-plug-example@1.0.0"
954
+ }
955
+ }
956
+ ```
957
+
958
+ In this example the command uses the [npx](https://docs.npmjs.com/cli/commands/npx) CLI to automatically download and run a NPM package. The package is also cached on local disk (in `~/.npm`) for fast subsequent runs.
959
+
960
+ The command receives one compact JSON document on STDIN, delimited by an EOL (pretty-printed here for display purposes):
961
+
962
+ ```json
963
+ {
964
+ "type": "postgres_password",
965
+ "config": {
966
+ "host": "mypg.postgres.example.com",
967
+ "database": "mydb",
968
+ "user": "my-app-user",
969
+ "ssl": true
970
+ }
971
+ }
972
+ ```
973
+
974
+ The `config` object contains the Postgres configuration with `password`, `passwordPlugin`, and `passwordPluginTimeout` removed.
975
+
976
+ The command should print one compact JSON document to STDOUT. Extra logging is allowed, as pixl-server-storage only parses the last line of STDOUT:
977
+
978
+ ```json
979
+ {
980
+ "password": "GENERATED_PASSWORD_OR_TOKEN",
981
+ "ttl": 3000
982
+ }
983
+ ```
984
+
985
+ The `password` property is required, and must be a string. The optional `ttl` property is a cache duration in seconds. If `ttl` is greater than zero, pixl-server-storage caches the returned password in memory and reuses it for subsequent new database connections until the TTL expires. If `ttl` is omitted or zero, the command is invoked for every new database connection.
986
+
987
+ If the plugin cannot produce a password, it can return an error:
988
+
989
+ ```json
990
+ {
991
+ "code": "auth",
992
+ "description": "Could not fetch provider token."
993
+ }
994
+ ```
995
+
996
+ The default plugin timeout is 30 seconds. You can customize it with `passwordPluginTimeout`, in milliseconds:
997
+
998
+ ```json
999
+ {
1000
+ "engine": "Postgres",
1001
+ "Postgres": {
1002
+ "host": "mypg.postgres.example.com",
1003
+ "database": "mydb",
1004
+ "user": "my-app-user",
1005
+ "passwordPlugin": "/usr/local/bin/px-pg-plug-example",
1006
+ "passwordPluginTimeout": 10000
1007
+ }
1008
+ }
1009
+ ```
1010
+
1011
+ For production, consider preinstalling the plugin and pointing `passwordPlugin` at the installed executable. This avoids starting `npx` and checking the NPM registry whenever the cached password expires.
1012
+
1013
+ See [S3 Cache](#s3-cache) for details on the `cache` section, as Postgres works in the same way.
756
1014
 
757
1015
  Note that pooling and caching have little effect when the database is running locally (i.e. localhost). They offer much more benefit when the database is located remotely over some number of network hops.
758
1016
 
@@ -800,6 +1058,31 @@ Note that all of the engine configuration objects are on the same level as the `
800
1058
 
801
1059
  When transactions are enabled, the `Hybrid` engine routes transaction commits through the configured `docEngine`, because pixl-server-storage transactions only apply to JSON records. If the `docEngine` supports native transactions, such as Postgres, Redis or SQLite, `Hybrid` will use that native commit path automatically. The `binaryEngine` is not involved in transactions, because binary keys are passed straight through.
802
1060
 
1061
+ # Cache Buffer Copying
1062
+
1063
+ All six engine caches (`Filesystem`, `S3`, `Redis`, `RedisCluster`, `SQLite`, and `Postgres`) default to `copyBuffers: true`. This applies whenever the engine's cache is enabled with `cache.enabled: true`; existing cache configurations receive the new default automatically.
1064
+
1065
+ These caches store serialized JSON records as Buffers. A small Buffer can share a much larger memory allocation with other Buffers, and keeping it in the cache can retain that entire allocation. Node.js 24.18.0 increased the default Buffer pool size from 8 KiB to 64 KiB, making this retention more costly. See the [Node.js 24.18.0 release notes](https://nodejs.org/en/blog/release/v24.18.0).
1066
+
1067
+ The Postgres report in [issue #8](https://github.com/jhuckaby/pixl-server-storage/issues/8) demonstrated how retained backing allocations could greatly exceed the cached payload size. The same retention pattern can affect all six engines, so Buffer copying is enabled by default across their caches. Each cached Buffer gets its own unpooled copy when added or replaced, preventing it from retaining an oversized shared allocation. This adds an allocation and byte copy on insertion, but cache hits do not make additional Buffer copies. The `maxBytes` setting still measures cache key and value sizes, rather than total process memory.
1068
+
1069
+ If you need to avoid the extra copying, set `copyBuffers` to `false` inside your engine's `cache` configuration. For example, merge this fragment into your existing `Storage` configuration for Postgres:
1070
+
1071
+ ```json
1072
+ {
1073
+ "Postgres": {
1074
+ "cache": {
1075
+ "enabled": true,
1076
+ "maxItems": 100000,
1077
+ "maxBytes": 104857600,
1078
+ "copyBuffers": false
1079
+ }
1080
+ }
1081
+ }
1082
+ ```
1083
+
1084
+ Use the same `cache.copyBuffers` property inside any of the other five engine configuration blocks. Caching remains enabled in this example, while disabling Buffer copying restores the previous retention behavior. Omit `copyBuffers`, or set it to `true`, to use the recommended default.
1085
+
803
1086
  # Key Normalization
804
1087
 
805
1088
  In order to maintain compatibility with all the various engines, keys are "normalized" on all entry points. Specifically, they undergo the following transformations before being passed along to the engine:
package/docs/API.md CHANGED
@@ -24,6 +24,7 @@ let storage = server.Storage;
24
24
  * [headMulti](#headmulti)
25
25
  * [delete](#delete)
26
26
  * [deleteMulti](#deletemulti)
27
+ * [deleteAll](#deleteall)
27
28
  * [copy](#copy)
28
29
  * [rename](#rename)
29
30
  * [lock](#lock)
@@ -328,6 +329,22 @@ storage.deleteMulti( ['test1', 'test2', 'test3'], function(err) {
328
329
  } );
329
330
  ```
330
331
 
332
+ Note that this function returns immediately if it fails to delete any key in the specified set.
333
+
334
+ ## deleteAll
335
+
336
+ ```js
337
+ storage.deleteAll( KEYS, CALLBACK );
338
+ ```
339
+
340
+ The `deleteAll()` method deletes multiple records at once, from a specified array of keys. This is similar to [deleteMulti()](#deletemulti), except it does not stop on the first error. Each delete error is logged, and the remaining keys are still attempted. Example:
341
+
342
+ ```js
343
+ storage.deleteAll( ['test1', 'test2', 'test3'], function(err) {
344
+ if (err) throw err;
345
+ } );
346
+ ```
347
+
331
348
  ## copy
332
349
 
333
350
  ```js
@@ -80,7 +80,7 @@ module.exports = Class.create({
80
80
  var cache_opts = this.config.get('cache');
81
81
  if (cache_opts && cache_opts.enabled) {
82
82
  this.logDebug(3, "Setting up LRU cache", cache_opts);
83
- this.cache = new Cache( Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) );
83
+ this.cache = new Cache({ copyBuffers: true, ...Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) });
84
84
  this.cache.on('expire', function(item, reason) {
85
85
  self.logDebug(9, "Expiring LRU cache object: " + item.key + " due to: " + reason, {
86
86
  key: item.key,
@@ -7,6 +7,8 @@
7
7
  const fs = require('fs');
8
8
  const Path = require('path');
9
9
  const zlib = require('zlib');
10
+ const os = require('os');
11
+ const cp = require('child_process');
10
12
  const Class = require("pixl-class");
11
13
  const Component = require("pixl-server/component");
12
14
  const pg = require('pg');
@@ -33,6 +35,8 @@ module.exports = Class.create({
33
35
  query_timeout: 6000,
34
36
  connectionTimeoutMillis: 30000,
35
37
  idleTimeoutMillis: 10000,
38
+ passwordPlugin: "",
39
+ passwordPluginTimeout: 30000,
36
40
  table: "items"
37
41
  },
38
42
 
@@ -63,7 +67,7 @@ module.exports = Class.create({
63
67
  var cache_opts = pg_config.cache;
64
68
  if (cache_opts && cache_opts.enabled) {
65
69
  this.logDebug(3, "Setting up LRU cache", cache_opts);
66
- this.cache = new Cache( Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) );
70
+ this.cache = new Cache({ copyBuffers: true, ...Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) });
67
71
  this.cache.on('expire', function(item, reason) {
68
72
  self.logDebug(9, "Expiring LRU cache object: " + item.key + " due to: " + reason, {
69
73
  key: item.key,
@@ -75,9 +79,16 @@ module.exports = Class.create({
75
79
  }
76
80
 
77
81
  // pass entire config to pg.Pool, sans our custom keys
78
- var db = this.db = new pg.Pool(
79
- Tools.copyHashRemoveKeys( pg_config, { cache: 1, table: 1 })
80
- );
82
+ var pool_config = Tools.copyHashRemoveKeys( pg_config, { cache: 1, table: 1, passwordPlugin: 1, passwordPluginTimeout: 1 } );
83
+
84
+ if (pg_config.passwordPlugin) {
85
+ // node-postgres supports password as a sync/async callback, called for each
86
+ // new physical connection. We use that hook to ask an external command for
87
+ // short-lived provider tokens without baking provider-specific code in here.
88
+ pool_config.password = this.getPasswordFromPlugin.bind(this);
89
+ }
90
+
91
+ var db = this.db = new pg.Pool( pool_config );
81
92
 
82
93
  db.on('error', function(err) {
83
94
  self.logError('db', "DB Error: " + err, err);
@@ -104,6 +115,89 @@ module.exports = Class.create({
104
115
  this.queryWithClient( this.db, sql, args, callback );
105
116
  },
106
117
 
118
+ getPasswordFromPlugin: function() {
119
+ // execute an external command to resolve the Postgres password
120
+ // the plugin may also return a TTL, allowing us to cache it in memory
121
+ var self = this;
122
+ var pg_config = this.config.get();
123
+ var now = Date.now();
124
+
125
+ if (this.passwordPluginCache && (now < this.passwordPluginCache.expires)) {
126
+ return Promise.resolve( this.passwordPluginCache.password );
127
+ }
128
+
129
+ // If a plugin call is already in-flight, return the same Promise.
130
+ // This coalesces simultaneous pool reconnects into one plugin exec.
131
+ if (this.passwordPluginFetch) return this.passwordPluginFetch;
132
+
133
+ var hook_args = {
134
+ type: 'postgres_password',
135
+ config: Tools.copyHashRemoveKeys( pg_config, { password: 1, passwordPlugin: 1, passwordPluginTimeout: 1 } )
136
+ };
137
+
138
+ var child_cmd = pg_config.passwordPlugin;
139
+ var child_opts = {
140
+ cwd: os.tmpdir(),
141
+ timeout: pg_config.passwordPluginTimeout || 30000
142
+ };
143
+
144
+ this.logDebug(5, "Calling Postgres password plugin");
145
+
146
+ this.passwordPluginFetch = new Promise( function(resolve, reject) {
147
+ var child = cp.exec( child_cmd, child_opts, function(err, stdout, stderr) {
148
+ var json = null;
149
+
150
+ if (!err && stdout.match(/\S/)) {
151
+ // parse last line only, to omit any noise from plugin
152
+ try { json = JSON.parse( stdout.replace(/\r\n/g, "\n").trim().split(/\n/).pop() ); }
153
+ catch (e) {
154
+ err = new Error("Postgres password plugin JSON parse error: " + (e.message || e));
155
+ err.code = 'json';
156
+ }
157
+ }
158
+
159
+ if (stderr && stderr.match(/\S/)) {
160
+ self.logDebug(9, "Postgres password plugin emitted STDERR: " + Buffer.byteLength(stderr) + " bytes");
161
+ }
162
+
163
+ if (err) return reject( err );
164
+ if (!json) return reject( new Error("Postgres password plugin error: No JSON found in response STDOUT") );
165
+ if (json.code) return reject( new Error("Postgres password plugin error: " + (json.description || json.code)) );
166
+ if (typeof(json.password) != 'string') return reject( new Error("Postgres password plugin error: No password string found in response JSON") );
167
+
168
+ var ttl = parseInt( json.ttl || 0, 10 ) || 0;
169
+
170
+ if (ttl > 0) {
171
+ self.passwordPluginCache = {
172
+ password: json.password,
173
+ expires: Date.now() + (ttl * 1000)
174
+ };
175
+
176
+ self.logDebug(6, "Postgres password plugin returned password with TTL: " + ttl + " seconds");
177
+ }
178
+ else {
179
+ self.passwordPluginCache = null;
180
+ self.logDebug(6, "Postgres password plugin returned password without TTL");
181
+ }
182
+
183
+ resolve( json.password );
184
+ } ); // cp.exec
185
+
186
+ // Write hook data to child's stdin, using one compact JSON document.
187
+ child.stdin.on('error', noop);
188
+ child.stdin.write( JSON.stringify(hook_args) + "\n" );
189
+ child.stdin.end();
190
+ } ).then( function(password) {
191
+ self.passwordPluginFetch = null;
192
+ return password;
193
+ }, function(err) {
194
+ self.passwordPluginFetch = null;
195
+ throw err;
196
+ } );
197
+
198
+ return this.passwordPluginFetch;
199
+ },
200
+
107
201
  queryWithClient: function(client, sql, args, callback) {
108
202
  // run and log db query, and error if applicable
109
203
  // allow for named variables in sql, expand to numbered $ variables for pg
package/engines/Redis.js CHANGED
@@ -54,7 +54,7 @@ module.exports = Class.create({
54
54
  var cache_opts = r_config.cache;
55
55
  if (cache_opts && cache_opts.enabled) {
56
56
  this.logDebug(3, "Setting up LRU cache", cache_opts);
57
- this.cache = new Cache( Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) );
57
+ this.cache = new Cache({ copyBuffers: true, ...Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) });
58
58
  this.cache.on('expire', function(item, reason) {
59
59
  self.logDebug(9, "Expiring LRU cache object: " + item.key + " due to: " + reason, {
60
60
  key: item.key,
@@ -56,7 +56,7 @@ module.exports = Class.create({
56
56
  var cache_opts = r_config.cache;
57
57
  if (cache_opts && cache_opts.enabled) {
58
58
  this.logDebug(3, "Setting up LRU cache", cache_opts);
59
- this.cache = new Cache( Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) );
59
+ this.cache = new Cache({ copyBuffers: true, ...Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) });
60
60
  this.cache.on('expire', function(item, reason) {
61
61
  self.logDebug(9, "Expiring LRU cache object: " + item.key + " due to: " + reason, {
62
62
  key: item.key,
package/engines/S3.js CHANGED
@@ -49,7 +49,7 @@ module.exports = Class.create({
49
49
  var cache_opts = s3_config.cache;
50
50
  if (cache_opts && cache_opts.enabled) {
51
51
  this.logDebug(3, "Setting up LRU cache", cache_opts);
52
- this.cache = new Cache( Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) );
52
+ this.cache = new Cache({ copyBuffers: true, ...Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) });
53
53
  this.cache.on('expire', function(item, reason) {
54
54
  self.logDebug(9, "Expiring LRU cache object: " + item.key + " due to: " + reason, {
55
55
  key: item.key,
package/engines/SQLite.js CHANGED
@@ -68,7 +68,7 @@ module.exports = Class.create({
68
68
  var cache_opts = this.config.get('cache');
69
69
  if (cache_opts && cache_opts.enabled) {
70
70
  this.logDebug(3, "Setting up LRU cache", cache_opts);
71
- this.cache = new Cache( Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) );
71
+ this.cache = new Cache({ copyBuffers: true, ...Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) });
72
72
  this.cache.on('expire', function(item, reason) {
73
73
  self.logDebug(9, "Expiring LRU cache object: " + item.key + " due to: " + reason, {
74
74
  key: item.key,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixl-server-storage",
3
- "version": "4.2.1",
3
+ "version": "4.3.0",
4
4
  "description": "A key/value/list storage component for the pixl-server framework.",
5
5
  "author": "Joseph Huckaby <jhuckaby@gmail.com>",
6
6
  "homepage": "https://github.com/jhuckaby/pixl-server-storage",
@@ -30,7 +30,7 @@
30
30
  "he": "1.1.1",
31
31
  "moo": "0.4.3",
32
32
  "nearley": "2.20.1",
33
- "pixl-cache": "^1.0.6",
33
+ "pixl-cache": "^1.1.2",
34
34
  "pixl-class": "^1.0.3",
35
35
  "pixl-perf": "^1.0.9",
36
36
  "pixl-server": "^1.0.39",
package/storage.js CHANGED
@@ -516,6 +516,35 @@ module.exports = Class.create({
516
516
  );
517
517
  },
518
518
 
519
+ deleteAll: function(keys, callback) {
520
+ // delete multiple records at once, given array of keys
521
+ // continue through all keys, even if some deletes fail
522
+ var self = this;
523
+ if (!this.started) return callback( new Error("Storage has not completed startup.") );
524
+
525
+ // if engine provides its own deleteAll, call that directly
526
+ if (("deleteAll" in this.engine) && !this.currentTransactionPath) {
527
+ return this.engine.deleteAll(keys, callback);
528
+ }
529
+
530
+ async.eachLimit(keys, this.concurrency,
531
+ function(key, callback) {
532
+ // iterator for each key
533
+ self.delete(key, function(err) {
534
+ if (err) {
535
+ // log and swallow the error so the remaining deletes can proceed
536
+ self.logError('storage', "Failed to delete key in deleteAll: " + key + ": " + err);
537
+ }
538
+ callback();
539
+ } );
540
+ },
541
+ function() {
542
+ // all keys attempted
543
+ callback();
544
+ }
545
+ );
546
+ },
547
+
519
548
  copy: function(old_key, new_key, callback) {
520
549
  // copy record to new key
521
550
  var self = this;
package/test/test-main.js CHANGED
@@ -656,6 +656,38 @@ module.exports = {
656
656
  } );
657
657
  },
658
658
 
659
+ function testDeleteAll(test) {
660
+ // delete multiple keys using deleteAll, continuing past errors
661
+ test.expect(5);
662
+ var self = this;
663
+ var keys = ['deleteall1', 'deleteall_missing', 'deleteall2'];
664
+
665
+ this.storage.putMulti( {
666
+ deleteall1: { fruit: 'apple' },
667
+ deleteall2: { fruit: 'banana' }
668
+ }, function(err) {
669
+ test.ok( !err, "No error creating deleteAll test records: " + err );
670
+
671
+ self.storage.deleteAll( keys, function(err) {
672
+ test.ok( !err, "No error calling deleteAll: " + err );
673
+
674
+ // the missing middle key should not prevent the final key from deleting
675
+ self.storage.get( 'deleteall1', function(err) {
676
+ test.ok( !!err, "Expected error fetching first deleteAll key after delete" );
677
+
678
+ self.storage.get( 'deleteall_missing', function(err) {
679
+ test.ok( !!err, "Expected error fetching missing deleteAll key after delete" );
680
+
681
+ self.storage.get( 'deleteall2', function(err) {
682
+ test.ok( !!err, "Expected error fetching final deleteAll key after delete" );
683
+ test.done();
684
+ } );
685
+ } );
686
+ } );
687
+ } );
688
+ } );
689
+ },
690
+
659
691
  function testOptimize(test) {
660
692
  // run engine-specific optimization, or a safe no-op if unsupported
661
693
  test.expect(8);
@@ -0,0 +1,243 @@
1
+ // Unit tests for Postgres password plugin support
2
+ // Copyright (c) 2026 Joseph Huckaby
3
+ // Released under the MIT License
4
+
5
+ var cp = require('child_process');
6
+ var EventEmitter = require('events').EventEmitter;
7
+ var Module = require('module');
8
+
9
+ var origLoad = Module._load;
10
+ var origExec = cp.exec;
11
+
12
+ // The Postgres engine requires 'pg' at module load time. Stub it here so this
13
+ // focused test can run without needing a live Postgres module or database.
14
+ var fakePG = {
15
+ lastConfig: null,
16
+ Pool: function(config) {
17
+ fakePG.lastConfig = config;
18
+ this.on = function() {};
19
+ this.query = function(sql, args, callback) {
20
+ callback( null, { command: 'CREATE', rowCount: 0, rows: [] } );
21
+ };
22
+ }
23
+ };
24
+
25
+ Module._load = function(request, parent, isMain) {
26
+ if (request == 'pg') return fakePG;
27
+ return origLoad.apply( this, arguments );
28
+ };
29
+
30
+ var PostgresEngine = require('../engines/Postgres.js');
31
+ Module._load = origLoad;
32
+
33
+ function makeEngine(config) {
34
+ // Keep this intentionally tiny. The password plugin helper only needs the
35
+ // component config and log methods, and setup() only needs query logging.
36
+ var engine = Object.create( PostgresEngine.prototype );
37
+ engine.config = {
38
+ get: function() { return config; }
39
+ };
40
+ engine.logDebug = function() {};
41
+ engine.logError = function() {};
42
+ return engine;
43
+ }
44
+
45
+ function mockExec(responder) {
46
+ var calls = [];
47
+
48
+ cp.exec = function(cmd, opts, callback) {
49
+ var call = { cmd: cmd, opts: opts, stdin: '' };
50
+ var child = { stdin: new EventEmitter() };
51
+ calls.push( call );
52
+
53
+ child.stdin.write = function(data) {
54
+ call.stdin += data;
55
+ };
56
+ child.stdin.end = function() {
57
+ responder( call, callback );
58
+ };
59
+
60
+ return child;
61
+ };
62
+
63
+ return calls;
64
+ }
65
+
66
+ function restoreExec() {
67
+ cp.exec = origExec;
68
+ }
69
+
70
+ module.exports = {
71
+ tests: [
72
+
73
+ function setup_installsPasswordFunctionAndStripsPluginKeys(test) {
74
+ test.expect(4);
75
+
76
+ var engine = makeEngine({
77
+ host: 'localhost',
78
+ database: 'test',
79
+ user: 'test',
80
+ password: 'static',
81
+ passwordPlugin: 'node ./plugin.js',
82
+ passwordPluginTimeout: 1234,
83
+ table: 'items'
84
+ });
85
+
86
+ fakePG.lastConfig = null;
87
+ engine.setup( function(err) {
88
+ test.ok( !err, "Setup completed without error" );
89
+ test.ok( typeof(fakePG.lastConfig.password) == 'function', "Password was converted to a callback function" );
90
+ test.ok( !('passwordPlugin' in fakePG.lastConfig), "passwordPlugin was stripped from pool config" );
91
+ test.ok( !('passwordPluginTimeout' in fakePG.lastConfig), "passwordPluginTimeout was stripped from pool config" );
92
+ test.done();
93
+ } );
94
+ },
95
+
96
+ function passwordPlugin_returnsPasswordAndSendsConfig(test) {
97
+ test.expect(5);
98
+
99
+ var engine = makeEngine({
100
+ host: 'localhost',
101
+ database: 'test',
102
+ user: 'tester',
103
+ password: 'static',
104
+ passwordPlugin: 'node ./plugin.js',
105
+ passwordPluginTimeout: 1234,
106
+ table: 'items'
107
+ });
108
+
109
+ var calls = mockExec( function(call, callback) {
110
+ callback( null, JSON.stringify({ xy: 1, password: 'dynamic-secret' }) + "\n", "" );
111
+ } );
112
+
113
+ engine.getPasswordFromPlugin().then( function(password) {
114
+ var input = JSON.parse( calls[0].stdin );
115
+
116
+ test.ok( password == 'dynamic-secret', "Returned dynamic password" );
117
+ test.ok( calls[0].cmd == 'node ./plugin.js', "Used configured plugin command" );
118
+ test.ok( calls[0].opts.timeout == 1234, "Used configured plugin timeout" );
119
+ test.ok( input.type == 'postgres_password', "Sent expected hook type" );
120
+ test.ok( !('password' in input.config), "Static password was omitted from plugin input" );
121
+
122
+ restoreExec();
123
+ test.done();
124
+ } ).catch( function(err) {
125
+ restoreExec();
126
+ test.ok( false, "Unexpected error: " + err );
127
+ test.done();
128
+ } );
129
+ },
130
+
131
+ function passwordPlugin_cachesPasswordForTTL(test) {
132
+ test.expect(3);
133
+
134
+ var engine = makeEngine({
135
+ passwordPlugin: 'node ./plugin.js',
136
+ table: 'items'
137
+ });
138
+
139
+ var calls = mockExec( function(call, callback) {
140
+ callback( null, JSON.stringify({ xy: 1, password: 'cached-secret', ttl: 60 }) + "\n", "" );
141
+ } );
142
+
143
+ engine.getPasswordFromPlugin().then( function(first) {
144
+ return engine.getPasswordFromPlugin().then( function(second) {
145
+ test.ok( first == 'cached-secret', "First password was returned" );
146
+ test.ok( second == 'cached-secret', "Second password was returned from cache" );
147
+ test.ok( calls.length == 1, "Plugin was only invoked once" );
148
+
149
+ restoreExec();
150
+ test.done();
151
+ } );
152
+ } ).catch( function(err) {
153
+ restoreExec();
154
+ test.ok( false, "Unexpected error: " + err );
155
+ test.done();
156
+ } );
157
+ },
158
+
159
+ function passwordPlugin_doesNotCacheWithoutTTL(test) {
160
+ test.expect(2);
161
+
162
+ var counter = 0;
163
+ var engine = makeEngine({
164
+ passwordPlugin: 'node ./plugin.js',
165
+ table: 'items'
166
+ });
167
+
168
+ var calls = mockExec( function(call, callback) {
169
+ counter++;
170
+ callback( null, JSON.stringify({ xy: 1, password: 'secret-' + counter }) + "\n", "" );
171
+ } );
172
+
173
+ engine.getPasswordFromPlugin().then( function(first) {
174
+ return engine.getPasswordFromPlugin().then( function(second) {
175
+ test.ok( first != second, "Passwords were fetched separately" );
176
+ test.ok( calls.length == 2, "Plugin was invoked twice" );
177
+
178
+ restoreExec();
179
+ test.done();
180
+ } );
181
+ } ).catch( function(err) {
182
+ restoreExec();
183
+ test.ok( false, "Unexpected error: " + err );
184
+ test.done();
185
+ } );
186
+ },
187
+
188
+ function passwordPlugin_coalescesConcurrentFetches(test) {
189
+ test.expect(2);
190
+
191
+ var engine = makeEngine({
192
+ passwordPlugin: 'node ./plugin.js',
193
+ table: 'items'
194
+ });
195
+
196
+ var calls = mockExec( function(call, callback) {
197
+ setTimeout( function() {
198
+ callback( null, JSON.stringify({ xy: 1, password: 'shared-secret', ttl: 60 }) + "\n", "" );
199
+ }, 10 );
200
+ } );
201
+
202
+ Promise.all([
203
+ engine.getPasswordFromPlugin(),
204
+ engine.getPasswordFromPlugin(),
205
+ engine.getPasswordFromPlugin()
206
+ ]).then( function(passwords) {
207
+ test.ok( passwords.join(',') == 'shared-secret,shared-secret,shared-secret', "All callers received the same password" );
208
+ test.ok( calls.length == 1, "Only one plugin process was spawned" );
209
+
210
+ restoreExec();
211
+ test.done();
212
+ } ).catch( function(err) {
213
+ restoreExec();
214
+ test.ok( false, "Unexpected error: " + err );
215
+ test.done();
216
+ } );
217
+ },
218
+
219
+ function passwordPlugin_reportsPluginError(test) {
220
+ test.expect(1);
221
+
222
+ var engine = makeEngine({
223
+ passwordPlugin: 'node ./plugin.js',
224
+ table: 'items'
225
+ });
226
+
227
+ mockExec( function(call, callback) {
228
+ callback( null, JSON.stringify({ xy: 1, code: 'auth', description: 'Token rejected' }) + "\n", "" );
229
+ } );
230
+
231
+ engine.getPasswordFromPlugin().then( function() {
232
+ restoreExec();
233
+ test.ok( false, "Expected plugin error" );
234
+ test.done();
235
+ } ).catch( function(err) {
236
+ restoreExec();
237
+ test.ok( /Token rejected/.test(err.message), "Reported plugin error: " + err.message );
238
+ test.done();
239
+ } );
240
+ }
241
+
242
+ ]
243
+ };
package/transaction.js CHANGED
@@ -575,7 +575,8 @@ module.exports = Class.create({
575
575
  trans.aborting = true;
576
576
 
577
577
  var num_actions = Tools.numKeys(trans.keys || {});
578
- this.logError('rollback', "Aborting transaction: " + trans.id, { path: path, actions: num_actions });
578
+ if (num_actions) this.logError('rollback', "Aborting transaction: " + trans.id, { path: path, actions: num_actions });
579
+ else this.logDebug(6, "Aborting empty transaction (no-op): " + trans.id, { path: path, actions: num_actions });
579
580
 
580
581
  // read in file line by line
581
582
  // (file may not exist, which is fine, hence 'ignore_not_found')