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 +286 -3
- package/docs/API.md +17 -0
- package/engines/Filesystem.js +1 -1
- package/engines/Postgres.js +98 -4
- package/engines/Redis.js +1 -1
- package/engines/RedisCluster.js +1 -1
- package/engines/S3.js +1 -1
- package/engines/SQLite.js +1 -1
- package/package.json +2 -2
- package/storage.js +29 -0
- package/test/test-main.js +32 -0
- package/test/test-postgres-password-plugin.js +243 -0
- package/transaction.js +2 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
package/engines/Filesystem.js
CHANGED
|
@@ -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,
|
package/engines/Postgres.js
CHANGED
|
@@ -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
|
|
79
|
-
|
|
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,
|
package/engines/RedisCluster.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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')
|