-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathDataStorage.js
More file actions
501 lines (468 loc) · 15.8 KB
/
DataStorage.js
File metadata and controls
501 lines (468 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
import { BehaviorSubject, Subject } from 'rxjs'
import {
isArr,
isArr2D,
isArrLike,
isDefined,
isFn,
isMap,
isNodeJS,
isObj,
isPositiveInteger,
isStr,
isValidNumber,
mapSearch,
mapSort,
} from './utils'
/* For NodeJS (non-browser applications) the following node module is required: node-localstorage */
/**
* @name rxForceUpdateCache
* @summary force all or certain instances of DataStorage to reload data from storage
*
* @param {Boolean|Array}
*
* @example
* ```javascript
* // Update only certain modules
* const moduleKey = 'totem_identities'
* rxForceUpdateCache.next([moduleKey])
*
* // Update every single instance of DataStorage that uses storage (has a "name")
* rxForceUpdateCache.next(true)
* ```
*/
export const rxForceUpdateCache = new Subject()
let _storage = getStorage()
/**
*
* @param location The location in which the local storage resides
* @param quota The partitioned size of the local storage
*
* @returns { LocalStorage }
*/
export function getStorage(storagePath, quota) {
try {
if (isNodeJS()) {
const { LocalStorage } = require('node-localstorage')
// for NodeJS server
storagePath = storagePath
|| process.env.STORAGE_PATH
|| './data'
const fileLimit = process.env.STORAGE_FILE_LIMIT
const absolutePath = require('path').resolve(storagePath)
quota = isValidNumber(quota)
? quota
: parseInt(fileLimit || 500 * 1024 * 1024 * 1024)
console.log('DataStorage', { STORAGE_PATH: storagePath, absolutePath })
return new LocalStorage(absolutePath, quota)
} else if (localStorage) {
// for web browser
return localStorage
}
} catch (err) {
/* ignore error if not nodejs */
if (isNodeJS() && err.message.toLowerCase().includes('no such file or directory')) throw err
}
console.warn('DataStorage: storage not supported. Writing data will not work. Using workaround to avoid error.')
// Hack for IFrame or if "node-localstorage" module is not available.
// Caution: All data will be lost as soon as application is closed.
const storage = new DataStorage()
storage.getItem = (...args) => storage.get(...args)
storage.setItem = (...args) => storage.set(...args)
return storage
}
/**
* @name read
* @summary read from storage (JSON file if NodeJS, otherwise, browser LocalStorage)
*
* @param {String} key file name (NodeJS) or property key (LocalStorage)
*
* @returns {Map} retieved data
*/
export const read = (key, asMap = true, storage = _storage) => {
let data = undefined
try {
data = JSON.parse(storage.getItem(key))
} catch (_) { }
return !asMap
? data
: new Map(data || [])
}
/**
* @name write
* @summary write to storage (JSON file if NodeJS, otherwise, browser LocalStorage)
*
* @param {String} key file name (NodeJS) or property key (LocalStorage)
* @param {String|*} value will be converted to JSON string
* @param {Boolean} asMap indicates whether value is a Map
* @param {String|*} value will be converted to JSON string
* @param {String|*} value will be converted to JSON string
* @param {String|*} value will be converted to JSON string
*/
export const write = (
key,
value,
asMap = true,
storage = _storage,
silent = true
) => {
// invalid key: ignore request
if (!isStr(key)) return
try {
if (!isStr(value)) {
value = JSON.stringify(
asMap
? Array.from(value)
: isArrLike(value)
? [...value.values()]
: value
)
}
storage.setItem(key, value)
return true
} catch (err) {
if (!silent) throw err
console.error('DataStorage: failed to write file.', err)
}
}
export default class DataStorage {
/**
* @name DataStorage
* @summary a wrapper to read/write to LocalStorage (browser) or JSON file (NodeJS) with added features.
* @description Notes:
* - this is a key-value storage that mimics the structure of `Map` and add extra functionalities like search.
* - `name` is not supplied: `disableCache` will always be assumed true
* - `disableCache = true`: reads once from storage and and only write when necessary.
* - `disableCache = false`: data is never preserved in-memory and every read/write
* operation will be directly from/to the appropriate storage
*
* @param {String} name filename (NodeJS) or property name (browser LocalStorage).
* @param {Boolean} disableCache (optional) Whether to keep data in-memory.
* Default: `false`
* @param {Function} onChange (optional) callback to be invoked on change of data.
* See Subject/BehaviorSubject.subscribe for more details.
* @param {Map} initialValue (optional) Default: new Map()
*/
constructor(
name,
disableCache = false,
initialValue,
onChange,
storage = _storage,
) {
let data = (name && read(name, true, storage)) || initialValue
data = !isMap(data)
? new Map(
isArr2D(data)
? data
: undefined
)
: data
this.name = name
this.disableCache = name && disableCache
this.rxData = this.disableCache
? new Subject()
: new BehaviorSubject(data)
// `this.save` can be used to skip write operations temporarily by setting it to false
this.save = true
this.storage = storage
let ignoredFirst = this.disableCache
this.rxData.subscribe(data => {
if (!ignoredFirst) {
// prevent save operation on startup when BehaviorSubject is used
ignoredFirst = true
return
}
this.write(data)
this.save = true
isFn(onChange) && onChange(data)
})
if (this.disableCache) return
// update cached data from localStorage throughout the application only when triggered
rxForceUpdateCache.subscribe(refresh => {
const doRefresh = !this.name
? false
: isArr(refresh) || isStr(refresh)
? refresh.includes(this.name)
: refresh === true
if (!doRefresh) return
const data = read(this.name, true, storage)
// prevent (unnecessary) writing to storage
this.save = false
this.rxData.next(data)
})
}
/**
* @name clear
* @summary clear stoarge data
*
* @returns {DataStorage} this
*/
clear() { return this.setAll(new Map(), true) }
/**
* @name delete
* @summary delete one or more items by their respective keys
*
* @param {Array|String} keys one or more keys
*
* @returns {DataStorage} reference to the DataStorage instance
*/
delete(keys = []) {
const data = this.getAll()
keys = isArr(keys)
? keys
: [keys]
// nothing to do
if (!keys.length) return this
keys.forEach(key => data.delete(key))
this.rxData.next(data)
return this
}
/**
* @name find
* @summary find the first item matching criteria. Uniqueness is not guaranteed.
*
* @param {Object} query Object with property names and the the value to match
* @param {Boolean} matchExact (optional) fulltext or partial search. Default: false
* @param {Boolean} matchAll (optional) AND/OR operation for keys in @query. Default: false
* @param {Boolean} ignoreCase (optional) case-sensitivity of the search. Default: false
*/
find(query, matchExact, matchAll, ignoreCase, searchKeys, includeId = false) {
const result = this.search(
query,
matchExact,
matchAll,
ignoreCase,
1,
searchKeys,
)
return result.size === 0
? null
: !includeId
? Array.from(result)[0][1]
: Array.from(result)[0]
}
/**
* @name get
* @summary get item by key
*
* @param {String} key
*
* @returns {*} value stored for the supplied @key
*/
get(key) { return this.getAll().get(key) }
/**
* @name forceRead
* @summary force read from storage
*
* @param {Boolean} forceRead
*
* @returns {Map} data
*/
getAll(forceRead = false) {
if (!forceRead && !this.disableCache) return this.rxData.value
const data = read(this.name, true, this.storage)
return data
}
/**
* @name has
* @summary check if key exists
*
* @param {String} key
*
* @returns {Boolean}
*/
has(key) { return this.getAll().has(key) }
/**
* @name keys
*
* @returns {Array}
*/
keys() { return [...this.getAll().keys()] }
/**
* @name map
* @summary map each item on the data to an Array. This is a shorhand for `Array.from(this.getAll()).map(cb)`
* @param {Function} callback callback function to execute for each item in the list. 3 arguments supplied:
* @item Array: Each item will contain key and value in an array. Eg: [key, value]
* @index Number
* @array Array: The entire Map in a 2D Array. Eg: [[key, value], [key2, value2]]
*
* @returns {Array} array of items returned by callback
*/
map(callback) { return this.toArray().map(callback) }
/**
* @name search
* @summary partial or fulltext search on storage data
*
* @param {Object|String} query Object with property names and the the value to match.
* Alternatively, do fuzzy search by supplying `String/Number`.
* @param {Boolean} matchExact (optional) fulltext or partial search. Default: false
* @param {Boolean} matchAll (optional) AND/OR operation for keys in `@query`. Default: false
* @param {Boolean} ignoreCase (optional) case-sensitivity of the search. Default: false.
* @param {Number} limit (optional) limits number of results. Default: 0 (no limit)
* @param {Array} searchKeys (optional) if `@query` is string, search by specific properties.
* Default: `undefined` (all properties/keys from the first available entry)
*
* @returns {Map} result
*/
search(
query,
matchExact = false,
matchAll = false,
ignoreCase = false,
limit = 0,
searchKeys
) {
const allEntries = this.getAll()
if (!isObj(query)) {
const [_, firstEntry] = [...allEntries][0]
searchKeys ??= Object.keys(firstEntry || {})
query = searchKeys.reduce((obj, key) => ({
...obj,
[key]: query,
}), {})
}
const result = mapSearch(
allEntries,
query,
matchExact,
matchAll,
ignoreCase,
)
const doLimit = isPositiveInteger(limit) && result.size > limit
return !doLimit
? result
: new Map(
Array.from(result)
.slice(0, limit)
)
}
/**
* @name set
* @summary save/update an item
*
* @param {String|Number|Boolean} key
* @param {*} value
*
* @returns {DataStorage} reference to the DataStorage instance
*/
set(key, value) {
if (!isDefined(key)) return this
const data = this.getAll()
data.set(key, value)
this.rxData.next(data)
return this
}
/**
* @name setAll
* @summary set multiple items at one go
*
* @param {Map} data list of items
* @param {Boolean} override whether to override or merge with existing data
* @param {Boolean} writeNow whether to immediately write to storage.
* If truthy, will throw error if fails to write to strorage
*
* @returns {DataStorage} reference to the DataStorage instance
*/
setAll(data, override = true, writeNow = false) {
if (!isMap(data)) return this
if (!override) {
// merge data
const existing = this.getAll()
Array.from(data)
.forEach(([key, value]) =>
existing.set(key, value)
)
data = existing // merged value
}
if (writeNow) {
this.save = false
this.write(data, false)
this.save = true
}
this.rxData.next(data)
return this
}
/**
* @name size
* @summary size of the data Map
*
* @returns {Number}
*/
get size() { return this.getAll().size }
/**
* @name sort
* @summary sort data by key or simply reverse the entire list. Optionally, save sorted data to storage.
*
* @param {Boolean} reverse whether to reverse reverse sort. Deafult: false
* @param {String} key (optional) sort by specific key. If `!key && !!reverse`, reverse the entire list.
* @param {Boolean} save (optional) whether to save sorted data to storage. Default: false
*
* @retuns {Map}
*/
sort(key, reverse = false, save = false) {
let data = this.getAll()
if (!key && !reverse) return data // nothing to do
data = !key
? new Map(Array.from(data).reverse())
: mapSort(data, key, reverse)
if (save) this.setAll(data)
return data
}
/**
* @name toArray
* @summary convert list of items (Map) to 2D Array
*
* @returns {Array}
*/
toArray() { return Array.from(this.getAll()) }
/**
* @name toJSON
* @summary converts list of items (Map) to JSON string of 2D Array
*
* @param {Function} replacer (optional) for use with `JSON.stringify`. Default: null
* @param {Number} spacing (optional) for use with `JSON.stringify`. Default: 0
*
* @returns {String} JSON string
*/
toJSON(replacer, spacing) {
return JSON.stringify(
this.toArray(),
replacer,
spacing,
)
}
/**
* @name toString
* @summary converts list of items (Map) to JSON string of 2D Array
*
* @param {Function} replacer (optional) for use with `JSON.stringify`. Default: null
* @param {Number} spacing (optional) for use with `JSON.stringify`. Default: 0
*
* @returns {String} JSON string
*/
toString() { return this.toJSON(null, 4) }
/**
* @name values
*
* @returns {Array}
*/
values() { return [...this.getAll().values()] }
/**
* @name write
* @summary trigger a synchronous write operation to the localStorage (browser) or file (NodeJS).
* If storage doesn't have a `name` or `save` property is falsy will skip writing.
*
* @param {Boolean} silent Whether to throw error if write operation fails.
* Will print error message, regardless.
*/
write(data, silent = true) {
this.name && this.save && write(
this.name,
data,
true,
this.storage,
silent,
)
}
}