@jsonkit/db 1.0.1 → 3.0.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.
package/README.md CHANGED
@@ -1,3 +1,249 @@
1
1
  # @jsonkit/db
2
2
 
3
- A simple JSON file based database for use in simple server side applications
3
+ `@jsonkit/db` is a lightweight, zero-dependency database abstraction for rapid prototyping. It provides simple **file-based** and **in-memory** databases with consistent APIs, suitable for small applications, tooling, tests, and early-stage prototypes where setting up a full database would be unnecessary overhead.
4
+
5
+ The package exposes two core concepts:
6
+
7
+ * **Single-entry databases** – manage exactly one JSON-serializable object.
8
+ * **Multi-entry databases** – manage collections of identifiable records keyed by an `id`.
9
+
10
+ Both concepts are available in **file-backed** and **in-memory** variants.
11
+
12
+ ---
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @jsonkit/db
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Multi-entry Databases
23
+
24
+ Multi-entry databases manage collections of entries keyed by `id`.
25
+
26
+ ### Common API (`MultiEntryDb<T>`)
27
+
28
+ All multi-entry implementations expose the same methods:
29
+
30
+ * `create(entry)`
31
+ * `getById(id)`
32
+ * `getByIdOrThrow(id)`
33
+ * `getWhere(predicate, pagination?)`
34
+ * `getAll(ids?)`
35
+ * `getAllIds()`
36
+ * `update(id, updater)`
37
+ * `deleteById(id)`
38
+ * `deleteByIds(ids)`
39
+ * `deleteWhere(predicate)`
40
+ * `exists(id)`
41
+ * `countAll()`
42
+ * `countWhere(predicate)`
43
+ * `destroy()`
44
+
45
+ Updates are **partial merges**, and changing an entry’s `id` during an update is supported.
46
+
47
+ ---
48
+
49
+ ### `MultiEntryFileDb<T extends Identifiable>`
50
+
51
+ A file-backed database where **each entry is stored as its own JSON file**.
52
+
53
+ ```ts
54
+ import { MultiEntryFileDb } from '@jsonkit/db'
55
+
56
+ const db = new MultiEntryFileDb<User>('./data/users')
57
+ ```
58
+
59
+ #### Behavior
60
+
61
+ * Each entry is stored as `<id>.json` in the provided directory.
62
+ * The directory is created implicitly as files are written.
63
+ * IDs are validated to prevent path traversal by default.
64
+
65
+ #### Constructor
66
+
67
+ ```ts
68
+ new MultiEntryFileDb<T>(dirpath, options?)
69
+ ```
70
+
71
+ **Options**
72
+
73
+ | Option | Description | Default |
74
+ | --------------- | -------------------------------------- | ------- |
75
+ | `noPathlikeIds` | Reject IDs containing `/` or `\` | `true` |
76
+ | `parser` | Custom JSON parser (`{ parse(text) }`) | `JSON` |
77
+
78
+ #### Notes
79
+
80
+ * Failed reads (missing file or invalid JSON) return `null`.
81
+ * `destroy()` deletes the entire directory.
82
+ * Intended for development, prototyping, and small datasets.
83
+
84
+ ---
85
+
86
+ ### `MultiEntryMemDb<T extends Identifiable>`
87
+
88
+ An in-memory implementation backed by a `Map`.
89
+
90
+ ```ts
91
+ import { MultiEntryMemDb } from '@jsonkit/db'
92
+
93
+ const db = new MultiEntryMemDb<User>()
94
+ ```
95
+
96
+ #### Behavior
97
+
98
+ * Fast, ephemeral storage.
99
+ * Ideal for tests and short-lived processes.
100
+ * `destroy()` clears all entries.
101
+
102
+ ---
103
+
104
+ ## Single-entry Databases
105
+
106
+ Single-entry databases manage **exactly one value**, often used for configuration or application state.
107
+
108
+ ### Common API (`SingleEntryDb<T>`)
109
+
110
+ * `isInited()`
111
+ * `read()`
112
+ * `write(entry | updater)`
113
+ * `delete()`
114
+
115
+ `write` supports either replacing the entry or partially updating it via an updater function.
116
+
117
+ ---
118
+
119
+ ### `SingleEntryFileDb<T>`
120
+
121
+ Stores a single JSON object in a file.
122
+
123
+ ```ts
124
+ import { SingleEntryFileDb } from '@jsonkit/db'
125
+
126
+ const db = new SingleEntryFileDb<AppConfig>('./config.json')
127
+ ```
128
+
129
+ #### Behavior
130
+
131
+ * Reads and writes a single JSON file.
132
+ * `isInited()` checks file existence.
133
+ * `read()` throws if the file does not exist.
134
+
135
+ #### Constructor
136
+
137
+ ```ts
138
+ new SingleEntryFileDb<T>(filepath, parser?)
139
+ ```
140
+
141
+ * `parser` defaults to `JSON`.
142
+
143
+ ---
144
+
145
+ ### `SingleEntryMemDb<T>`
146
+
147
+ An in-memory single-value database.
148
+
149
+ ```ts
150
+ import { SingleEntryMemDb } from '@jsonkit/db'
151
+
152
+ const db = new SingleEntryMemDb<AppConfig>()
153
+ ```
154
+
155
+ #### Behavior
156
+
157
+ * Optional initial value.
158
+ * `read()` throws if uninitialized.
159
+ * `delete()` resets the entry to `null`.
160
+
161
+ ---
162
+
163
+ ## Example
164
+
165
+ ```ts
166
+ type User = { id: string; name: string }
167
+
168
+ const users = new MultiEntryFileDb<User>('./users')
169
+
170
+ await users.create({ id: 'u1', name: 'Alice' })
171
+
172
+ await users.update('u1', (u) => ({ name: 'Alice Smith' }))
173
+
174
+ const allUsers = await users.getAll()
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Use Cases
180
+
181
+ * Rapid application prototyping
182
+ * CLI tools
183
+ * Small internal services
184
+ * Tests and mocks
185
+ * Configuration and state persistence
186
+
187
+ ---
188
+
189
+ ## Core Types
190
+
191
+ ### `Identifiable`
192
+
193
+ ```ts
194
+ type Identifiable = { id: string }
195
+ ```
196
+
197
+ All multi-entry databases require entries to have a string `id`.
198
+
199
+ ### `Promisable<T>`
200
+
201
+ A value or a promise of a value.
202
+
203
+ ```ts
204
+ type Promisable<T> = T | Promise<T>
205
+ ```
206
+
207
+ ### `PredicateFn<T>`
208
+
209
+ Used for filtering entries.
210
+
211
+ ```ts
212
+ type PredicateFn<T extends Identifiable> = (entry: T) => boolean
213
+ ```
214
+
215
+ ### `PaginationInput`
216
+
217
+ Used for filtering entries.
218
+
219
+ ```ts
220
+ type PredicateFn<T extends Identifiable> = (entry: T) => boolean
221
+ ```
222
+
223
+ ### `DeleteManyOutput`
224
+
225
+ Returned by bulk delete operations.
226
+
227
+ ```ts
228
+ type DeleteManyOutput = {
229
+ deletedIds: string[]
230
+ ignoredIds: string[]
231
+ }
232
+ ```
233
+
234
+ ---
235
+
236
+ ## Non-goals
237
+
238
+ * Concurrency control
239
+ * High-performance querying
240
+ * Large datasets
241
+ * ACID guarantees
242
+
243
+ For these, a dedicated database is recommended.
244
+
245
+ ---
246
+
247
+ ## License
248
+
249
+ MIT
@@ -27,6 +27,106 @@ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
27
27
  var fsSync__namespace = /*#__PURE__*/_interopNamespaceDefault(fsSync);
