sorla-db 1.1.0 → 1.3.0

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/index.d.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Type declarations for sorla-db
2
+
3
+ export type Document = Record<string, unknown> & { _id?: unknown };
4
+
5
+ export type ComparisonOperators = {
6
+ $eq?: unknown;
7
+ $ne?: unknown;
8
+ $gt?: unknown;
9
+ $gte?: unknown;
10
+ $lt?: unknown;
11
+ $lte?: unknown;
12
+ $in?: unknown[];
13
+ $nin?: unknown[];
14
+ $exists?: boolean;
15
+ $regex?: string | RegExp;
16
+ /** Flags for $regex: i, m, s */
17
+ $options?: string;
18
+ $not?: ComparisonOperators | RegExp;
19
+ };
20
+
21
+ /** A query: field conditions (value or operators, "a.b" paths allowed) and nested $and / $or / $nor. */
22
+ export type Query = {
23
+ $and?: Query[];
24
+ $or?: Query[];
25
+ $nor?: Query[];
26
+ [field: string]: unknown;
27
+ };
28
+
29
+ /** Exclusion ({ email: 0 }) or inclusion ({ name: 1 }); only _id can mix both. */
30
+ export type Projection = Record<string, 0 | 1>;
31
+
32
+ export type UpdateFilter = {
33
+ $set?: Record<string, unknown>;
34
+ $unset?: Record<string, unknown>;
35
+ $inc?: Record<string, number>;
36
+ $push?: Record<string, unknown | { $each: unknown[] }>;
37
+ $pull?: Record<string, unknown>;
38
+ };
39
+
40
+ export interface UpdateOptions {
41
+ /** Insert a document built from the filter when nothing matches. */
42
+ upsert?: boolean;
43
+ }
44
+
45
+ export interface UpdateResult {
46
+ matchedCount: number;
47
+ modifiedCount: number;
48
+ upsertedId: unknown | null;
49
+ }
50
+
51
+ export interface DeleteResult {
52
+ deletedCount: number;
53
+ }
54
+
55
+ export type ChangeType = 'insert' | 'update' | 'replace' | 'delete';
56
+
57
+ export interface ChangeEvent {
58
+ type: ChangeType;
59
+ ids: unknown[];
60
+ /** Copies of the documents (after the change; the removed ones for delete). */
61
+ documents: Document[];
62
+ /** Copies of the documents before an update or replace. */
63
+ previous?: Document[];
64
+ }
65
+ export type SortSpec = Record<string, 1 | -1>;
66
+
67
+ export interface SortOptions {
68
+ /** Intl.Collator options plus the locale, e.g. { locale: 'pt-BR', sensitivity: 'base', numeric: true } */
69
+ collation?: Intl.CollatorOptions & { locale?: string };
70
+ }
71
+
72
+ export interface StoredDocument {
73
+ readonly _data: Document;
74
+ readonly [field: string]: unknown;
75
+ }
76
+
77
+ export declare class Cursor {
78
+ readonly length: number;
79
+ currIndex: number;
80
+ hasNext(): boolean;
81
+ next(): StoredDocument;
82
+ reset(): void;
83
+ /** Data of the first document, null when empty. */
84
+ first(): Document | null;
85
+ /** Data of the last document, null when empty. */
86
+ last(): Document | null;
87
+ /** Plain copies of the documents. */
88
+ toArray(): Document[];
89
+ sort(spec: SortSpec, options?: SortOptions): this;
90
+ skip(count: number): this;
91
+ /** 0 means no limit. */
92
+ limit(count: number): this;
93
+ projection(projection: Projection): this;
94
+ push(element: object): void;
95
+ }
96
+
97
+ export declare class Collection {
98
+ readonly first: Document;
99
+ readonly last: Document;
100
+ readonly all: StoredDocument[];
101
+ /** All documents, or the ones matching the query. */
102
+ count(query?: Query): number;
103
+ /** Inserts a copy of the document and returns its _id. */
104
+ insertOne(document: Document, callback?: (id: unknown) => void): unknown;
105
+ /** Validates every document first, then inserts them and returns their ids. */
106
+ insertMany(documents: Document[], callback?: (ids: unknown[]) => void): unknown[];
107
+ find(query: Query, projection?: Projection | null): Cursor;
108
+ /** Data of the first matching document, null when nothing matches. */
109
+ findOne(query: Query, projection?: Projection | null): Document | null;
110
+ updateOne(filter: Query, update: UpdateFilter, options?: UpdateOptions): UpdateResult;
111
+ updateMany(filter: Query, update: UpdateFilter, options?: UpdateOptions): UpdateResult;
112
+ /** Replaces the document, keeping its _id. */
113
+ replaceOne(filter: Query, replacement: Document, options?: UpdateOptions): UpdateResult;
114
+ deleteOne(filter: Query): DeleteResult;
115
+ /** deleteMany({}) empties the collection. */
116
+ deleteMany(filter: Query): DeleteResult;
117
+ /** Returns a function that unsubscribes. "change" receives every type. */
118
+ on(event: ChangeType | 'change', listener: (event: ChangeEvent) => void): () => void;
119
+ once(event: ChangeType | 'change', listener: (event: ChangeEvent) => void): () => void;
120
+ off(event: ChangeType | 'change', listener?: (event: ChangeEvent) => void): void;
121
+ }
122
+
123
+ export interface DatabaseStats {
124
+ name: string;
125
+ documents: number;
126
+ collections: Array<{ name: string; documents: number }>;
127
+ }
128
+
129
+ export declare class Database {
130
+ readonly name: string;
131
+ createCollection(name: string, validationSchema?: unknown): void;
132
+ countCollections(): number;
133
+ listCollections(): string[];
134
+ /** Any collection by name, also when the name shadows a method. */
135
+ getCollection(name: string): Collection | undefined;
136
+ dropCollection(name: string): boolean;
137
+ getStats(): DatabaseStats;
138
+ [collection: string]: unknown;
139
+ }
140
+
141
+ declare class sorla {
142
+ constructor();
143
+ /** The selected database. Collections are properties of it: sorla.db.people */
144
+ readonly db: Database & Record<string, Collection>;
145
+ dbExists(name: string): boolean;
146
+ /** Position of the new database, or false when the name exists. */
147
+ createDb(name: string): number | false;
148
+ /** Selects a database, creating it when needed. */
149
+ useDb(name: string): void;
150
+ countDb(): number;
151
+ listDatabases(): string[];
152
+ /** When the dropped database was selected, db becomes null. */
153
+ dropDb(name: string): boolean;
154
+ }
155
+
156
+ export { sorla };
157
+ export default sorla;
package/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ // ES module entry: import sorla from 'sorla-db'
2
+ import sorla from './src/sorla.js';
3
+
4
+ export { sorla };
5
+ export default sorla;
package/package.json CHANGED
@@ -1,11 +1,32 @@
1
1
  {
2
2
  "name": "sorla-db",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A simple and temporary database focused to use in browser environment that will bring capabilities for other tools that will be launched as soon as possible.",
5
- "main": "dist/sorla.min.js",
5
+ "main": "src/sorla.js",
6
+ "module": "index.mjs",
7
+ "types": "index.d.ts",
8
+ "unpkg": "dist/sorla.min.js",
9
+ "jsdelivr": "dist/sorla.min.js",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./index.d.ts",
13
+ "import": "./index.mjs",
14
+ "require": "./src/sorla.js"
15
+ },
16
+ "./dist/sorla.min.js": "./dist/sorla.min.js",
17
+ "./src/*": "./src/*",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "!src/helper.js",
23
+ "dist",
24
+ "index.mjs",
25
+ "index.d.ts"
26
+ ],
6
27
  "scripts": {
28
+ "build": "node index.js",
7
29
  "mini": "node index.js",
8
- "build": "webpack",
9
30
  "test": "jest"
10
31
  },
