pixl-server-storage 3.1.0 → 3.1.2

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
@@ -611,6 +611,32 @@ The optional `keyPrefix` property works similarly to the [S3 Key Prefix](#s3-key
611
611
 
612
612
  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.
613
613
 
614
+ ## SQLite
615
+
616
+ If you want to use [SQLite](https://sqlite.com/) as a backing store, here is how to do so. First, you need to manually install the [sqlite3](https://www.npmjs.com/package/sqlite3) module into your app:
617
+
618
+ ```
619
+ npm install --save sqlite3
620
+ ```
621
+
622
+ Then configure your storage thusly:
623
+
624
+ ```javascript
625
+ {
626
+ "engine": "SQLite",
627
+ "SQLite": {
628
+ "base_dir": "data",
629
+ "filename": "sqlite.db"
630
+ }
631
+ }
632
+ ```
633
+
634
+ The `base_dir` defaults to the current working directory, and will be created on startup if necessary.
635
+
636
+ The optional `keyPrefix` property works similarly to the [S3 Key Prefix](#s3-key-prefix) feature. It allows you to prefix all the SQLite keys with a common string, to separate your application's data in a shared database situation.
637
+
638
+ 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.
639
+
614
640
  ## Hybrid
615
641
 
616
642
  Your application may need the features of multiple engines. Specifically, you may want JSON (document) records to use one engine, and binary records to use another. Binary records are specified with keys that end in a file extension, e.g. `.jpg`. To facilitate this, there is a `Hybrid` engine available, which can load multiple sub-engines, one for JSON keys and one for binary keys. Example use:
@@ -0,0 +1,363 @@
1
+ // SQLite Storage Plugin
2
+ // Copyright (c) 2023 Joseph Huckaby
3
+ // Released under the MIT License
4
+
5
+ // Requires the 'sqlite3' module from npm
6
+ // npm install --save sqlite3
7
+
8
+ const Path = require('path');
9
+ const Class = require("pixl-class");
10
+ const Component = require("pixl-server/component");
11
+ const SQLite3 = require('sqlite3');
12
+ const Tools = require("pixl-tools");
13
+ const async = require('async');
14
+
15
+ module.exports = Class.create({
16
+
17
+ __name: 'SQLite',
18
+ __parent: Component,
19
+
20
+ defaultConfig: {
21
+ base_dir: '',
22
+ filename: 'sqlite.db',
23
+ keyPrefix: "",
24
+ keyTemplate: ""
25
+ },
26
+
27
+ startup: function(callback) {
28
+ // setup SQLite connection
29
+ var self = this;
30
+ this.logDebug(2, "Setting up SQLite", this.config.get() );
31
+ this.setup(callback);
32
+ },
33
+
34
+ setup: function(callback) {
35
+ // setup SQLite connection
36
+ var self = this;
37
+ var sql_config = this.config.get();
38
+
39
+ this.keyPrefix = (sql_config.keyPrefix || '').replace(/^\//, '');
40
+ if (this.keyPrefix && !this.keyPrefix.match(/\/$/)) this.keyPrefix += '/';
41
+
42
+ this.keyTemplate = (sql_config.keyTemplate || '').replace(/^\//, '').replace(/\/$/, '');
43
+
44
+ this.baseDir = sql_config.base_dir || process.cwd();
45
+ this.commands = {};
46
+
47
+ // create initial data dir if necessary
48
+ try {
49
+ Tools.mkdirp.sync( this.baseDir );
50
+ }
51
+ catch (e) {
52
+ var msg = "FATAL ERROR: Base directory could not be created: " + this.baseDir + ": " + e;
53
+ this.logError('sqlite', msg);
54
+ throw new Error(msg);
55
+ }
56
+
57
+ var db_file = Path.join( this.baseDir, sql_config.filename );
58
+ this.logDebug(3, "Opening database file: " + db_file);
59
+
60
+ async.series([
61
+ function(callback) {
62
+ self.db = new SQLite3.Database( db_file, callback );
63
+ },
64
+ function(callback) {
65
+ self.db.run( 'CREATE TABLE IF NOT EXISTS items( key TEXT PRIMARY KEY, value BLOB, modified INTEGER )', callback );
66
+ },
67
+ function(callback) {
68
+ self.commands.get = self.db.prepare( 'SELECT value FROM items WHERE key = $key LIMIT 1', callback );
69
+ },
70
+ function(callback) {
71
+ self.commands.head = self.db.prepare( 'SELECT modified, length(value) FROM items WHERE key = $key LIMIT 1', callback );
72
+ },
73
+ function(callback) {
74
+ self.commands.put = self.db.prepare( 'INSERT INTO items VALUES($key, $value, $now) ON CONFLICT (key) DO UPDATE SET value = $value, modified = $now WHERE key = $key', callback );
75
+ },
76
+ function(callback) {
77
+ self.commands.delete = self.db.prepare( 'DELETE FROM items WHERE key = $key', callback );
78
+ }
79
+ ],
80
+ function(err) {
81
+ if (err) {
82
+ self.logError('sqlite', "FATAL ERROR: Database setup failed: " + err);
83
+ return callback(err);
84
+ }
85
+ self.logDebug(3, "Setup complete");
86
+ callback();
87
+ }); // async.series
88
+ },
89
+
90
+ prepKey: function(key) {
91
+ // prepare key based on config
92
+ var md5 = Tools.digestHex(key, 'md5');
93
+
94
+ if (this.keyPrefix) {
95
+ key = this.keyPrefix + key;
96
+ }
97
+
98
+ if (this.keyTemplate) {
99
+ var idx = 0;
100
+ var temp = this.keyTemplate.replace( /\#/g, function() {
101
+ return md5.substr(idx++, 1);
102
+ } );
103
+ key = Tools.substitute( temp, { key: key, md5: md5 } );
104
+ }
105
+
106
+ return key;
107
+ },
108
+
109
+ put: function(key, value, callback) {
110
+ // store key+value in SQLite
111
+ var self = this;
112
+ var now = Tools.timeNow(true);
113
+ key = this.prepKey(key);
114
+
115
+ if (this.storage.isBinaryKey(key)) {
116
+ this.logDebug(9, "Storing SQLite Binary Object: " + key, '' + value.length + ' bytes');
117
+ }
118
+ else {
119
+ this.logDebug(9, "Storing SQLite JSON Object: " + key, this.debugLevel(10) ? value : null);
120
+ value = Buffer.from( JSON.stringify( value ) );
121
+ }
122
+
123
+ this.commands.put.run({ $key: key, $value: value, $now: now }, function(err) {
124
+ if (err) {
125
+ err.message = "Failed to store object: " + key + ": " + err;
126
+ self.logError('sqlite', '' + err);
127
+ }
128
+ else self.logDebug(9, "Store complete: " + key);
129
+
130
+ if (callback) callback(err);
131
+ } ); // put
132
+ },
133
+
134
+ putStream: function(key, inp, callback) {
135
+ // store key+value in SQLite using read stream
136
+ var self = this;
137
+
138
+ // The SQLite API has no stream support.
139
+ // So, we have to do this the RAM-hard way...
140
+
141
+ var chunks = [];
142
+ inp.on('data', function(chunk) {
143
+ chunks.push( chunk );
144
+ } );
145
+ inp.on('end', function() {
146
+ var buf = Buffer.concat(chunks);
147
+ self.put( key, buf, callback );
148
+ } );
149
+ },
150
+
151
+ head: function(key, callback) {
152
+ // head sqlite item given key
153
+ var self = this;
154
+ key = this.prepKey(key);
155
+
156
+ this.commands.head.get({ $key: key }, function(err, row) {
157
+ if (err) {
158
+ // an actual error
159
+ err.message = "Failed to head key: " + key + ": " + err;
160
+ self.logError('sqlite', '' + err);
161
+ callback(err);
162
+ }
163
+ else if (!row) {
164
+ // record not found
165
+ // always use "NoSuchKey" in error code
166
+ var err = new Error("Failed to head key: " + key + ": Not found");
167
+ err.code = "NoSuchKey";
168
+ callback( err, null );
169
+ }
170
+ else {
171
+ callback( null, { mod: row.modified, len: row['length(value)'] } );
172
+ }
173
+ }); // head
174
+ },
175
+
176
+ get: function(key, callback) {
177
+ // fetch SQLite value given key
178
+ var self = this;
179
+ key = this.prepKey(key);
180
+
181
+ this.logDebug(9, "Fetching SQLite Object: " + key);
182
+
183
+ this.commands.get.get({ $key: key }, function(err, row) {
184
+ if (err) {
185
+ // an actual error
186
+ err.message = "Failed to fetch key: " + key + ": " + err;
187
+ self.logError('sqlite', '' + err);
188
+ callback(err);
189
+ }
190
+ else if (!row) {
191
+ // record not found
192
+ // always use "NoSuchKey" in error code
193
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
194
+ err.code = "NoSuchKey";
195
+ callback( err, null );
196
+ }
197
+ else {
198
+ // success
199
+ if (self.storage.isBinaryKey(key)) {
200
+ self.logDebug(9, "Binary fetch complete: " + key, '' + row.value.length + ' bytes');
201
+ callback( null, row.value );
202
+ }
203
+ else {
204
+ var json = null;
205
+ try { json = JSON.parse( row.value.toString() ); }
206
+ catch (err) {
207
+ self.logError('sqlite', "Failed to parse JSON record: " + key + ": " + err);
208
+ callback( err, null );
209
+ return;
210
+ }
211
+ self.logDebug(9, "JSON fetch complete: " + key, self.debugLevel(10) ? json : null);
212
+ callback( null, json );
213
+ }
214
+ }
215
+ }); // get
216
+ },
217
+
218
+ getStream: function(key, callback) {
219
+ // get readable stream to record value given key
220
+ var self = this;
221
+
222
+ // The SQLite API has no stream support.
223
+ // So, we have to do this the RAM-hard way...
224
+
225
+ this.get( key, function(err, buf) {
226
+ if (err) {
227
+ // an actual error
228
+ err.message = "Failed to fetch key: " + key + ": " + err;
229
+ self.logError('sqlite', '' + err);
230
+ return callback(err);
231
+ }
232
+ else if (!buf) {
233
+ // record not found
234
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
235
+ err.code = "NoSuchKey";
236
+ return callback( err, null );
237
+ }
238
+
239
+ var stream = new BufferStream(buf);
240
+ callback(null, stream, { mod: 1, len: buf.length });
241
+ } );
242
+ },
243
+
244
+ getStreamRange: function(key, start, end, callback) {
245
+ // get readable stream to record value given key and range
246
+ var self = this;
247
+
248
+ // The SQLite API has no stream support.
249
+ // So, we have to do this the RAM-hard way...
250
+
251
+ this.get( key, function(err, buf) {
252
+ if (err) {
253
+ // an actual error
254
+ err.message = "Failed to fetch key: " + key + ": " + err;
255
+ self.logError('sqlite', '' + err);
256
+ return callback(err);
257
+ }
258
+ else if (!buf) {
259
+ // record not found
260
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
261
+ err.code = "NoSuchKey";
262
+ return callback( err, null );
263
+ }
264
+
265
+ // validate byte range, now that we have the head info
266
+ if (isNaN(start) && !isNaN(end)) {
267
+ start = buf.length - end;
268
+ end = buf.length ? buf.length - 1 : 0;
269
+ }
270
+ else if (!isNaN(start) && isNaN(end)) {
271
+ end = buf.length ? buf.length - 1 : 0;
272
+ }
273
+ if (isNaN(start) || isNaN(end) || (start < 0) || (start >= buf.length) || (end < start) || (end >= buf.length)) {
274
+ download.destroy();
275
+ callback( new Error("Invalid byte range (" + start + '-' + end + ") for key: " + key + " (len: " + buf.length + ")"), null );
276
+ return;
277
+ }
278
+
279
+ var range = buf.slice(start, end + 1);
280
+ var stream = new BufferStream(range);
281
+ callback(null, stream, { mod: 1, len: buf.length });
282
+ } );
283
+ },
284
+
285
+ delete: function(key, callback) {
286
+ // delete SQLite key given key
287
+ var self = this;
288
+ key = this.prepKey(key);
289
+
290
+ this.logDebug(9, "Deleting SQLite Object: " + key);
291
+
292
+ this.commands.delete.run({ $key: key }, function(err) {
293
+ // In sqlite3 callbacks `this` is special, and contains `changes`
294
+ if (!err && !this.changes) {
295
+ err = new Error("Not found");
296
+ err.code = "NoSuchKey";
297
+ }
298
+ if (err) {
299
+ self.logError('sqlite', "Failed to delete object: " + key + ": " + err);
300
+ err = new Error("Failed to delete object: " + key + ": " + err);
301
+ return callback(err);
302
+ }
303
+ self.logDebug(9, "Delete complete: " + key);
304
+ callback();
305
+ }); // delete
306
+ },
307
+
308
+ runMaintenance: function(callback) {
309
+ // run daily maintenance
310
+ callback();
311
+ },
312
+
313
+ shutdown: function(callback) {
314
+ // shutdown storage
315
+ var self = this;
316
+ this.logDebug(2, "Shutting down SQLite");
317
+
318
+ if (this.db) {
319
+ // finalize all statements and close db
320
+ async.eachSeries( Object.keys(this.commands),
321
+ function(key, callback) {
322
+ self.commands[key].finalize(callback);
323
+ },
324
+ function() {
325
+ self.db.close( function(err) {
326
+ if (err) self.logError('sqlite', "Failed to shutdown database cleanly: " + err);
327
+ else self.logDebug(3, "Shutdown complete");
328
+ callback();
329
+ } );
330
+ self.db = null;
331
+ self.commands = null;
332
+ }
333
+ ); // eachSeries
334
+ }
335
+ }
336
+
337
+ });
338
+
339
+ // Modified the following snippet from node-streamifier:
340
+ // Copyright (c) 2014 Gabriel Llamas, MIT Licensed
341
+
342
+ var util = require('util');
343
+ var stream = require('stream');
344
+
345
+ var BufferStream = function (object, options) {
346
+ if (object instanceof Buffer || typeof object === 'string') {
347
+ options = options || {};
348
+ stream.Readable.call(this, {
349
+ highWaterMark: options.highWaterMark,
350
+ encoding: options.encoding
351
+ });
352
+ } else {
353
+ stream.Readable.call(this, { objectMode: true });
354
+ }
355
+ this._object = object;
356
+ };
357
+
358
+ util.inherits(BufferStream, stream.Readable);
359
+
360
+ BufferStream.prototype._read = function () {
361
+ this.push(this._object);
362
+ this._object = null;
363
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixl-server-storage",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
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",
@@ -18,7 +18,8 @@
18
18
  "nosql",
19
19
  "s3",
20
20
  "couchbase",
21
- "redis"
21
+ "redis",
22
+ "sqlite"
22
23
  ],
23
24
  "dependencies": {
24
25
  "@aws-sdk/client-s3": "3.186.0",
@@ -27,16 +28,16 @@
27
28
  "he": "1.1.1",
28
29
  "moo": "0.4.3",
29
30
  "nearley": "2.20.1",
30
- "pixl-cache": "^1.0.1",
31
+ "pixl-cache": "^1.0.6",
31
32
  "pixl-class": "^1.0.3",
32
- "pixl-perf": "^1.0.0",
33
- "pixl-server": "^1.0.0",
34
- "pixl-tools": "^1.0.24",
33
+ "pixl-perf": "^1.0.9",
34
+ "pixl-server": "^1.0.39",
35
+ "pixl-tools": "^1.0.33",
35
36
  "porter-stemmer": "0.9.1",
36
37
  "unidecode": "0.1.8"
37
38
  },
38
39
  "devDependencies": {
39
- "pixl-unit": "^1.0.0"
40
+ "pixl-unit": "^1.0.14"
40
41
  },
41
42
  "scripts": {
42
43
  "test": "pixl-unit test/test.js",
package/storage.js CHANGED
@@ -670,6 +670,16 @@ module.exports = Class.create({
670
670
  }
671
671
  }; // deleteExpiredRecord
672
672
 
673
+ var doEngineMaint = function() {
674
+ // allow engine to run maint as well
675
+ self.engine.runMaintenance( function() {
676
+ stats.elapsed_sec = Tools.timeNow() - stats.time_start;
677
+ self.logDebug(3, "Daily maintenance complete");
678
+ self.logTransaction('maint', cleanup_list_path, stats);
679
+ if (callback) callback();
680
+ } );
681
+ }; // finish
682
+
673
683
  this.listEach( cleanup_list_path,
674
684
  function(item, item_idx, callback) {
675
685
  // delete item if still expired
@@ -705,7 +715,7 @@ module.exports = Class.create({
705
715
  // list iteration complete
706
716
  if (err) {
707
717
  self.logDebug(10, "Failed to load list, skipping maintenance (probably harmless)", cleanup_list_path);
708
- if (callback) callback();
718
+ doEngineMaint();
709
719
  }
710
720
  else {
711
721
  // no error, delete list
@@ -713,15 +723,7 @@ module.exports = Class.create({
713
723
  if (err) {
714
724
  self.logError('maint', "Failed to delete cleanup list: " + cleanup_list_path + ": " + err);
715
725
  }
716
-
717
- // allow engine to run maint as well
718
- self.engine.runMaintenance( function() {
719
- stats.elapsed_sec = Tools.timeNow() - stats.time_start;
720
- self.logDebug(3, "Daily maintenance complete");
721
- self.logTransaction('maint', cleanup_list_path, stats);
722
- if (callback) callback();
723
- } );
724
-
726
+ doEngineMaint();
725
727
  } ); // listDelete
726
728
  } // succes
727
729
  } // list complete
package/test/config.json CHANGED
@@ -64,6 +64,10 @@
64
64
  "host": "127.0.0.1",
65
65
  "port": 6379,
66
66
  "keyPrefix": ""
67
+ },
68
+ "SQLite": {
69
+ "base_dir": "data",
70
+ "filename": "sqlite.db"
67
71
  }
68
72
  }
69
73
  }
package/test/test-main.js CHANGED
@@ -450,7 +450,7 @@ module.exports = {
450
450
  test.ok( !!storageStream, "Got storage stream as 2nd arg");
451
451
  test.ok( !!storageStream.pipe, "Storage stream has a pipe");
452
452
  test.ok( !!streamInfo, "Info was provided as the 3rd arg");
453
- test.ok( streamInfo.len == 6, "Info has correct data length");
453
+ test.ok( streamInfo.len == 43, "Info has correct data length (expected 43, got " + streamInfo.len + ")");
454
454
  test.ok( streamInfo.mod > 0, "Info has a non-zero mod date");
455
455
 
456
456
  outStream.on('finish', function() {
@@ -497,7 +497,7 @@ module.exports = {
497
497
  test.ok( !!storageStream, "Got storage stream as 2nd arg");
498
498
  test.ok( !!storageStream.pipe, "Storage stream has a pipe");
499
499
  test.ok( !!streamInfo, "Info was provided as the 3rd arg");
500
- test.ok( streamInfo.len == 23, "Info has correct data length");
500
+ test.ok( streamInfo.len == 43, "Info has correct data length (expected 43, got " + streamInfo.len + ")");
501
501
  test.ok( streamInfo.mod > 0, "Info has a non-zero mod date");
502
502
 
503
503
  outStream.on('finish', function() {
@@ -544,7 +544,7 @@ module.exports = {
544
544
  test.ok( !!storageStream, "Got storage stream as 2nd arg");
545
545
  test.ok( !!storageStream.pipe, "Storage stream has a pipe");
546
546
  test.ok( !!streamInfo, "Info was provided as the 3rd arg");
547
- test.ok( streamInfo.len == 10, "Info has correct data length");
547
+ test.ok( streamInfo.len == 43, "Info has correct data length (expected 43, got " + streamInfo.len + ")");
548
548
  test.ok( streamInfo.mod > 0, "Info has a non-zero mod date");
549
549
 
550
550
  outStream.on('finish', function() {