jexidb 1.0.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/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "jexidb",
3
+ "version": "1.0.2",
4
+ "description": "JexiDB is a pure JS NPM library for managing data on disk using JSONL efficiently, without the need for a server.",
5
+ "main": "./dist/Database.cjs",
6
+ "module": "./src/Database.mjs",
7
+ "exports": {
8
+ ".": {
9
+ "require": "./dist/Database.cjs",
10
+ "import": "./src/Database.mjs"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "test": "node test/test.mjs && exit 1",
15
+ "build": "npx babel src/Database.mjs --plugins @babel/plugin-transform-async-generator-functions --out-file-extension .cjs --out-dir dist"
16
+ },
17
+ "author": "EdenwareApps",
18
+ "license": "MIT",
19
+ "devDependencies": {
20
+ "@babel/cli": "^7.25.6",
21
+ "@babel/core": "^7.25.2",
22
+ "@babel/plugin-transform-async-generator-functions": "^7.25.4",
23
+ "@babel/preset-env": "^7.25.4"
24
+ },
25
+ "dependencies": {
26
+ "p-limit": "^6.1.0"
27
+ },
28
+ "directories": {
29
+ "test": "test"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/EdenwareApps/jexidb.git"
34
+ },
35
+ "keywords": [
36
+ "couchdb",
37
+ "database",
38
+ "nosql",
39
+ "pouchdb",
40
+ "local-storage",
41
+ "db",
42
+ "persistent-storage",
43
+ "dexiejs",
44
+ "embedded-database",
45
+ "data-management",
46
+ "nedb",
47
+ "lowdb",
48
+ "dexie",
49
+ "offline-database",
50
+ "simple-database",
51
+ "fast-database",
52
+ "jexidb"
53
+ ],
54
+ "bugs": {
55
+ "url": "https://github.com/EdenwareApps/jexidb/issues"
56
+ },
57
+ "homepage": "https://github.com/EdenwareApps/jexidb#readme"
58
+ }
@@ -0,0 +1,365 @@
1
+ import { EventEmitter } from 'events'
2
+ import FileHandler from './FileHandler.mjs'
3
+ import IndexManager from './IndexManager.mjs'
4
+ import Serializer from './serializers/Simple.mjs'
5
+ import AdvancedSerializer from './serializers/Advanced.mjs'
6
+ import fs from 'fs'
7
+
8
+ export class Database extends EventEmitter {
9
+ constructor(filePath, opts={}) {
10
+ super()
11
+ this.opts = Object.assign({
12
+ v8: false,
13
+ index: {data: {}},
14
+ indexes: {},
15
+ compress: false,
16
+ compressIndex: false,
17
+ maxMemoryUsage: 64 * 1024 // 64KB
18
+ }, opts)
19
+ this.shouldSave = false
20
+ if(this.opts.v8 || this.opts.compress || this.opts.compressIndex) {
21
+ this.serializer = new AdvancedSerializer(this.opts)
22
+ } else {
23
+ this.serializer = new Serializer(this.opts)
24
+ }
25
+ this.fileHandler = new FileHandler(filePath)
26
+ this.indexManager = new IndexManager(this.opts)
27
+ this.indexOffset = 0
28
+ this.writeBuffer = []
29
+ }
30
+
31
+ use(plugin) {
32
+ if(this.destroyed) throw new Error('Database is destroyed')
33
+ plugin(this)
34
+ }
35
+
36
+ async init() {
37
+ if(this.destroyed) throw new Error('Database is destroyed')
38
+ if(this.initialized) return
39
+ if(this.initlializing) return await new Promise(resolve => this.once('init', resolve))
40
+ this.initializing = true
41
+ try {
42
+ if(this.opts.clear) {
43
+ await this.fileHandler.truncate(0).catch(console.error)
44
+ throw new Error('Cleared, empty file')
45
+ }
46
+ const lastLine = await this.fileHandler.readLastLine()
47
+ if(!lastLine || !lastLine.length) {
48
+ throw new Error('File does not exists or is a empty file')
49
+ }
50
+ const offsets = await this.serializer.deserialize(lastLine, {compress: this.opts.compressIndex})
51
+ if(!Array.isArray(offsets)) {
52
+ throw new Error('File to parse offsets, expected an array')
53
+ }
54
+ this.indexOffset = offsets[offsets.length - 2]
55
+ this.offsets = offsets
56
+ const ptr = this.locate(offsets.length - 2)
57
+ this.offsets = this.offsets.slice(0, -2)
58
+ this.shouldTruncate = true
59
+ let indexLine = await this.fileHandler.readRange(...ptr)
60
+ const index = await this.serializer.deserialize(indexLine, {compress: this.opts.compressIndex})
61
+ index && this.indexManager.load(index)
62
+ } catch (e) {
63
+ if(!this.offsets) {
64
+ this.offsets = []
65
+ }
66
+ this.indexOffset = 0
67
+ if(!String(e).includes('empty file')) {
68
+ console.error('Error loading database:', e)
69
+ }
70
+ } finally {
71
+ this.initializing = false
72
+ this.initialized = true
73
+ this.emit('init')
74
+ }
75
+ }
76
+
77
+ async save() {
78
+ if(this.destroyed) throw new Error('Database is destroyed')
79
+ if(this.saving) return new Promise(resolve => this.once('save', resolve))
80
+ this.saving = true
81
+ await this.flush()
82
+ this.emit('before-save')
83
+ const index = Object.assign({data: {}}, this.indexManager.index)
84
+ for(const field in this.indexManager.index.data) {
85
+ for(const term in this.indexManager.index.data[field]) {
86
+ index.data[field][term] = [...this.indexManager.index.data[field][term]] // set to array
87
+ }
88
+ }
89
+ const offsets = this.offsets.slice(0)
90
+ const indexString = await this.serializer.serialize(index, {compress: this.opts.compressIndex})
91
+ for(const field in this.indexManager.index.data) {
92
+ for(const term in this.indexManager.index.data[field]) {
93
+ this.indexManager.index.data[field][term] = new Set(index.data[field][term]) // set back to set because of serialization
94
+ }
95
+ }
96
+ offsets.push(this.indexOffset)
97
+ offsets.push(this.indexOffset + indexString.length)
98
+ const offsetsString = await this.serializer.serialize(offsets, {compress: this.opts.compressIndex, linebreak: false})
99
+ if (this.shouldTruncate) {
100
+ await this.fileHandler.truncate(this.indexOffset)
101
+ this.shouldTruncate = false
102
+ }
103
+ this.writeBuffer.push(indexString)
104
+ this.writeBuffer.push(offsetsString)
105
+ await this.flush() // write the index and offsets
106
+ this.shouldTruncate = true
107
+ this.shouldSave = false
108
+ this.saving = false
109
+ this.emit('save')
110
+ }
111
+
112
+ async ready() {
113
+ if (!this.initialized) {
114
+ await new Promise(resolve => this.once('init', resolve))
115
+ }
116
+ }
117
+
118
+ locate(n) {
119
+ if (this.offsets[n] === undefined) {
120
+ if(this.offsets[n - 1]) {
121
+ return [this.indexOffset, Number.MAX_SAFE_INTEGER]
122
+ }
123
+ return
124
+ }
125
+ let end = this.offsets[n + 1] || this.indexOffset || Number.MAX_SAFE_INTEGER
126
+ return [this.offsets[n], end]
127
+ }
128
+
129
+ getRanges(map) {
130
+ return (map || Array.from(this.offsets.keys())).map(n => {
131
+ const ret = this.locate(n)
132
+ if(ret !== undefined) return {start: ret[0], end: ret[1], index: n}
133
+ }).filter(n => n !== undefined)
134
+ }
135
+
136
+ async readLines(map, ranges) {
137
+ if(!ranges) ranges = this.getRanges(map)
138
+ const results = await this.fileHandler.readRanges(ranges, this.serializer.deserialize.bind(this.serializer))
139
+ let i = 0
140
+ for(const start in results) {
141
+ if(!results[start] || results[start]._ !== undefined) continue
142
+ while(this.offsets[i] != start && i < map.length) i++ // weak comparison as 'start' is a string
143
+ results[start]._ = map[i++]
144
+ }
145
+ return Object.values(results).filter(r => r !== undefined)
146
+ }
147
+
148
+ async insert(data) {
149
+ if(this.destroyed) throw new Error('Database is destroyed')
150
+ const line = await this.serializer.serialize(data, {compress: this.opts.compress}) // using Buffer for offsets accuracy
151
+ if (this.shouldTruncate) {
152
+ this.writeBuffer.push(this.indexOffset)
153
+ this.shouldTruncate = false
154
+ }
155
+ const position = this.offsets.length
156
+ this.offsets.push(this.indexOffset)
157
+ this.indexOffset += line.length
158
+ this.indexManager.add(data, position)
159
+ this.emit('insert', data, position)
160
+ this.writeBuffer.push(line)
161
+ if(!this.flushing && this.currentWriteBufferSize() > this.opts.maxMemoryUsage) {
162
+ await this.flush()
163
+ }
164
+ this.shouldSave = true
165
+ }
166
+
167
+ currentWriteBufferSize(){
168
+ const lengths = this.writeBuffer.filter(b => Buffer.isBuffer(b)).map(b => b.length)
169
+ return lengths.reduce((a, b) => a + b, 0)
170
+ }
171
+
172
+ flush() {
173
+ if(this.flushing) return this.flushing
174
+ return new Promise((resolve, reject) => {
175
+ if(this.destroyed) return reject(new Error('Database is destroyed'))
176
+ if(!this.writeBuffer.length) return resolve()
177
+ let err
178
+ this.flushing = this._flush().catch(e => err = e).finally(() => {
179
+ this.flushing = false
180
+ err ? reject(err) : resolve()
181
+ })
182
+ })
183
+ }
184
+
185
+ async _flush() {
186
+ let fd = await fs.promises.open(this.fileHandler.filePath, 'a')
187
+ try {
188
+ while(this.writeBuffer.length) {
189
+ let data
190
+ const pos = this.writeBuffer.findIndex(b => typeof b === 'number')
191
+ if(pos === 0) {
192
+ await fd.close()
193
+ await this.fileHandler.truncate(this.writeBuffer.shift())
194
+ fd = await fs.promises.open(this.fileHandler.filePath, 'a')
195
+ continue
196
+ } else if(pos === -1) {
197
+ data = Buffer.concat(this.writeBuffer)
198
+ this.writeBuffer.length = 0
199
+ } else {
200
+ data = Buffer.concat(this.writeBuffer.slice(0, pos))
201
+ this.writeBuffer.splice(0, pos)
202
+ }
203
+ await fd.write(data)
204
+ }
205
+ this.shouldSave = true
206
+ } catch(err) {
207
+ console.error('Error flushing:', err)
208
+ } finally {
209
+ await fd.close()
210
+ }
211
+ }
212
+
213
+ async *walk(map, options={}) {
214
+ if(this.destroyed) throw new Error('Database is destroyed')
215
+ this.shouldSave && await this.save().catch(console.error)
216
+ if(this.indexOffset === 0) return
217
+ if(!Array.isArray(map)) {
218
+ if(map && typeof map === 'object') {
219
+ map = this.indexManager.query(map, options.matchAny)
220
+ } else {
221
+ map = [...Array(this.offsets.length).keys()]
222
+ }
223
+ }
224
+ const ranges = this.getRanges(map)
225
+ const partitionedRanges = [], currentPartition = 0
226
+ for (const line in ranges) {
227
+ if (partitionedRanges[currentPartition] === undefined) {
228
+ partitionedRanges[currentPartition] = []
229
+ }
230
+ partitionedRanges[currentPartition].push(ranges[line])
231
+ if (partitionedRanges[currentPartition].length >= this.opts.maxMemoryUsage) {
232
+ currentPartition++
233
+ }
234
+ }
235
+ let m = 0
236
+ for (const ranges of partitionedRanges) {
237
+ const lines = await this.fileHandler.readRanges(ranges)
238
+ for (const line in lines) {
239
+ let err
240
+ const entry = await this.serializer.deserialize(lines[line]).catch(e => console.error(err = e))
241
+ if (err) continue
242
+ if (entry._ === undefined) {
243
+ while(this.offsets[m] != line && m < map.length) m++ // weak comparison as 'start' is a string
244
+ entry._ = m++
245
+ }
246
+ yield entry
247
+ }
248
+ }
249
+ }
250
+
251
+ async query(criteria, options={}) {
252
+ if(this.destroyed) throw new Error('Database is destroyed')
253
+ this.shouldSave && await this.save().catch(console.error)
254
+ if(Array.isArray(criteria)) {
255
+ let results = await this.readLines(criteria)
256
+ if (options.orderBy) {
257
+ const [field, direction = 'asc'] = options.orderBy.split(' ')
258
+ results.sort((a, b) => {
259
+ if (a[field] > b[field]) return direction === 'asc' ? 1 : -1
260
+ if (a[field] < b[field]) return direction === 'asc' ? -1 : 1
261
+ return 0;
262
+ })
263
+ }
264
+ if (options.limit) {
265
+ results = results.slice(0, options.limit);
266
+ }
267
+ return results
268
+ } else {
269
+ const matchingLines = await this.indexManager.query(criteria, options.matchAny)
270
+ if (!matchingLines || !matchingLines.size) {
271
+ return []
272
+ }
273
+ return await this.query([...matchingLines], options)
274
+ }
275
+ }
276
+
277
+ async update(criteria, data, options={}) {
278
+ if(this.destroyed) throw new Error('Database is destroyed')
279
+ this.shouldSave && await this.save().catch(console.error)
280
+ const matchingLines = await this.indexManager.query(criteria, options.matchAny)
281
+ if (!matchingLines || !matchingLines.size) {
282
+ return []
283
+ }
284
+ const ranges = this.getRanges([...matchingLines])
285
+ const validMatchingLines = new Set(ranges.map(r => r.index))
286
+ if (!validMatchingLines.size) {
287
+ return []
288
+ }
289
+ const entries = await this.readLines([...validMatchingLines], ranges)
290
+ const lines = []
291
+ for(const entry of entries) {
292
+ let err
293
+ const updated = Object.assign(entry, data)
294
+ const ret = await this.serializer.serialize(updated).catch(e => err = e)
295
+ err || lines.push(ret)
296
+ }
297
+ const offsets = []
298
+ let byteOffset = 0, k = 0
299
+ this.offsets.forEach((n, i) => {
300
+ const prevByteOffset = byteOffset
301
+ if (validMatchingLines.has(i) && ranges[k]) {
302
+ const r = ranges[k]
303
+ byteOffset += lines[k].length - (r.end - r.start)
304
+ k++
305
+ }
306
+ offsets.push(n + prevByteOffset)
307
+ })
308
+ this.offsets = offsets
309
+ this.indexOffset += byteOffset
310
+ await this.fileHandler.replaceLines(ranges, lines);
311
+ [...validMatchingLines].forEach((lineNumber, i) => {
312
+ this.indexManager.dryRemove(lineNumber)
313
+ this.indexManager.add(entries[i], lineNumber)
314
+ })
315
+ this.shouldSave = true
316
+ return entries
317
+ }
318
+
319
+ async delete(criteria, options={}) {
320
+ if(this.destroyed) throw new Error('Database is destroyed')
321
+ this.shouldSave && await this.save().catch(console.error)
322
+ const matchingLines = await this.indexManager.query(criteria, options.matchAny)
323
+ if (!matchingLines || !matchingLines.size) {
324
+ return 0
325
+ }
326
+ const ranges = this.getRanges([...matchingLines])
327
+ const validMatchingLines = new Set(ranges.map(r => r.index))
328
+ await this.fileHandler.replaceLines(ranges, [])
329
+ const offsets = []
330
+ let byteOffset = 0, k = 0
331
+ this.offsets.forEach((n, i) => {
332
+ if (validMatchingLines.has(i)) {
333
+ const r = ranges[k]
334
+ byteOffset -= (r.end - r.start)
335
+ k++
336
+ } else {
337
+ offsets.push(n + byteOffset)
338
+ }
339
+ })
340
+ this.offsets = offsets
341
+ this.indexOffset += byteOffset
342
+ this.indexManager.remove([...validMatchingLines])
343
+ this.shouldSave = true
344
+ return ranges.length
345
+ }
346
+
347
+ async destroy() {
348
+ this.shouldSave && await this.save().catch(console.error)
349
+ this.destroyed = true
350
+ this.indexOffset = 0
351
+ this.indexManager.index = {}
352
+ this.writeBuffer.length = 0
353
+ this.initialized = false
354
+ this.fileHandler.destroy()
355
+ }
356
+
357
+ get length() {
358
+ return this.offsets.length
359
+ }
360
+
361
+ get index() {
362
+ return this.indexManager.index
363
+ }
364
+
365
+ }
@@ -0,0 +1,133 @@
1
+ import fs from 'fs'
2
+ import pLimit from 'p-limit'
3
+
4
+ export default class FileHandler {
5
+ constructor(filePath) {
6
+ this.filePath = filePath
7
+ }
8
+
9
+ async truncate(offset) {
10
+ try {
11
+ await fs.promises.access(this.filePath, fs.constants.F_OK)
12
+ await fs.promises.truncate(this.filePath, offset)
13
+ } catch (err) {
14
+ await fs.promises.writeFile(this.filePath, '')
15
+ }
16
+ }
17
+
18
+ async readRange(start, end) {
19
+ let fd = await fs.promises.open(this.filePath, 'r')
20
+ const length = end - start
21
+ let buffer = Buffer.alloc(length)
22
+ const { bytesRead } = await fd.read(buffer, 0, length, start).catch(console.error)
23
+ await fd.close()
24
+ if(buffer.length > bytesRead) return buffer.subarray(0, bytesRead)
25
+ return buffer
26
+ }
27
+
28
+ async readRanges(ranges, mapper) {
29
+ const lines = {}, limit = pLimit(4)
30
+ const fd = await fs.promises.open(this.filePath, 'r')
31
+ try {
32
+ const tasks = ranges.map(r => {
33
+ return async () => {
34
+ let err
35
+ const length = r.end - r.start
36
+ let buffer = Buffer.alloc(length)
37
+ const { bytesRead } = await fd.read(buffer, 0, length, r.start).catch(e => err = e)
38
+ if (buffer.length > bytesRead) buffer = buffer.subarray(0, bytesRead)
39
+ lines[r.start] = mapper ? (await mapper(buffer, r)) : buffer
40
+ }
41
+ })
42
+ await Promise.allSettled(tasks.map(limit))
43
+ } catch (e) {
44
+ console.error('Error reading ranges:', e)
45
+ } finally {
46
+ await fd.close()
47
+ }
48
+ return lines
49
+ }
50
+
51
+ async replaceLines(ranges, lines) {
52
+ let closed
53
+ const tmpFile = this.filePath + '.tmp'
54
+ const writer = await fs.promises.open(tmpFile, 'w+')
55
+ const reader = await fs.promises.open(this.filePath, 'r')
56
+ try {
57
+ let i = 0, start = 0
58
+ for (const r of ranges) {
59
+ const length = r.start - start
60
+ const buffer = Buffer.alloc(length)
61
+ await reader.read(buffer, 0, length, start)
62
+ start = r.end
63
+ buffer.length && await writer.write(buffer)
64
+ if (lines[i]) {
65
+ await writer.write(lines[i])
66
+ }
67
+ i++
68
+ }
69
+ const size = (await reader.stat()).size
70
+ const length = size - start
71
+ const buffer = Buffer.alloc(length)
72
+ await reader.read(buffer, 0, length, start)
73
+ await writer.write(buffer)
74
+ await reader.close()
75
+ await writer.close()
76
+ closed = true
77
+ await fs.promises.copyFile(tmpFile, this.filePath)
78
+ } catch (e) {
79
+ console.error('Error replacing lines:', e)
80
+ } finally {
81
+ if(!closed) {
82
+ await reader.close()
83
+ await writer.close()
84
+ }
85
+ await fs.promises.unlink(tmpFile).catch(() => {})
86
+ }
87
+ }
88
+ async writeData(data, immediate, fd) {
89
+ await fd.write(data)
90
+ }
91
+
92
+ writeDataSync(data) {
93
+ fs.writeFileSync(this.filePath, data, { flag: 'a' })
94
+ }
95
+
96
+ async readLastLine() {
97
+ const reader = await fs.promises.open(this.filePath, 'r')
98
+ try {
99
+ const { size } = await reader.stat()
100
+ if (size < 1) throw 'empty file'
101
+ this.size = size
102
+ const bufferSize = 16384
103
+ let buffer, lastReadSize, readPosition = Math.max(size - bufferSize, 0)
104
+ while (readPosition >= 0) {
105
+ const readSize = Math.min(bufferSize, size - readPosition)
106
+ if (readSize !== lastReadSize) {
107
+ lastReadSize = readSize
108
+ buffer = Buffer.alloc(readSize)
109
+ }
110
+ const { bytesRead } = await reader.read(buffer, 0, readSize, readPosition)
111
+ if (bytesRead === 0) break
112
+ const newlineIndex = buffer.lastIndexOf(10, size - 4) // 0x0A is the ASCII code for '\n'
113
+ if (newlineIndex !== -1) {
114
+ const start = readPosition + newlineIndex + 1
115
+ const lastLine = Buffer.alloc(size - start)
116
+ await reader.read(lastLine, 0, size - start, start)
117
+ if (!lastLine || !lastLine.length) {
118
+ throw 'no metadata or empty file'
119
+ }
120
+ return lastLine
121
+ } else {
122
+ readPosition -= bufferSize
123
+ }
124
+ }
125
+ } catch (e) {
126
+ String(e).includes('empty file') || console.error('Error reading last line:', e)
127
+ } finally {
128
+ reader.close()
129
+ }
130
+ }
131
+
132
+ async destroy() {}
133
+ }