28
28
  var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
29
29
 
30
+ class MultiEntryDb {
31
+ async getByIdOrThrow(id) {
32
+ const entry = await this.getById(id);
33
+ if (!entry)
34
+ throw new Error(`Entry with id '${id}' does not exist`);
35
+ return entry;
36
+ }
37
+ async getWhere(predicate, pagination) {
38
+ let totalMatched = 0;
39
+ const entries = [];
40
+ if (!pagination) {
41
+ for await (const entry of this.iterEntries()) {
42
+ const isMatch = predicate(entry);
43
+ if (isMatch)
44
+ entries.push(entry);
45
+ }
46
+ return entries;
47
+ }
48
+ const { take, page } = pagination;
49
+ const skip = pagination.skip ?? 0;
50
+ const startIndex = (page - 1) * take + skip;
51
+ const endIndex = startIndex + take;
52
+ for await (const entry of this.iterEntries()) {
53
+ const isMatch = predicate(entry);
54
+ if (isMatch) {
55
+ if (totalMatched >= startIndex && totalMatched < endIndex) {
56
+ entries.push(entry);
57
+ }
58
+ totalMatched++;
59
+ if (totalMatched >= endIndex)
60
+ break;
61
+ }
62
+ }
63
+ return entries;
64
+ }
65
+ getAll() {
66
+ return this.getWhere(() => true);
67
+ }
68
+ async getAllIds() {
69
+ const ids = [];
70
+ for await (const id of this.iterIds()) {
71
+ ids.push(id);
72
+ }
73
+ return ids;
74
+ }
75
+ deleteByIds(ids) {
76
+ return this.deleteWhere((entry) => ids.includes(entry.id));
77
+ }
78
+ async deleteWhere(predicate) {
79
+ const deletedIds = [];
80
+ const ignoredIds = [];
81
+ for await (const entry of this.iterEntries()) {
82
+ if (!predicate(entry))
83
+ continue;
84
+ const didDelete = await this.deleteById(entry.id);
85
+ if (didDelete) {
86
+ deletedIds.push(entry.id);
87
+ }
88
+ else {
89
+ ignoredIds.push(entry.id);
90
+ }
91
+ }
92
+ return { deletedIds, ignoredIds };
93
+ }
94
+ async exists(id) {
95
+ const entry = await this.getById(id);
96
+ return entry !== null;
97
+ }
98
+ countAll() {
99
+ return this.countWhere(() => true);
100
+ }
101
+ async countWhere(predicate, pagination) {
102
+ let totalMatched = 0;
103
+ let count = 0;
104
+ if (!pagination) {
105
+ for await (const entry of this.iterEntries()) {
106
+ const isMatch = predicate(entry);
107
+ if (isMatch)
108
+ count++;
109
+ }
110
+ return count;
111
+ }
112
+ const { take, page } = pagination;
113
+ const skip = pagination.skip ?? 0;
114
+ const startIndex = (page - 1) * take + skip;
115
+ const endIndex = startIndex + take;
116
+ for await (const entry of this.iterEntries()) {
117
+ const isMatch = predicate(entry);
118
+ if (isMatch) {
119
+ if (totalMatched >= startIndex && totalMatched < endIndex)
120
+ count++;
121
+ totalMatched++;
122
+ if (totalMatched >= endIndex)
123
+ break;
124
+ }
125
+ }
126
+ return count;
127
+ }
128
+ }
129
+
30
130
  exports.FileType = void 0;