11
32
  "repository": {
@@ -38,5 +59,8 @@
38
59
  "jsdom-global": "^3.0.2",
39
60
  "mocha": "^10.2.0",
40
61
  "terser": "^5.27.0"
62
+ },
63
+ "engines": {
64
+ "node": ">=18"
41
65
  }
42
66
  }
@@ -67,6 +67,54 @@ class database
67
67
  {
68
68
  return this._collectionCount;
69
69
  }
70
+
71
+ /**
72
+ * Names of the collections, in creation order
73
+ * @return array
74
+ */
75
+ listCollections()
76
+ {
77
+ return Object.keys(this._collections);
78
+ }
79
+
80
+ /**
81
+ * A collection by name, also when the name is not a valid property (or shadows a method)
82
+ * @param string name
83
+ * @return collection|undefined
84
+ */
85
+ getCollection(name)
86
+ {
87
+ return this._collections.hasOwnProperty(name) ? this._collections[name] : undefined;
88
+ }
89
+
90
+ /**
91
+ * Removes a collection and its documents
92
+ * @param string name
93
+ * @return boolean false when it did not exist
94
+ */
95
+ dropCollection(name)
96
+ {
97
+ if(!this._collections.hasOwnProperty(name))
98
+ return false;
99
+
100
+ delete this._collections[name];
101
+ this._collectionCount--;
102
+ return true;
103
+ }
104
+
105
+ /**
106
+ * Number of documents per collection
107
+ * @return object {name, documents, collections: [{name, documents}]}
108
+ */
109
+ getStats()
110
+ {
111
+ const collections = this.listCollections().map(name => ({ name, documents: this._collections[name].count() }));
112
+ return {
113
+ name: this._name,
114
+ documents: collections.reduce((total, item) => total + item.documents, 0),
115
+ collections
116
+ };
117
+ }
70
118
  }
