pixl-server-storage 4.0.3 → 4.1.1

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.
@@ -0,0 +1,515 @@
1
+ // Postgres Storage Plugin
2
+ // Copyright (c) 2026 Joseph Huckaby
3
+ // Released under the MIT License
4
+
5
+ // Requires the 'pg' module
6
+
7
+ const fs = require('fs');
8
+ const Path = require('path');
9
+ const zlib = require('zlib');
10
+ const Class = require("pixl-class");
11
+ const Component = require("pixl-server/component");
12
+ const pg = require('pg');
13
+ const Tools = require("pixl-tools");
14
+ const Perf = require("pixl-perf");
15
+ const Cache = require("pixl-cache");
16
+ const async = require('async');
17
+ const noop = function() {};
18
+
19
+ module.exports = Class.create({
20
+
21
+ __name: 'Postgres',
22
+ __parent: Component,
23
+
24
+ defaultConfig: {
25
+ min: 0,
26
+ max: 32,
27
+ host: "localhost",
28
+ database: "",
29
+ user: "",
30
+ password: "",
31
+ port: 5432,
32
+ statement_timeout: 5000,
33
+ query_timeout: 6000,
34
+ connectionTimeoutMillis: 30000,
35
+ idleTimeoutMillis: 10000,
36
+ table: "items"
37
+ },
38
+
39
+ startup: function(callback) {
40
+ // setup Postgres connection
41
+ var self = this;
42
+ this.logDebug(2, "Setting up Postgres", Tools.copyHashRemoveKeys( this.config.get(), { password:1 }) );
43
+ this.setup(callback);
44
+ },
45
+
46
+ setup: function(callback) {
47
+ // setup Postgres connection
48
+ var self = this;
49
+ var pg_config = this.config.get();
50
+
51
+ this.commands = {
52
+ create: `CREATE TABLE IF NOT EXISTS ${pg_config.table}( key TEXT PRIMARY KEY, value BYTEA NOT NULL, modified BIGINT NOT NULL )`,
53
+ get: `SELECT modified, value FROM ${pg_config.table} WHERE key = $key LIMIT 1`,
54
+ head: `SELECT modified, octet_length(value) AS len FROM ${pg_config.table} WHERE key = $key LIMIT 1`,
55
+ put: `INSERT INTO ${pg_config.table} (key, value, modified) VALUES ($key, $value, $now) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, modified = EXCLUDED.modified`,
56
+ delete: `DELETE FROM ${pg_config.table} WHERE key = $key`
57
+ };
58
+
59
+ // optional LRU cache
60
+ this.cache = null;
61
+ var cache_opts = pg_config.cache;
62
+ if (cache_opts && cache_opts.enabled) {
63
+ this.logDebug(3, "Setting up LRU cache", cache_opts);
64
+ this.cache = new Cache( Tools.copyHashRemoveKeys(cache_opts, { enabled: 1 }) );
65
+ this.cache.on('expire', function(item, reason) {
66
+ self.logDebug(9, "Expiring LRU cache object: " + item.key + " due to: " + reason, {
67
+ key: item.key,
68
+ reason: reason,
69
+ totalCount: self.cache.count,
70
+ totalBytes: self.cache.bytes
71
+ });
72
+ });
73
+ }
74
+
75
+ // pass entire config to pg.Pool, sans our custom keys
76
+ var db = this.db = new pg.Pool(
77
+ Tools.copyHashRemoveKeys( pg_config, { cache: 1, table: 1 })
78
+ );
79
+
80
+ db.on('error', function(err) {
81
+ self.logError('db', "DB Error: " + err, err);
82
+ if (callback) { callback(err); callback = null; }
83
+ });
84
+ db.on('connect', function(client) {
85
+ self.logDebug(9, "Opened DB pool connection");
86
+ });
87
+ db.on('remove', function(client) {
88
+ self.logDebug(9, "Closed DB pool connection");
89
+ });
90
+
91
+ // create initial table
92
+ this.logDebug(3, "Creating initial table: " + pg_config.table);
93
+ this.query( this.commands.create, {}, function(err, res) {
94
+ if (err) self.logError('db', "Failed to create initial table: " + err, err);
95
+ else self.logDebug(3, "Setup complete");
96
+ if (callback) { callback(err); callback = null; }
97
+ } );
98
+ },
99
+
100
+ query(sql, args, callback) {
101
+ // run and log db query, and error if applicable
102
+ // allow for named variables in sql, expand to numbered $ variables for pg
103
+ var self = this;
104
+
105
+ // perform $ placeholder substitution to populate sargs array
106
+ var sargs = [];
107
+ sql = sql.replace(/\$(\w+)/g, function(m_all, name) {
108
+ var value = args[name];
109
+ sargs.push(value);
110
+ value = '$' + sargs.length;
111
+ return value;
112
+ } );
113
+
114
+ this.db.query( sql, sargs, function(err, res) {
115
+ if (err) {
116
+ self.logError('db', "Failed to execute SQL: " + err, { sql, err });
117
+ return callback(err);
118
+ }
119
+
120
+ self.logDebug(9, "SQL query completed successfully", {
121
+ command: res.command || '',
122
+ rowCount: res.rowCount || 0
123
+ });
124
+
125
+ callback(null, res);
126
+ } ); // db.query
127
+ },
128
+
129
+ prepKey: function(key) {
130
+ // prepare key (no-op)
131
+ return key;
132
+ },
133
+
134
+ put: function(key, value, callback) {
135
+ // store key+value in Postgres
136
+ var self = this;
137
+ var now = Tools.timeNow(true);
138
+ key = this.prepKey(key);
139
+
140
+ var is_binary = this.storage.isBinaryKey(key);
141
+
142
+ if (is_binary) {
143
+ this.logDebug(9, "Storing Postgres Binary Object: " + key, '' + value.length + ' bytes');
144
+ }
145
+ else {
146
+ this.logDebug(9, "Storing Postgres JSON Object: " + key, this.debugLevel(10) ? value : null);
147
+ value = Buffer.from( JSON.stringify( value ) );
148
+ }
149
+
150
+ this.query( this.commands.put, { key: key, value: value, now: now }, function(err, res) {
151
+ if (err) {
152
+ err.message = "Failed to store object: " + key + ": " + err;
153
+ self.logError('pg', '' + err);
154
+ if (callback) callback(err);
155
+ return;
156
+ }
157
+
158
+ self.logDebug(9, "Store complete: " + key);
159
+ // possibly cache in LRU
160
+ if (self.cache && !is_binary) {
161
+ self.cache.set( key, value, { date: now } );
162
+ }
163
+ if (callback) callback(null);
164
+ } ); // query
165
+ },
166
+
167
+ putStream: function(key, inp, callback) {
168
+ // store key+value in Postgres using read stream
169
+ var self = this;
170
+
171
+ // The Postgres API has no stream support.
172
+ // So, we have to do this the RAM-hard way...
173
+
174
+ var chunks = [];
175
+ var done = false;
176
+ var finish = function(err) {
177
+ if (done) return;
178
+ done = true;
179
+ callback(err);
180
+ };
181
+
182
+ inp.on('error', function(err) {
183
+ err.message = "Failed to store stream: " + key + ": " + err;
184
+ self.logError('pg', '' + err);
185
+ finish(err);
186
+ } );
187
+ inp.on('data', function(chunk) {
188
+ if (done) return;
189
+ chunks.push( chunk );
190
+ } );
191
+ inp.on('end', function() {
192
+ if (done) return;
193
+ var buf = Buffer.concat(chunks);
194
+ self.put( key, buf, finish );
195
+ } );
196
+ },
197
+
198
+ head: function(key, callback) {
199
+ // head pg item given key
200
+ var self = this;
201
+ key = this.prepKey(key);
202
+
203
+ // check cache first
204
+ if (this.cache && this.cache.has(key)) {
205
+ var item = this.cache.getMeta(key);
206
+
207
+ if (item.value.length == 0) {
208
+ process.nextTick( function() {
209
+ var err = new Error("Failed to head key: " + key + ": Not found");
210
+ err.code = "NoSuchKey";
211
+ callback( err );
212
+ } );
213
+ }
214
+ else {
215
+ process.nextTick( function() {
216
+ self.logDebug(9, "Cached head complete: " + key);
217
+ callback( null, {
218
+ mod: item.date,
219
+ len: item.value.length
220
+ } );
221
+ } );
222
+ }
223
+ return;
224
+ } // cache
225
+
226
+ this.query( this.commands.head, { key: key }, function(err, res) {
227
+ if (err) {
228
+ err.message = "Failed to head key: " + key + ": " + err;
229
+ self.logError('pg', '' + err);
230
+ callback(err);
231
+ return;
232
+ }
233
+
234
+ var row = res.rows[0];
235
+
236
+ if (!row) {
237
+ var err = new Error("Failed to head key: " + key + ": Not found");
238
+ err.code = "NoSuchKey";
239
+
240
+ if (self.cache && !self.storage.isBinaryKey(key)) {
241
+ // store 'empty' stub in cache
242
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
243
+ }
244
+
245
+ return callback(err, null);
246
+ }
247
+ callback(null, { mod: parseInt(row.modified, 10), len: row.len });
248
+ } ); // query
249
+ },
250
+
251
+ get: function(key, callback) {
252
+ // fetch Postgres value given key
253
+ var self = this;
254
+ key = this.prepKey(key);
255
+
256
+ var is_binary = this.storage.isBinaryKey(key);
257
+
258
+ // check cache first
259
+ if (this.cache && !is_binary && this.cache.has(key)) {
260
+ var item = this.cache.getMeta(key);
261
+
262
+ if (item.value.length == 0) {
263
+ process.nextTick( function() {
264
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
265
+ err.code = "NoSuchKey";
266
+ callback( err );
267
+ } );
268
+ }
269
+ else {
270
+ process.nextTick( function() {
271
+ var data = null;
272
+ try { data = JSON.parse( item.value.toString() ); }
273
+ catch (e) {
274
+ self.logError('pg', "Failed to parse JSON record: " + key + ": " + e);
275
+ callback( e, null );
276
+ return;
277
+ }
278
+ self.logDebug(9, "Cached JSON fetch complete: " + key, self.debugLevel(10) ? data : null);
279
+
280
+ callback( null, data );
281
+ } );
282
+ }
283
+ return;
284
+ } // cache
285
+
286
+ this.logDebug(9, "Fetching Postgres Object: " + key);
287
+
288
+ this.query( this.commands.get, { key: key }, function(err, res) {
289
+ if (err) {
290
+ err.message = "Failed to fetch key: " + key + ": " + err;
291
+ self.logError('pg', '' + err);
292
+ callback(err);
293
+ return;
294
+ }
295
+
296
+ var row = res.rows[0];
297
+
298
+ if (!row) {
299
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
300
+ err.code = "NoSuchKey";
301
+
302
+ if (self.cache && !is_binary) {
303
+ // store 'empty' stub in cache
304
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
305
+ }
306
+
307
+ return callback(err, null);
308
+ }
309
+ if (is_binary) {
310
+ self.logDebug(9, "Binary fetch complete: " + key, '' + row.value.length + ' bytes');
311
+ return callback(null, row.value);
312
+ }
313
+ if (self.cache) {
314
+ self.cache.set( key, row.value, { date: parseInt(row.modified, 10) } );
315
+ }
316
+ var json = null;
317
+ try { json = JSON.parse( row.value.toString() ); }
318
+ catch (err) {
319
+ self.logError('pg', "Failed to parse JSON record: " + key + ": " + err);
320
+ return callback(err, null);
321
+ }
322
+ self.logDebug(9, "JSON fetch complete: " + key, self.debugLevel(10) ? json : null);
323
+ callback(null, json);
324
+ } ); // query
325
+ },
326
+
327
+ getBuffer: function(key, callback) {
328
+ // fetch Postgres buffer given key
329
+ var self = this;
330
+ key = this.prepKey(key);
331
+
332
+ this.logDebug(9, "Fetching Postgres Object: " + key);
333
+
334
+ this.query( this.commands.get, { key: key }, function(err, res) {
335
+ if (err) {
336
+ err.message = "Failed to fetch key: " + key + ": " + err;
337
+ self.logError('pg', '' + err);
338
+ callback(err);
339
+ return;
340
+ }
341
+
342
+ var row = res.rows[0];
343
+
344
+ if (!row) {
345
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
346
+ err.code = "NoSuchKey";
347
+ return callback(err, null);
348
+ }
349
+ self.logDebug(9, "Binary fetch complete: " + key, '' + row.value.length + ' bytes');
350
+ callback(null, row.value);
351
+ } ); // query
352
+ },
353
+
354
+ getStream: function(key, callback) {
355
+ // get readable stream to record value given key
356
+ var self = this;
357
+
358
+ // The Postgres API has no stream support.
359
+ // So, we have to do this the RAM-hard way...
360
+
361
+ this.query( this.commands.get, { key: this.prepKey(key) }, function(err, res) {
362
+ if (err) {
363
+ // an actual error
364
+ err.message = "Failed to fetch key: " + key + ": " + err;
365
+ self.logError('pg', '' + err);
366
+ return callback(err);
367
+ }
368
+
369
+ var row = res.rows[0];
370
+
371
+ if (!row) {
372
+ // record not found
373
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
374
+ err.code = "NoSuchKey";
375
+ return callback( err, null );
376
+ }
377
+
378
+ self.logDebug(9, "Binary fetch complete: " + key, '' + row.value.length + ' bytes');
379
+ var stream = new BufferStream(row.value);
380
+ callback(null, stream, { mod: parseInt(row.modified, 10), len: row.value.length });
381
+ } ); // query
382
+ },
383
+
384
+ getStreamRange: function(key, start, end, callback) {
385
+ // get readable stream to record value given key and range
386
+ var self = this;
387
+
388
+ // The Postgres API has no stream support.
389
+ // So, we have to do this the RAM-hard way...
390
+
391
+ this.query( this.commands.get, { key: this.prepKey(key) }, function(err, res) {
392
+ if (err) {
393
+ // an actual error
394
+ err.message = "Failed to fetch key: " + key + ": " + err;
395
+ self.logError('pg', '' + err);
396
+ return callback(err);
397
+ }
398
+
399
+ var row = res.rows[0];
400
+
401
+ if (!row) {
402
+ // record not found
403
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
404
+ err.code = "NoSuchKey";
405
+ return callback( err, null );
406
+ }
407
+
408
+ var buf = row.value;
409
+
410
+ // validate byte range, now that we have the head info
411
+ if (isNaN(start) && !isNaN(end)) {
412
+ start = buf.length - end;
413
+ end = buf.length ? buf.length - 1 : 0;
414
+ }
415
+ else if (!isNaN(start) && isNaN(end)) {
416
+ end = buf.length ? buf.length - 1 : 0;
417
+ }
418
+ if (isNaN(start) || isNaN(end) || (start < 0) || (start >= buf.length) || (end < start) || (end >= buf.length)) {
419
+ callback( new Error("Invalid byte range (" + start + '-' + end + ") for key: " + key + " (len: " + buf.length + ")"), null );
420
+ return;
421
+ }
422
+
423
+ var range = buf.slice(start, end + 1);
424
+ var stream = new BufferStream(range);
425
+ callback(null, stream, { mod: parseInt(row.modified, 10), len: buf.length });
426
+ } ); // query
427
+ },
428
+
429
+ delete: function(key, callback) {
430
+ // delete Postgres key given key
431
+ var self = this;
432
+ key = this.prepKey(key);
433
+
434
+ this.logDebug(9, "Deleting Postgres Object: " + key);
435
+
436
+ this.query( this.commands.delete, { key: key }, function(err, res) {
437
+ if (err) {
438
+ self.logError('pg', "Failed to delete object: " + key + ": " + err);
439
+ callback(err);
440
+ return;
441
+ }
442
+
443
+ if (!res.rowCount) {
444
+ var err = new Error("Failed to delete object: " + key + ": Not found");
445
+ err.code = "NoSuchKey";
446
+
447
+ if (self.cache && !self.storage.isBinaryKey(key)) {
448
+ // store 'empty' stub in cache
449
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
450
+ }
451
+
452
+ return callback(err);
453
+ }
454
+ self.logDebug(9, "Delete complete: " + key);
455
+
456
+ // possibly "delete" from LRU cache as well
457
+ if (self.cache && !self.storage.isBinaryKey(key)) {
458
+ // store 'empty' stub in cache
459
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
460
+ }
461
+ callback();
462
+ } ); // query
463
+ },
464
+
465
+ runMaintenance: function(callback) {
466
+ // run daily maintenance
467
+ callback();
468
+ },
469
+
470
+ shutdown: function(callback) {
471
+ // shutdown storage
472
+ var self = this;
473
+ this.logDebug(2, "Shutting down Postgres");
474
+
475
+ if (this.db) {
476
+ this.db.end( function(err) {
477
+ if (err) self.logError('pg', "Failed to shutdown database cleanly: " + err);
478
+ else self.logDebug(3, "Shutdown complete");
479
+ delete self.db;
480
+ callback();
481
+ } );
482
+ }
483
+ else {
484
+ this.logDebug(3, "Shutdown complete");
485
+ callback();
486
+ }
487
+ }
488
+
489
+ });
490
+
491
+ // Modified the following snippet from node-streamifier:
492
+ // Copyright (c) 2014 Gabriel Llamas, MIT Licensed
493
+
494
+ var util = require('util');
495
+ var stream = require('stream');
496
+
497
+ var BufferStream = function (object, options) {
498
+ if (object instanceof Buffer || typeof object === 'string') {
499
+ options = options || {};
500
+ stream.Readable.call(this, {
501
+ highWaterMark: options.highWaterMark,
502
+ encoding: options.encoding
503
+ });
504
+ } else {
505
+ stream.Readable.call(this, { objectMode: true });
506
+ }
507
+ this._object = object;
508
+ };
509
+
510
+ util.inherits(BufferStream, stream.Readable);
511
+
512
+ BufferStream.prototype._read = function () {
513
+ this.push(this._object);
514
+ this._object = null;
515
+ };