31
131
  (function (FileType) {
32
132
  FileType["File"] = "file";
@@ -35,7 +135,7 @@ exports.FileType = void 0;
35
135
  })(exports.FileType || (exports.FileType = {}));
36
136
 
37
137
  const DEFUALT_ENCODING = 'utf-8';
38
- class FilesService {
138
+ class Files {
39
139
  async move(oldPath, newPath) {
40
140
  await fs__namespace.rename(oldPath, newPath);
41
141
  }
@@ -243,64 +343,44 @@ class FilesService {
243
343
  }
244
344
  }
245
345
 
246
- class MultiEntryFileDb {
247
- constructor(dirpath, parser = JSON) {
346
+ class MultiEntryFileDb extends MultiEntryDb {
347
+ constructor(dirpath, options) {
348
+ super();
248
349
  this.dirpath = dirpath;
249
- this.parser = parser;
250
- this.files = new FilesService();
350
+ this.files = new Files();
351
+ this.parser = options?.parser ?? JSON;
352
+ this.noPathlikeIds = options?.noPathlikeIds ?? true;
251
353
  }
252
354
  async create(entry) {
253
355
  await this.writeEntry(entry);
254
356
  return entry;
255
357
  }
256
358
  async getById(id) {
257
- return await this.readEntry(id);
258
- }
259
- async getByIdOrThrow(id) {
260
- const entry = await this.readEntry(id);
261
- if (!entry) {
262
- throw new Error('Entry with id ' + id + ' does not exist');
263
- }
264
- return entry;
265
- }
266
- async getWhere(predicate, max) {
267
- const entries = await this.getAll();
268
- return entries.filter(predicate).slice(0, max);
269
- }
270
- async getAll(whereIds) {
271
- const ids = whereIds === undefined ? await this.getAllIds() : whereIds;
272
- const entries = [];
273
- for (const id of ids) {
274
- const entry = await this.readEntry(id);
275
- if (entry)
276
- entries.push(entry);
277
- }
278
- return entries;
279
- }
280
- async getAllIds() {
359
+ if (!this.isIdValid(id))
360
+ throw new Error(`Invalid id: ${id}`);
281
361
  try {
282
- const entries = await this.files.list(this.dirpath);
283
- return entries.filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5)); // Remove .json extension
362
+ const filepath = this.getFilePath(id);
363
+ const text = await this.files.read(filepath);
364
+ const entry = this.parser.parse(text);
365
+ return entry;
284
366
  }
285
- catch {
286
- // Directory might not exist
287
- return [];
367
+ catch (error) {
368
+ console.error('Failed to read entry', error);
369
+ // File doesn't exist or invalid JSON
370
+ return null;
288
371
  }
289
372
  }
290
373
  async update(id, updater) {
291
- const entry = await this.readEntry(id);
292
- if (!entry) {
293
- throw new Error('Entry with id ' + id + ' does not exist');
294
- }
374
+ const entry = await this.getByIdOrThrow(id);
295
375
  const updatedEntryFields = await updater(entry);
296
376
  const updatedEntry = { ...entry, ...updatedEntryFields };
297
377
  await this.writeEntry(updatedEntry);
298
378
  if (updatedEntry.id !== id) {
299
- await this.delete(id);
379
+ await this.deleteById(id);
300
380
  }
301
381
  return updatedEntry;
302
382
  }
303
- async delete(id) {
383
+ async deleteById(id) {
304
384
  try {
305
385
  const filepath = this.getFilePath(id);
306
386
  await this.files.delete(filepath, { force: false });
@@ -314,71 +394,51 @@ class MultiEntryFileDb {
314
394
  async deleteByIds(ids) {
315
395
  return this.deleteWhere((entry) => ids.includes(entry.id));
316
396
  }
317
- async deleteWhere(predicate) {
318
- const deletedIds = [];
319
- const ignoredIds = [];
320
- for await (const entry of this.iterEntries()) {
321
- if (!predicate(entry))
322
- continue;
323
- const didDelete = await this.delete(entry.id);
324
- if (didDelete) {
325
- deletedIds.push(entry.id);
326
- }
327
- else {
328
- ignoredIds.push(entry.id);
329
- }
330
- }
331
- return { deletedIds, ignoredIds };
332
- }
333
397
  async destroy() {
334
398
  await this.files.delete(this.dirpath);
335
399
  }
336
- async exists(id) {
337
- const entry = await this.readEntry(id);
338
- return entry !== null;
339
- }
340
- async countAll() {
341
- const ids = await this.getAllIds();
342
- return ids.length;
343
- }
344
- async countWhere(predicate) {
345
- return (await this.getWhere(predicate)).length;
346
- }
347
400
  getFilePath(id) {
348
401
  return path__namespace.join(this.dirpath, `${id}.json`);
349
402
  }
350
- async readEntry(id) {
351
- try {
352
- const filepath = this.getFilePath(id);
353
- const text = await this.files.read(filepath);
354
- const entry = this.parser.parse(text);
355
- return entry;
356
- }
357
- catch (error) {
358
- console.error('Failed to read entry', error);
359
- // File doesn't exist or invalid JSON
360
- return null;
361
- }
362
- }
363
403
  async writeEntry(entry) {
404
+ if (!this.isIdValid(entry.id))
405
+ throw new Error(`Invalid id: ${entry.id}`);
364
406
  const filepath = this.getFilePath(entry.id);
365
407
  await this.files.write(filepath, JSON.stringify(entry, null, 2));
366
408
  }
409
+ isIdValid(id) {
410
+ if (typeof id !== 'string')
411
+ return false;
412
+ if (!this.noPathlikeIds)
413
+ return true;
414
+ if (id.includes('/') || id.includes('\\'))
415
+ return false;
416
+ return true;
417
+ }
367
418
  async *iterEntries() {
368
- const ids = await this.getAllIds();
369
- for (const id of ids) {
370
- const entry = await this.readEntry(id);
419
+ for await (const id of this.iterIds()) {
420
+ const entry = await this.getById(id);
371
421
  if (entry)
372
422
  yield entry;
373
423
  }
374
424
  }
425
+ async *iterIds() {
426
+ const filenames = await this.files.list(this.dirpath);
427
+ for (const filename of filenames) {
428
+ if (!filename.endsWith('.json'))
429
+ continue;
430
+ const id = filename.replace(/\.json$/, '');
431
+ if (this.isIdValid(id))
432
+ yield id;
433
+ }
434
+ }
375
435
  }
376
436
 
377
437
  class SingleEntryFileDb {
378
438
  constructor(filepath, parser = JSON) {
379
439
  this.filepath = filepath;
380
440
  this.parser = parser;
381
- this.files = new FilesService();
441
+ this.files = new Files();
382
442
  }
383
443
  path() {
384
444
  return this.filepath;
@@ -411,6 +471,81 @@ class SingleEntryFileDb {
411
471
  }
412
472
  }
413
473
 
474
+ class MultiEntryMemDb extends MultiEntryDb {
475
+ constructor() {
476
+ super(...arguments);
477
+ this.entries = new Map();
478
+ }
479
+ async create(entry) {
480
+ this.entries.set(entry.id, entry);
481
+ return entry;
482
+ }
483
+ async getById(id) {
484
+ return this.entries.get(id) ?? null;
485
+ }
486
+ async update(id, updater) {
487
+ const entry = await this.getByIdOrThrow(id);
488
+ const updatedEntryFields = await updater(entry);
489
+ const updatedEntry = { ...entry, ...updatedEntryFields };
490
+ this.entries.set(updatedEntry.id, updatedEntry);
491
+ if (updatedEntry.id !== id)
492
+ this.entries.delete(id);
493
+ return updatedEntry;
494
+ }
495
+ async deleteById(id) {
496
+ return this.entries.delete(id);
497
+ }
498
+ async destroy() {
499
+ this.entries.clear();
500
+ }
501
+ async *iterEntries() {
502
+ for (const entry of this.entries.values()) {
503
+ yield entry;
504
+ }
505
+ }
506
+ async *iterIds() {
507
+ for (const id of this.entries.keys()) {
508
+ yield id;
509
+ }
510
+ }
511
+ }
512
+
513
+ class SingleEntryMemDb {
514
+ constructor(initialEntry = null) {
515
+ this.entry = null;
516
+ this.entry = initialEntry;
517
+ }
518
+ isInited() {
519
+ return this.entry !== null;
520
+ }
521
+ read() {
522
+ if (this.entry === null)
523
+ throw new Error('Entry not initialized');
524
+ return this.entry;
525
+ }
526
+ write(updaterOrEntry) {
527
+ let entry;
528
+ if (typeof updaterOrEntry === 'function') {
529
+ const updater = updaterOrEntry;
530
+ if (this.entry === null) {
531
+ throw new Error('Cannot update uninitialized entry. Use write(entry) to initialize first.');
532
+ }
533
+ const updatedFields = updater(this.entry);
534
+ entry = { ...this.entry, ...updatedFields };
535
+ }
536
+ else {
537
+ entry = updaterOrEntry;
538
+ }
539
+ this.entry = entry;
540
+ return entry;
541
+ }
542
+ delete() {
543
+ this.entry = null;
544
+ }
545
+ }
546
+
414
547
  exports.MultiEntryFileDb = MultiEntryFileDb;
548
+ exports.MultiEntryMemDb = MultiEntryMemDb;
415
549
  exports.SingleEntryFileDb = SingleEntryFileDb;
550
+ exports.SingleEntryMemDb = SingleEntryMemDb;
416
551
  //# sourceMappingURL=index.cjs.map