71
119
 
72
120
  module.exports = database;
@@ -3,7 +3,6 @@
3
3
  * @description Cursor class is used to represent the cursor in the document, adding some extra capabilities to object.
4
4
  */
5
5
 
6
- const documents = require('../documents/document.js');
7
6
 
8
7
  class cursor {
9
8
  /**
@@ -45,7 +44,8 @@ class cursor {
45
44
  * @return array
46
45
  */
47
46
  toArray() {
48
- return structuredClone(this._data);
47
+ // the documents are proxies, which structuredClone cannot copy: clone their data
48
+ return this._data.map(doc => structuredClone(doc._data));
49
49
  }
50
50
 
51
51
  /**
@@ -63,7 +63,7 @@ class cursor {
63
63
  * @return mixed
64
64
  */
65
65
  first() {
66
- return this._data[0]._data;
66
+ return this._data.length ? this._data[0]._data : null;
67
67
  }
68
68
 
69
69
  /**
@@ -72,30 +72,45 @@ class cursor {
72
72
  * @return mixed
73
73
  */
74
74
  last() {
75
- return this._data[this._data.length - 1];
75
+ return this._data.length ? this._data[this._data.length - 1]._data : null;
76
76
  }
77
77
 
78
78
  /**
79
79
  * @method sort
80
- * @description Sort the cursor using collection
81
- * @param json sortQuery
82
- * @return void
80
+ * @description Sort the documents of the cursor, like MongoDB: {age: -1, name: 1}
81
+ * Values of different types follow MongoDB's order: missing and null, numbers, strings,
82
+ * objects, arrays, booleans, dates. Strings compare by code point unless a collation is given.
83
+ * @param json sortQuery field => 1 (ascending) or -1 (descending), "a.b" paths allowed
84
+ * @param json options {collation: {locale, sensitivity, numeric...}} (Intl.Collator options)
85
+ * @return cursor
83
86
  */
84
- sort(sortQuery) {
85
- const queryDoc = new documents(sortQuery);
87
+ sort(sortQuery, options = {}) {
88
+ if(sortQuery === null || typeof sortQuery !== 'object' || Array.isArray(sortQuery))
89
+ throw new Error('The sort query must be an object like {field: 1}');
90
+
91
+ const keys = Object.keys(sortQuery);
92
+ for(const key of keys)
93
+ {
94
+ if(sortQuery[key] !== 1 && sortQuery[key] !== -1)
95
+ throw new Error(`Sort direction of "${key}" must be 1 or -1`);
96
+ }
97
+
98
+ const collation = options.collation
99
+ ? new Intl.Collator(options.collation.locale, options.collation)
100
+ : null;
101
+
86
102
  this._data.sort((a, b) => {
87
- for(const key of Object.keys(sortQuery))
103
+ for(const key of keys)
88
104
  {
89
- const ascending = queryDoc[key] === 1;
90
- if(a[key] > b[key])
91
- return ascending ? 1 : -1;
92
-
93
- if(a[key] < b[key])
94
- return ascending ? -1 : 1;
105
+ const result = compareValues(a[key], b[key], collation);
106
+ if(result !== 0)
107
+ return result * sortQuery[key];
95
108
  }
96
109
 
97
110
  return 0;
98
111
  });
112
+
113
+ return this;
99
114
  }
100
115
 
101
116
  /**
@@ -108,23 +123,75 @@ class cursor {
108
123
  }
109
124
 
110
125
  /**
111
- * Projection of the cursor
126
+ * Projection of the cursor, like MongoDB:
127
+ * {email: 0} every field except email
128
+ * {name: 1, age: 1} only name and age, plus _id unless {_id: 0}
129
+ * Inclusion and exclusion cannot be mixed, except for _id. Top level fields only.
112
130
  * @param json projection
113
131
  * @return cursor
114
132
  */
115
133
  projection(projection) {
116
134
  if(projection === null || typeof projection !== 'object')
117
- return;
135
+ return this;
136
+
137
+ const fields = Object.keys(projection);
138
+ for(const field of fields)
139
+ {
140
+ if(projection[field] !== 0 && projection[field] !== 1)
141
+ throw new Error(`Projection of "${field}" must be 0 or 1`);
142
+
143
+ if(field.includes('.'))
144
+ throw new Error(`Projection supports top level fields only ("${field}")`);
145
+ }
146
+
147
+ const included = fields.filter(field => projection[field] === 1 && field !== '_id');
148
+ const excluded = fields.filter(field => projection[field] === 0 && field !== '_id');
149
+ if(included.length && excluded.length)
150
+ throw new Error('Projection cannot mix inclusion and exclusion (except for _id)');
151
+
152
+ const keep = new Set(included);
153
+ if(projection._id !== 0) keep.add('_id');
118
154
 
119
- const self = this;
120
155
  this._data.forEach(doc => {
121
- for(const attribute of Object.keys(projection))
122
- {
123
- if(projection[attribute] === 0) {
124
- doc.deleteProp(attribute, self);
125
- }
126
- }
156
+ const remove = included.length
157
+ ? Object.keys(doc._data).filter(field => !keep.has(field))
158
+ : [...excluded, ...(projection._id === 0 ? ['_id'] : [])];
159
+
160
+ for(const field of remove)
161
+ doc.deleteProp(field);
127
162
  });
163
+
164
+ return this;
165
+ }
166
+
167
+ /**
168
+ * Skips the first documents (apply after sort, like MongoDB applies it)
169
+ * @param int count
170
+ * @return cursor
171
+ */
172
+ skip(count) {
173
+ if(!Number.isInteger(count) || count < 0)
174
+ throw new Error('skip() needs a non negative integer');
175
+
176
+ this._data = this._data.slice(count);
177
+ this.currIndex = 0;
178
+ return this;
179
+ }
180
+
181
+ /**
182
+ * Keeps at most `count` documents; 0 means no limit, like MongoDB
183
+ * @param int count
184
+ * @return cursor
185
+ */
186
+ limit(count) {
187
+ if(!Number.isInteger(count) || count < 0)
188
+ throw new Error('limit() needs a non negative integer');
189
+
190
+ if(count > 0)
191
+ this._data = this._data.slice(0, count);
192
+
193
+ this.currIndex = 0;
194
+ return this;
128
195
  }
129
196
 
130
197
  /**
@@ -141,4 +208,53 @@ class cursor {
141
208
  }
142
209
  }
143
210
 
211
+ /**
212
+ * Rank of a value in MongoDB's comparison order
213
+ * @param mixed value
214
+ * @return number
215
+ */
216
+ function typeRank(value) {
217
+ if(value === undefined || value === null) return 0;
218
+ if(typeof value === 'number') return 1;
219
+ if(typeof value === 'string') return 2;
220
+ // toString instead of instanceof: Dates from another realm (iframes, workers, vm) still count
221
+ if(Object.prototype.toString.call(value) === '[object Date]') return 6;
222
+ if(Array.isArray(value)) return 4;
223
+ if(typeof value === 'object') return 3;
224
+ if(typeof value === 'boolean') return 5;
225
+ return 7;
226
+ }
227
+
228
+ /**
229
+ * Compares two field values for sorting
230
+ * @param mixed a
231
+ * @param mixed b
232
+ * @param Intl.Collator|null collation
233
+ * @return number -1, 0 or 1
234
+ */
235
+ function compareValues(a, b, collation) {
236
+ const rankA = typeRank(a);
237
+ const rankB = typeRank(b);
238
+ if(rankA !== rankB) return rankA < rankB ? -1 : 1;
239
+
240
+ switch(rankA) {
241
+ case 0:
242
+ return 0;
243
+ case 2:
244
+ if(collation) return Math.sign(collation.compare(a, b));
245
+ break;
246
+ case 3:
247
+ case 4:
248
+ a = JSON.stringify(a);
249
+ b = JSON.stringify(b);
250
+ break;
251
+ case 6:
252
+ a = a.getTime();
253
+ b = b.getTime();
254
+ break;
255
+ }
256
+
257
+ return a < b ? -1 : a > b ? 1 : 0;
258
+ }
259
+
144
260
  module.exports = cursor;
@@ -1,7 +1,10 @@
1
1
  const utils = require('../utils');
2
- const cursor = require('./cursor');
3
- const manipulation = require('../query_language/manipulation');
4
2
 
3
+ /**
4
+ * A stored document. Fields read like a plain object, including "a.b" paths. Stored documents are
5
+ * read-only from the outside; find() returns copies (clone()) whose fields projection can remove.
6
+ * This module requires no other document or query module, which keeps the require graph acyclic.
7
+ */
5
8
  class documents
6
9
  {
7
10
  constructor(object)
@@ -9,24 +12,23 @@ class documents
9
12
  this._data = object;
10
13
  this._utils = new utils();
11
14
  this._cloned = false;
12
- this._deleteReceiver = null;
13
15
 
14
16
  return new Proxy(this, {
15
- deleteProperty: (target, prop, receiver) => {
16
- if(target._cloned && target._deleteReceiver instanceof cursor)
17
+ deleteProperty: (target, prop) => {
18
+ if(target._cloned && prop in target._data)
17
19
  {
18
- if(prop in target._data)
19
- {
20
- console.log('2', prop);
21
- delete target._data[prop];
22
- return true;
23
- }
24
-
25
- return false;
20
+ delete target._data[prop];
21
+ return true;
26
22
  }
23
+
24
+ return false;
27
25
  },
28
26
 
29
27
  get: (target, prop) => {
28
+ // symbols (Symbol.toPrimitive, Symbol.iterator...) belong to the wrapper, not to the data
29
+ if(typeof prop !== 'string')
30
+ return target[prop];
31
+
30
32
  if(prop.indexOf('.') > -1)
31
33
  return target._utils.getDotData(target._data, prop);
32
34
 
@@ -36,19 +38,15 @@ class documents
36
38
  return target[prop];
37
39
  },
38
40
 
39
- set: (target, prop, value, receiver) => {
40
- if(!(prop in target._data) && !target.hasOwnProperty(prop))
41
- return false;
42
-
43
- if(target.hasOwnProperty(prop) && ['_data', '_utils'].indexOf(prop) === -1){
44
- target[prop] = value;
41
+ set: (target, prop, value) => {
42
+ if(prop === '_cloned')
43
+ {
44
+ target._cloned = value;
45
45
  return true;
46
46
  }
47
47
 
48
- if(receiver instanceof manipulation || target._cloned && receiver instanceof cursor) {
49
- target._data[prop] = value;
50
- return true;
51
- }
48
+ if(typeof prop === 'string' && prop in target._data)
49
+ throw new Error(`Document fields are read-only ("${prop}"), change documents through the collection`);
52
50
 
53
51
  return false;
54
52
  }
@@ -69,18 +67,16 @@ class documents
69
67
 
70
68
  /**
71
69
  * @method delete prop
72
- * @description Delete a property from the document
70
+ * @description Delete a property from a copy returned by find() (used by projection)
73
71
  * @param string prop
74
- * @param cursor receiver
75
72
  * @return boolean
76
73
  */
77
- deleteProp(prop, receiver)
74
+ deleteProp(prop)
78
75
  {
79
- this._deleteReceiver = receiver;
80
- if(this._cloned)
81
- return Reflect.deleteProperty(this, prop);
76
+ if(!this._cloned)
77
+ return false;
82
78
 
83
- return false;
79
+ return Reflect.deleteProperty(this, prop);
84
80
  }
85
81
  }
86
82
 
package/src/events.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Minimal event emitter for collections: insert, update, replace, delete and change (all of them).
3
+ * A failing listener never breaks the write that triggered it; its error is rethrown asynchronously.
4
+ */
5
+ class emitter
6
+ {
7
+ constructor()
8
+ {
9
+ this._listeners = new Map();
10
+ }
11
+
12
+ /**
13
+ * @param string event
14
+ * @param function listener receives the event payload
15
+ * @return function unsubscribe
16
+ */
17
+ on(event, listener)
18
+ {
19
+ if(typeof listener !== 'function')
20
+ throw new Error('The listener must be a function');
21
+
22
+ if(!this._listeners.has(event))
23
+ this._listeners.set(event, new Set());
24
+
25
+ this._listeners.get(event).add(listener);
26
+ return () => this.off(event, listener);
27
+ }
28
+
29
+ once(event, listener)
30
+ {
31
+ const off = this.on(event, payload => {
32
+ off();
33
+ listener(payload);
34
+ });
35
+ return off;
36
+ }
37
+
38
+ off(event, listener)
39
+ {
40
+ if(typeof listener === 'undefined')
41
+ this._listeners.delete(event);
42
+ else
43
+ this._listeners.get(event)?.delete(listener);
44
+ }
45
+
46
+ /**
47
+ * Emits the event and a generic "change" event with the same payload plus its type
48
+ * @param string type
49
+ * @param object payload
50
+ * @return void
51
+ */
52
+ _emitChange(type, payload)
53
+ {
54
+ const event = { type, ...payload };
55
+ for(const name of [type, 'change'])
56
+ {
57
+ for(const listener of [...(this._listeners.get(name) ?? [])])
58
+ {
59
+ try {
60
+ listener(event);
61
+ } catch(error) {
62
+ setTimeout(() => { throw error; });
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ module.exports = emitter;