sorla-db 1.2.0 → 1.4.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/README.md CHANGED
@@ -100,6 +100,128 @@ using the `Intl.Collator` options:
100
100
  cursor.sort({ name: 1 }, { collation: { locale: 'pt-BR', sensitivity: 'base', numeric: true } });
101
101
  ```
102
102
 
103
+ ## Paging and projection
104
+
105
+ ```
106
+ collection.find({}).sort({ age: 1 }).skip(20).limit(10).toArray();
107
+ collection.find({}, { name: 1, age: 1 }); // only name, age and _id
108
+ collection.find({}, { name: 1, _id: 0 }); // only name
109
+ collection.find({}, { password: 0 }); // everything except password
110
+ ```
111
+
112
+ `limit(0)` means no limit. Inclusion and exclusion cannot be mixed (except `_id`), and projection
113
+ works on top level fields.
114
+
115
+ ## Updating documents
116
+
117
+ ```
118
+ collection.updateOne({ _id: id }, { $set: { 'address.city': 'Recife' }, $inc: { visits: 1 } });
119
+ collection.updateMany({ active: false }, { $unset: { token: '' } });
120
+ collection.updateOne({ email: 'a@b.c' }, { $set: { name: 'Ana' } }, { upsert: true });
121
+ ```
122
+
123
+ | Operator | Effect |
124
+ |---|---|
125
+ | `$set` | sets fields, creating missing objects of `"a.b"` paths |
126
+ | `$unset` | removes fields |
127
+ | `$inc` | adds a number (missing fields start at 0) |
128
+ | `$push` | appends to an array, `{ $push: { tags: { $each: ['a', 'b'] } } }` appends several |
129
+ | `$pull` | removes array items equal to a value, matching a condition (`{ $in: [...] }`) or a sub document (`{ ok: false }`) |
130
+
131
+ They return `{ matchedCount, modifiedCount, upsertedId }`. Updates are applied to a copy first: if an
132
+ operator fails (for example `$inc` on a text field) no document changes. `_id` cannot change. With
133
+ `{ upsert: true }`, a document is inserted from the equality conditions of the filter when nothing matches.
134
+
135
+ `replaceOne(filter, document)` replaces the whole document and keeps its `_id`.
136
+
137
+ ## Deleting and counting
138
+
139
+ ```
140
+ collection.deleteOne({ _id: id }); // { deletedCount: 1 }
141
+ collection.deleteMany({ age: { $lt: 18 } });
142
+ collection.deleteMany({}); // empties the collection
143
+ collection.count(); // every document
144
+ collection.count({ active: true }); // matching documents, without copying them
145
+ ```
146
+
147
+ ## Change events
148
+
149
+ ```
150
+ const off = collection.on('change', event => {
151
+ // event.type: 'insert' | 'update' | 'replace' | 'delete'
152
+ // event.ids, event.documents (copies), event.previous (before update / replace)
153
+ });
154
+ collection.on('delete', event => console.log(event.ids));
155
+ off();
156
+ ```
157
+
158
+ `insertMany` and `updateMany` emit one event for the whole operation, and nothing is emitted when nothing
159
+ changed. A listener that throws does not undo or interrupt the write.
160
+
161
+ ## Databases and collections
162
+
163
+ ```
164
+ sorla.listDatabases(); // ['tags', ...]
165
+ sorla.dropDb('tags');
166
+ sorla.db.listCollections(); // ['updatedTags', ...]
167
+ sorla.db.getCollection('stats'); // any collection, also when its name shadows a method
168
+ sorla.db.dropCollection('updatedTags');
169
+ sorla.db.getStats(); // { name, documents, collections: [{ name, documents }] }
170
+ ```
171
+
172
+ ## Schema validation
173
+
174
+ Give a collection a schema (a subset of JSON Schema) and every insert, update, replace and upsert must
175
+ satisfy it:
176
+
177
+ ```
178
+ sorla.db.createCollection('users', {
179
+ required: ['name', 'email'],
180
+ additionalProperties: false,
181
+ properties: {
182
+ name: { type: 'string', minLength: 2, maxLength: 80 },
183
+ email: { type: 'string', format: 'email' },
184
+ age: { type: ['integer', 'null'], minimum: 0 },
185
+ role: { enum: ['admin', 'user'], default: 'user' },
186
+ tags: { type: 'array', items: { type: 'string' }, uniqueItems: true },
187
+ createdAt: { type: 'date' },
188
+ address: {
189
+ type: 'object',
190
+ required: ['zip'],
191
+ properties: { zip: { type: 'string', pattern: '^\\d{5}-\\d{3}$' } }
192
+ }
193
+ }
194
+ });
195
+
196
+ try {
197
+ sorla.db.users.insertOne({ name: 'A', email: 'nope' });
198
+ } catch(error) {
199
+ // error instanceof sorla.ValidationError
200
+ error.errors; // [{ path: 'name', rule: 'minLength', message: 'name must have at least 2 characters' },
201
+ // { path: 'email', rule: 'format', message: 'email must be a valid email' }]
202
+ }
203
+
204
+ sorla.db.users.validate({ name: 'Ana' }); // { valid: false, errors: [...] } without storing (forms)
205
+ sorla.db.users.setSchema(otherSchema); // existing documents are not revalidated; null removes it
206
+ ```
207
+
208
+ | Keyword | Notes |
209
+ |---|---|
210
+ | `type` | `string`, `number`, `integer`, `boolean`, `object`, `array`, `null`, several (`['string', 'null']`), and `date` for Date objects |
211
+ | `required`, `properties`, `additionalProperties` | `additionalProperties` can be `false` or a schema; `_id` is always allowed |
212
+ | `enum`, `const` | |
213
+ | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` | numbers |
214
+ | `minLength`, `maxLength`, `pattern`, `format` | strings; lengths count characters; formats: `email`, `date`, `date-time`, `uri` |
215
+ | `items`, `minItems`, `maxItems`, `uniqueItems` | arrays |
216
+ | `default` | filled on insert and upsert when the field is missing, also in nested objects |
217
+ | `title`, `description`, `examples`, `$schema`, `$id` | annotations |
218
+
219
+ Every problem is reported at once, with its field path (`address.zip`, `tags[1]`). `insertMany` and
220
+ `updateMany` stay atomic: one invalid document stores nothing. Unknown keywords, types and formats throw
221
+ when the schema is set, so a typo like `tpye` is not silently ignored.
222
+
223
+ A function also works as a schema: `doc => doc.total >= 0 || 'total must not be negative'`.
224
+
103
225
  ## Find Operators
104
226
 
105
227
  ### $and
@@ -196,6 +318,22 @@ A document without the queried field does not match `$eq`, `$gt`, `$in`... but d
196
318
 
197
319
  Unknown operators and logical operators that do not receive an array throw an error.
198
320
 
321
+ ### $regex
322
+ Matches strings with a regular expression. `$options` takes the flags `i` (case insensitive), `m` and `s`.
323
+ A RegExp can be used directly as the value. Array fields match when one of their strings does.
324
+
325
+ ```
326
+ const results = window.sorla.db.updatedTags.find({ name: { $regex: '^an', $options: 'i' } });
327
+ const same = window.sorla.db.updatedTags.find({ name: /^an/i });
328
+ ```
329
+
330
+ ### $exists
331
+ `{ field: { $exists: true } }` matches documents that have the field, even when it is `null`;
332
+ `false` matches documents without it.
333
+
334
+ ### Arrays
335
+ Equality is strict: `{ tags: 'dev' }` does not match `tags: ['dev']`. Use `{ tags: { $in: ['dev'] } }`.
336
+
199
337
  ## Comparison operators
200
338
 
201
339
  ### $eq
package/dist/sorla.min.js CHANGED
@@ -1 +1 @@
1
- !function r(t,e,n){function o(i,a){if(!e[i]){if(!t[i]){var c="function"==typeof require&&require;if(!a&&c)return c(i,!0);if(s)return s(i,!0);var u=new Error("Cannot find module '"+i+"'");throw u.code="MODULE_NOT_FOUND",u}var l=e[i]={exports:{}};t[i][0].call(l.exports,(function(e){return o(t[i][1][e]||e)}),l,l.exports,r,t,e,n)}return e[i].exports}for(var s="function"==typeof require&&require,i=0;i<n.length;i++)o(n[i]);return o}({1:[function(t,e,n){const s=t("../query_language/query.js");e.exports=class extends s{constructor(t){super(t),this._documents=[],this._indexes=[]}get first(){return structuredClone(this._documents[0]._data)}get last(){return structuredClone(this._documents[this._documents.length-1]._data)}get all(){return this._documents}}},{"../query_language/query.js":7}],2:[function(t,e,n){const s=t("../collections/collection.js");e.exports=class{constructor(t){return this._collections={},this._name=t,this._collectionCount=0,new Proxy(this,{get:(t,e,n)=>e in t._collections?t._collections[e]:t[e]})}get name(){return this._name}createCollection(t,e){if(this._collections.hasOwnProperty(t)||void 0!==this[t])throw new Error("Collection already exists");this._collections[t]=new s(e),this._collectionCount++}countCollections(){return this._collectionCount}}},{"../collections/collection.js":1}],3:[function(t,e,n){function typeRank(t){return null==t?0:"number"==typeof t?1:"string"==typeof t?2:"[object Date]"===Object.prototype.toString.call(t)?6:Array.isArray(t)?4:"object"==typeof t?3:"boolean"==typeof t?5:7}function compareValues(t,e,n){const s=typeRank(t),i=typeRank(e);if(s!==i)return s<i?-1:1;switch(s){case 0:return 0;case 2:if(n)return Math.sign(n.compare(t,e));break;case 3:case 4:t=JSON.stringify(t),e=JSON.stringify(e);break;case 6:t=t.getTime(),e=e.getTime()}return t<e?-1:t>e?1:0}e.exports=class{constructor(t,e){this._data=void 0===t?[]:t,this.currIndex=0,"object"==typeof e&&this.projection(e)}hasNext(){return this.currIndex<this._data.length}next(){if(!this.hasNext())throw new Error("No more item to the cursor");return this._data[this.currIndex++]}toArray(){return this._data.map((t=>structuredClone(t._data)))}reset(){this.currIndex=0}first(){return this._data.length?this._data[0]._data:null}last(){return this._data.length?this._data[this._data.length-1]._data:null}sort(t,e={}){if(null===t||"object"!=typeof t||Array.isArray(t))throw new Error("The sort query must be an object like {field: 1}");const n=Object.keys(t);for(const e of n)if(1!==t[e]&&-1!==t[e])throw new Error(`Sort direction of "${e}" must be 1 or -1`);const s=e.collation?new Intl.Collator(e.collation.locale,e.collation):null;return this._data.sort(((e,i)=>{for(const a of n){const n=compareValues(e[a],i[a],s);if(0!==n)return n*t[a]}return 0})),this}get length(){return this._data.length}projection(t){if(null===t||"object"!=typeof t)return;const e=this;this._data.forEach((n=>{for(const s of Object.keys(t))0===t[s]&&n.deleteProp(s,e)}))}push(t){if("object"!=typeof t)throw new Error("The element must be an object");this._data.push(t)}}},{}],4:[function(t,e,n){const s=t("../utils");class documents{constructor(t){return this._data=t,this._utils=new s,this._cloned=!1,new Proxy(this,{deleteProperty:(t,e)=>!(!t._cloned||!(e in t._data))&&(delete t._data[e],!0),get:(t,e)=>"string"!=typeof e?t[e]:e.indexOf(".")>-1?t._utils.getDotData(t._data,e):e in t._data?t._data[e]:t[e],set:(t,e,n)=>{if("_cloned"===e)return t._cloned=n,!0;if("string"==typeof e&&e in t._data)throw new Error(`Document fields are read-only ("${e}"), change documents through the collection`);return!1}})}clone(){const t=new documents(structuredClone(this._data));return t._cloned=!0,t}deleteProp(t){return!!this._cloned&&Reflect.deleteProperty(this,t)}}e.exports=documents},{"../utils":9}],5:[function(t,e,n){const s=t("../documents/document.js"),i=t("./operators.js");e.exports=class{constructor(t=null){this.validationSchema=t,this._index={},this._prohibitedCharacters=["$","."]}_generateKey(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){var e=16*Math.random()|0;return("x"===t?e:3&e|8).toString(16)}))}set validationSchema(t){if("undefined"!=typeof schemaValidation&&null!==schemaValidation){if(-1==["object","function"].indexOf(typeof schemaValidation))throw new Error("Invalid schema validation");this._validationSchema=t}}_validate(t){return!0}_isDocument(t){return null!==t&&"object"==typeof t&&!Array.isArray(t)}_validateKeys(t){const e=new i;for(const n of Object.keys(t)){if(this._prohibitedCharacters.some((t=>n.indexOf(t)>-1))&&!e.isOperator(n))return!1;const s=t[n],i=Array.isArray(s)?s:[s];for(const t of i)if(null!==t&&"object"==typeof t&&!this._validateKeys(t))return!1}return!0}_ids(){return this._idSet&&this._idSet.size===this._documents.length||(this._idSet=new Set(this._documents.map((t=>t._data._id)))),this._idSet}_copy(t){try{return structuredClone(t)}catch(t){throw new Error("Documents can only contain cloneable values (no functions or DOM nodes)")}}insertOne(t,e){if(!this._isDocument(t))throw new Error("Invalid document type");if(!this._validateKeys(t))throw new Error("Invalid document keys");const n=this._copy(t);if(void 0===n._id)do{n._id=this._generateKey()}while(this._ids().has(n._id));else if(this._ids().has(n._id))throw new Error(`Duplicate _id "${n._id}"`);if(!this._validate(n))throw new Error("Invalid document");return this._documents.push(new s(n)),this._ids().add(n._id),"function"==typeof e&&e(n._id),n._id}insertMany(t,e){if(!Array.isArray(t)||!t.every((t=>this._isDocument(t))))throw new Error("Documents must be an array of objects");const n=new Set;for(const e of t){if(!this._validateKeys(e))throw new Error("Invalid document keys");if(void 0!==e._id){if(n.has(e._id)||this._ids().has(e._id))throw new Error(`Duplicate _id "${e._id}"`);n.add(e._id)}}const s=t.map((t=>this.insertOne(t)));return"function"==typeof e&&e(s),s}}},{"../documents/document.js":4,"./operators.js":6}],6:[function(t,e,n){e.exports=class{constructor(){this._logicalOperators=["$or","$and","$not","$nor"],this._comparisonOperators=["$lt","$lte","$gt","$gte","$eq","$ne","$in","$nin"]}isAnyOperator(t){return this.isLogicalOperator(t)||this.isOperator(t)}isLogicalOperator(t){return this._logicalOperators.indexOf(t)>-1}isOperator(t){return this._comparisonOperators.indexOf(t)>-1}matches(t,e){if(null===e||"object"!=typeof e||Array.isArray(e))throw new Error("The query must be an object");for(const n of Object.keys(e))if(this.isLogicalOperator(n)){if(!this.matchesLogical(n,e[n],t))return!1}else if(!this.matchesCondition(t[n],e[n]))return!1;return!0}matchesLogical(t,e,n){if("$not"===t)throw new Error("The $not operator must be used inside a field condition, like {age: {$not: {$gt: 30}}}");if(!Array.isArray(e))throw new Error(`The ${t} operator must be an array`);switch(t){case"$and":return e.every((t=>this.matches(n,t)));case"$or":return e.some((t=>this.matches(n,t)));case"$nor":return!e.some((t=>this.matches(n,t)))}return!1}matchesCondition(t,e){if(!this.isOperatorObject(e))return this.$eq(t,e);for(const n of Object.keys(e))if("$not"!==n){if(!this.isOperator(n))throw new Error(`Unknown operator ${n}`);if(!this[n](t,e[n]))return!1}else if(this.matchesCondition(t,e[n]))return!1;return!0}isOperatorObject(t){if(null===t||"object"!=typeof t||Array.isArray(t))return!1;const e=Object.keys(t);return e.length>0&&e.every((t=>t.startsWith("$")))}get $nin(){return(t,e)=>{if(!Array.isArray(e))throw new Error("The $nin operator must be an array");return!this.$in(t,e)}}get $in(){return(t,e)=>{if(!Array.isArray(e))throw new Error("The $in operator must be an array");if(Array.isArray(t))for(const n of e)if(t.indexOf(n)>-1)return!0;return e.indexOf(t)>-1}}get $lt(){return(t,e)=>!Array.isArray(e)&&t<e}get $lte(){return(t,e)=>!Array.isArray(e)&&t<=e}get $gt(){return(t,e)=>!Array.isArray(e)&&t>e}get $gte(){return(t,e)=>!Array.isArray(e)&&t>=e}get $ne(){return(t,e)=>!Array.isArray(e)&&t!==e}get $eq(){return(t,e)=>!Array.isArray(e)&&t===e}}},{}],7:[function(t,e,n){const s=t("./manipulation.js"),i=t("./operators.js"),a=t("../documents/cursor.js");e.exports=class extends s{constructor(t){super(t),this._operators=new i}count(){return this._documents.length}_deepFind(t,e,n=!1){const s=[];for(const e of this._documents)if(this._operators.matches(e,t)&&(s.push(e.clone()),n))break;return new a(s,e)}findOne(t,e=null){return this._deepFind(t,e,!0).first()}find(t,e=null){return this._deepFind(t,e)}}},{"../documents/cursor.js":3,"./manipulation.js":5,"./operators.js":6}],8:[function(t,e,n){const s=t("./dbs/database.js");class sorla{constructor(){this._dbs=[],this._selectedDb=null}_findDbIndex(t){return this._dbs.findIndex((e=>e.name==t))}dbExists(t){return this._findDbIndex(t)>-1}_selectDb(t){if(!this._dbs.hasOwnProperty(t))throw new Error("Database does not exists");this._selectedDb=this._dbs[t]}createDb(t){if(this.dbExists(t))return!1;let e=this._dbs.length;return this._dbs.push(new s(t)),e}useDb(t){let e=null;this.dbExists(t)||(e=this.createDb(t)),this._selectDb(null===e?this._findDbIndex(t):e)}countDb(){return this._dbs.length}get db(){return this._selectedDb}}e.exports=sorla,"undefined"!=typeof window&&(window.sorla=sorla)},{"./dbs/database.js":2}],9:[function(t,e,n){e.exports=class{get uuid(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){let e=16*Math.random()|0;return("x"==t?e:3&e|8).toString(16)}))}getDotData(t,e){let n=t;for(const t of e.split(".")){if(null===n||"object"!=typeof n||!(t in n))return;n=n[t]}return n}}},{}]},{},[8]);
1
+ !function r(t,e,n){function o(s,a){if(!e[s]){if(!t[s]){var c="function"==typeof require&&require;if(!a&&c)return c(s,!0);if(i)return i(s,!0);var u=new Error("Cannot find module '"+s+"'");throw u.code="MODULE_NOT_FOUND",u}var h=e[s]={exports:{}};t[s][0].call(h.exports,(function(e){return o(t[s][1][e]||e)}),h,h.exports,r,t,e,n)}return e[s].exports}for(var i="function"==typeof require&&require,s=0;s<n.length;s++)o(n[s]);return o}({1:[function(t,e,n){const i=t("../query_language/query.js");e.exports=class extends i{constructor(t){super(t),this._documents=[],this._indexes=[]}get first(){return structuredClone(this._documents[0]._data)}get last(){return structuredClone(this._documents[this._documents.length-1]._data)}get all(){return this._documents}}},{"../query_language/query.js":8}],2:[function(t,e,n){const i=t("../collections/collection.js");e.exports=class{constructor(t){return this._collections={},this._name=t,this._collectionCount=0,new Proxy(this,{get:(t,e,n)=>e in t._collections?t._collections[e]:t[e]})}get name(){return this._name}createCollection(t,e){if(this._collections.hasOwnProperty(t)||void 0!==this[t])throw new Error("Collection already exists");this._collections[t]=new i(e),this._collectionCount++}countCollections(){return this._collectionCount}listCollections(){return Object.keys(this._collections)}getCollection(t){return this._collections.hasOwnProperty(t)?this._collections[t]:void 0}dropCollection(t){return!!this._collections.hasOwnProperty(t)&&(delete this._collections[t],this._collectionCount--,!0)}getStats(){const t=this.listCollections().map((t=>({name:t,documents:this._collections[t].count()})));return{name:this._name,documents:t.reduce(((t,e)=>t+e.documents),0),collections:t}}}},{"../collections/collection.js":1}],3:[function(t,e,n){function typeRank(t){return null==t?0:"number"==typeof t?1:"string"==typeof t?2:"[object Date]"===Object.prototype.toString.call(t)?6:Array.isArray(t)?4:"object"==typeof t?3:"boolean"==typeof t?5:7}function compareValues(t,e,n){const i=typeRank(t),s=typeRank(e);if(i!==s)return i<s?-1:1;switch(i){case 0:return 0;case 2:if(n)return Math.sign(n.compare(t,e));break;case 3:case 4:t=JSON.stringify(t),e=JSON.stringify(e);break;case 6:t=t.getTime(),e=e.getTime()}return t<e?-1:t>e?1:0}e.exports=class{constructor(t,e){this._data=void 0===t?[]:t,this.currIndex=0,"object"==typeof e&&this.projection(e)}hasNext(){return this.currIndex<this._data.length}next(){if(!this.hasNext())throw new Error("No more item to the cursor");return this._data[this.currIndex++]}toArray(){return this._data.map((t=>structuredClone(t._data)))}reset(){this.currIndex=0}first(){return this._data.length?this._data[0]._data:null}last(){return this._data.length?this._data[this._data.length-1]._data:null}sort(t,e={}){if(null===t||"object"!=typeof t||Array.isArray(t))throw new Error("The sort query must be an object like {field: 1}");const n=Object.keys(t);for(const e of n)if(1!==t[e]&&-1!==t[e])throw new Error(`Sort direction of "${e}" must be 1 or -1`);const i=e.collation?new Intl.Collator(e.collation.locale,e.collation):null;return this._data.sort(((e,s)=>{for(const a of n){const n=compareValues(e[a],s[a],i);if(0!==n)return n*t[a]}return 0})),this}get length(){return this._data.length}projection(t){if(null===t||"object"!=typeof t)return this;const e=Object.keys(t);for(const n of e){if(0!==t[n]&&1!==t[n])throw new Error(`Projection of "${n}" must be 0 or 1`);if(n.includes("."))throw new Error(`Projection supports top level fields only ("${n}")`)}const n=e.filter((e=>1===t[e]&&"_id"!==e)),i=e.filter((e=>0===t[e]&&"_id"!==e));if(n.length&&i.length)throw new Error("Projection cannot mix inclusion and exclusion (except for _id)");const s=new Set(n);return 0!==t._id&&s.add("_id"),this._data.forEach((e=>{const a=n.length?Object.keys(e._data).filter((t=>!s.has(t))):[...i,...0===t._id?["_id"]:[]];for(const t of a)e.deleteProp(t)})),this}skip(t){if(!Number.isInteger(t)||t<0)throw new Error("skip() needs a non negative integer");return this._data=this._data.slice(t),this.currIndex=0,this}limit(t){if(!Number.isInteger(t)||t<0)throw new Error("limit() needs a non negative integer");return t>0&&(this._data=this._data.slice(0,t)),this.currIndex=0,this}push(t){if("object"!=typeof t)throw new Error("The element must be an object");this._data.push(t)}}},{}],4:[function(t,e,n){const i=t("../utils");class documents{constructor(t){return this._data=t,this._utils=new i,this._cloned=!1,new Proxy(this,{deleteProperty:(t,e)=>!(!t._cloned||!(e in t._data))&&(delete t._data[e],!0),get:(t,e)=>"string"!=typeof e?t[e]:e.indexOf(".")>-1?t._utils.getDotData(t._data,e):e in t._data?t._data[e]:t[e],set:(t,e,n)=>{if("_cloned"===e)return t._cloned=n,!0;if("string"==typeof e&&e in t._data)throw new Error(`Document fields are read-only ("${e}"), change documents through the collection`);return!1}})}clone(){const t=new documents(structuredClone(this._data));return t._cloned=!0,t}deleteProp(t){return!!this._cloned&&Reflect.deleteProperty(this,t)}}e.exports=documents},{"../utils":12}],5:[function(t,e,n){e.exports=class{constructor(){this._listeners=new Map}on(t,e){if("function"!=typeof e)throw new Error("The listener must be a function");return this._listeners.has(t)||this._listeners.set(t,new Set),this._listeners.get(t).add(e),()=>this.off(t,e)}once(t,e){const n=this.on(t,(t=>{n(),e(t)}));return n}off(t,e){void 0===e?this._listeners.delete(t):this._listeners.get(t)?.delete(e)}_emitChange(t,e){const n={type:t,...e};for(const e of[t,"change"])for(const t of[...this._listeners.get(e)??[]])try{t(n)}catch(t){setTimeout((()=>{throw t}))}}}},{}],6:[function(t,e,n){const i=t("../documents/document.js"),s=t("./operators.js"),a=t("../events.js"),{applyUpdate:c}=t("./update.js"),{compileSchema:u,ValidationError:h}=t("../schema/schema.js");e.exports=class extends a{constructor(t=null){super(),this.validationSchema=t,this._index={},this._prohibitedCharacters=["$","."]}_generateKey(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){var e=16*Math.random()|0;return("x"===t?e:3&e|8).toString(16)}))}set validationSchema(t){if(null!=t){if(-1===["object","function"].indexOf(typeof t)||Array.isArray(t))throw new Error("The validation schema must be an object (JSON Schema) or a function");this._schema=u(t)}else this._schema=null}get validationSchema(){return this._schema?this._schema.source:null}setSchema(t){return this.validationSchema=t,this}getSchema(){return this.validationSchema}validate(t){const e=this._schema?this._schema.validate(t):[];return{valid:0===e.length,errors:e}}_validate(t){const e=this._schema?this._schema.validate(t):[];if(e.length)throw new h(e)}_isDocument(t){return null!==t&&"object"==typeof t&&!Array.isArray(t)}_validateKeys(t){const e=new s;for(const n of Object.keys(t)){if(this._prohibitedCharacters.some((t=>n.indexOf(t)>-1))&&!e.isOperator(n))return!1;const i=t[n],s=Array.isArray(i)?i:[i];for(const t of s)if(null!==t&&"object"==typeof t&&!this._validateKeys(t))return!1}return!0}_ids(){return this._idSet&&this._idSet.size===this._documents.length||(this._idSet=new Set(this._documents.map((t=>t._data._id)))),this._idSet}_copy(t){try{return structuredClone(t)}catch(t){throw new Error("Documents can only contain cloneable values (no functions or DOM nodes)")}}insertOne(t,e){const n=this._insertDocument(t);return this._emitChange("insert",{ids:[n],documents:[this._copy(this._documents.at(-1)._data)]}),"function"==typeof e&&e(n),n}_insertDocument(t){return this._store(this._prepare(t))}_prepare(t,e=null){if(!this._isDocument(t))throw new Error("Invalid document type");if(!this._validateKeys(t))throw new Error("Invalid document keys");const taken=t=>this._ids().has(t)||null!==e&&e.has(t),n=this._copy(t);if(void 0===n._id)do{n._id=this._generateKey()}while(taken(n._id));else if(taken(n._id))throw new Error(`Duplicate _id "${n._id}"`);if(this._schema&&(this._schema.applyDefaults(n,(t=>this._copy(t))),!this._validateKeys(n)))throw new Error("Invalid document keys");return this._validate(n),n}_store(t){return this._documents.push(new i(t)),this._ids().add(t._id),t._id}insertMany(t,e){if(!Array.isArray(t)||!t.every((t=>this._isDocument(t))))throw new Error("Documents must be an array of objects");const n=new Set,i=t.map((t=>{const e=this._prepare(t,n);return n.add(e._id),e})),s=this._documents.length,a=i.map((t=>this._store(t)));return this._emitChange("insert",{ids:a,documents:this._documents.slice(s).map((t=>this._copy(t._data)))}),"function"==typeof e&&e(a),a}_updateHelpers(){return{copy:t=>this._copy(t),validateKeys:t=>this._validateKeys(t),operators:this._operators}}_upsertBase(t){const e={};for(const[n,i]of Object.entries(t))n.startsWith("$")||n.includes(".")||this._operators.isRegExp(i)||(this._operators.isOperatorObject(i)?"$eq"in i&&(e[n]=i.$eq):e[n]=i);return e}_update(t,e,n,s){this._requireFilter(t);const a=this._updateHelpers(),u=[];for(let n=0;n<this._documents.length;n++){const i=this._documents[n];if(!this._operators.matches(i,t))continue;const h=c(i._data,e,a);if(this._validate(h),u.push({index:n,previous:i._data,next:h}),!s)break}if(!u.length&&n.upsert){const n=c(this._upsertBase(t),e,a),i=this._insertDocument(n);return this._emitChange("insert",{ids:[i],documents:[this._copy(this._documents.at(-1)._data)]}),{matchedCount:0,modifiedCount:0,upsertedId:i}}const h=u.filter((t=>JSON.stringify(t.previous)!==JSON.stringify(t.next)));for(const t of h)this._documents[t.index]=new i(t.next);return h.length&&this._emitChange("update",{ids:h.map((t=>t.next._id)),documents:h.map((t=>this._copy(t.next))),previous:h.map((t=>this._copy(t.previous)))}),{matchedCount:u.length,modifiedCount:h.length,upsertedId:null}}updateOne(t,e,n={}){return this._update(t,e,n,!1)}updateMany(t,e,n={}){return this._update(t,e,n,!0)}replaceOne(t,e,n={}){if(this._requireFilter(t),!this._isDocument(e))throw new Error("The replacement must be a document");if(Object.keys(e).some((t=>t.startsWith("$"))))throw new Error("The replacement cannot contain update operators, use updateOne()");if(!this._validateKeys(e))throw new Error("Invalid document keys");const s=this._documents.findIndex((e=>this._operators.matches(e,t)));if(-1===s){if(!n.upsert)return{matchedCount:0,modifiedCount:0,upsertedId:null};const t=this._insertDocument(e);return this._emitChange("insert",{ids:[t],documents:[this._copy(this._documents.at(-1)._data)]}),{matchedCount:0,modifiedCount:0,upsertedId:t}}const a=this._documents[s]._data;if(void 0!==e._id&&e._id!==a._id)throw new Error("The _id field cannot be changed");const c={...this._copy(e),_id:a._id};this._validate(c);const u=JSON.stringify(a)!==JSON.stringify(c);return u&&(this._documents[s]=new i(c),this._emitChange("replace",{ids:[c._id],documents:[this._copy(c)],previous:[this._copy(a)]})),{matchedCount:1,modifiedCount:u?1:0,upsertedId:null}}_delete(t,e){this._requireFilter(t);const n=[];let i=0;for(let s=0;s<this._documents.length;s++){const a=this._documents[s];!e&&n.length||!this._operators.matches(a,t)?this._documents[i++]=a:n.push(a._data)}if(this._documents.length=i,n.length){for(const t of n)this._idSet?.delete(t._id);this._emitChange("delete",{ids:n.map((t=>t._id)),documents:n})}return{deletedCount:n.length}}deleteOne(t){return this._delete(t,!1)}deleteMany(t){return this._delete(t,!0)}_requireFilter(t){if(!this._isDocument(t))throw new Error("The filter must be a query object, use {} to match every document")}}},{"../documents/document.js":4,"../events.js":5,"../schema/schema.js":10,"./operators.js":7,"./update.js":9}],7:[function(t,e,n){e.exports=class{constructor(){this._logicalOperators=["$or","$and","$not","$nor"],this._comparisonOperators=["$lt","$lte","$gt","$gte","$eq","$ne","$in","$nin","$exists","$regex","$options"]}isAnyOperator(t){return this.isLogicalOperator(t)||this.isOperator(t)}isLogicalOperator(t){return this._logicalOperators.indexOf(t)>-1}isOperator(t){return this._comparisonOperators.indexOf(t)>-1}matches(t,e){if(null===e||"object"!=typeof e||Array.isArray(e))throw new Error("The query must be an object");for(const n of Object.keys(e))if(this.isLogicalOperator(n)){if(!this.matchesLogical(n,e[n],t))return!1}else if(!this.matchesCondition(t[n],e[n]))return!1;return!0}matchesLogical(t,e,n){if("$not"===t)throw new Error("The $not operator must be used inside a field condition, like {age: {$not: {$gt: 30}}}");if(!Array.isArray(e))throw new Error(`The ${t} operator must be an array`);switch(t){case"$and":return e.every((t=>this.matches(n,t)));case"$or":return e.some((t=>this.matches(n,t)));case"$nor":return!e.some((t=>this.matches(n,t)))}return!1}matchesCondition(t,e){if(this.isRegExp(e))return this.matchesRegex(t,e);if(!this.isOperatorObject(e))return this.$eq(t,e);if("$options"in e&&!("$regex"in e))throw new Error("The $options operator must be used with $regex");for(const n of Object.keys(e))if("$not"!==n){if("$options"!==n)if("$regex"!==n){if(!this.isOperator(n))throw new Error(`Unknown operator ${n}`);if(!this[n](t,e[n]))return!1}else if(!this.matchesRegex(t,this.toRegExp(e.$regex,e.$options)))return!1}else if(this.matchesCondition(t,e[n]))return!1;return!0}isRegExp(t){return"[object RegExp]"===Object.prototype.toString.call(t)}toRegExp(t,e=""){if("string"!=typeof e||/[^ims]/.test(e))throw new Error("The $options operator accepts only the flags i, m and s");if(this.isRegExp(t))return new RegExp(t.source,[...new Set((t.flags+e).replace(/[gy]/g,""))].join(""));if("string"!=typeof t)throw new Error("The $regex operator must be a string or a RegExp");return new RegExp(t,e)}matchesRegex(t,e){const test=t=>"string"==typeof t&&(e.lastIndex=0,e.test(t));return Array.isArray(t)?t.some(test):test(t)}isOperatorObject(t){if(null===t||"object"!=typeof t||Array.isArray(t))return!1;const e=Object.keys(t);return e.length>0&&e.every((t=>t.startsWith("$")))}get $exists(){return(t,e)=>void 0!==t==!!e}get $nin(){return(t,e)=>{if(!Array.isArray(e))throw new Error("The $nin operator must be an array");return!this.$in(t,e)}}get $in(){return(t,e)=>{if(!Array.isArray(e))throw new Error("The $in operator must be an array");if(Array.isArray(t))for(const n of e)if(t.indexOf(n)>-1)return!0;return e.indexOf(t)>-1}}get $lt(){return(t,e)=>!Array.isArray(e)&&t<e}get $lte(){return(t,e)=>!Array.isArray(e)&&t<=e}get $gt(){return(t,e)=>!Array.isArray(e)&&t>e}get $gte(){return(t,e)=>!Array.isArray(e)&&t>=e}get $ne(){return(t,e)=>!Array.isArray(e)&&t!==e}get $eq(){return(t,e)=>!Array.isArray(e)&&t===e}}},{}],8:[function(t,e,n){const i=t("./manipulation.js"),s=t("./operators.js"),a=t("../documents/cursor.js");e.exports=class extends i{constructor(t){super(t),this._operators=new s}count(t){return void 0===t?this._documents.length:this._documents.reduce(((e,n)=>e+(this._operators.matches(n,t)?1:0)),0)}_deepFind(t,e,n=!1){const i=[];for(const e of this._documents)if(this._operators.matches(e,t)&&(i.push(e.clone()),n))break;return new a(i,e)}findOne(t,e=null){return this._deepFind(t,e,!0).first()}find(t,e=null){return this._deepFind(t,e)}}},{"../documents/cursor.js":3,"./manipulation.js":6,"./operators.js":7}],9:[function(t,e,n){const i=["$set","$unset","$inc","$push","$pull"],isPlainObject=t=>null!==t&&"object"==typeof t&&!Array.isArray(t)&&"[object Object]"===Object.prototype.toString.call(t);function splitPath(t){if("string"!=typeof t||""===t||t.split(".").some((t=>""===t)))throw new Error(`Invalid field path "${t}"`);if("_id"===t||t.startsWith("_id."))throw new Error("The _id field cannot be updated");return t.split(".")}function getPath(t,e){let n=t;for(const t of e){if(null===n||"object"!=typeof n||!(t in n))return;n=n[t]}return n}function parentOf(t,e,n){let i=t;for(const t of e.slice(0,-1)){if(!(t in i)){if(!n)return null;i[t]={}}if(null===i[t]||"object"!=typeof i[t])throw new Error(`Cannot create the field "${e.join(".")}": "${t}" is not an object`);i=i[t]}return i}function validateUpdate(t){if(!isPlainObject(t))throw new Error("The update must be an object like {$set: {field: value}}");const e=Object.keys(t);if(!e.length)throw new Error("The update must contain at least one operator");for(const n of e){if(!n.startsWith("$"))throw new Error(`The update can only contain operators, use replaceOne() to replace a document (found "${n}")`);if(!i.includes(n))throw new Error(`Unknown update operator ${n}`);if(!isPlainObject(t[n]))throw new Error(`The ${n} operator must be an object like {${n}: {field: value}}`)}}e.exports={applyUpdate:function(t,e,n){validateUpdate(e);const i=n.copy(t);for(const[t,s]of Object.entries(e.$set??{})){const e=splitPath(t);if(null!==s&&"object"==typeof s&&!n.validateKeys(Array.isArray(s)?{value:s}:s))throw new Error("Invalid document keys");parentOf(i,e,!0)[e.at(-1)]=n.copy(s)}for(const t of Object.keys(e.$unset??{})){const e=splitPath(t),n=parentOf(i,e,!1);n&&!Array.isArray(n)&&delete n[e.at(-1)]}for(const[t,n]of Object.entries(e.$inc??{})){const e=splitPath(t);if("number"!=typeof n||!Number.isFinite(n))throw new Error(`$inc needs a number for "${t}"`);const s=getPath(i,e);if(void 0!==s&&"number"!=typeof s)throw new Error(`Cannot $inc "${t}": it is not a number`);parentOf(i,e,!0)[e.at(-1)]=(s??0)+n}for(const[t,s]of Object.entries(e.$push??{})){const e=splitPath(t),a=getPath(i,e);if(void 0!==a&&!Array.isArray(a))throw new Error(`Cannot $push to "${t}": it is not an array`);const c=isPlainObject(s)&&Array.isArray(s.$each)?s.$each:[s];for(const t of c)if(null!==t&&"object"==typeof t&&!n.validateKeys(Array.isArray(t)?{item:t}:t))throw new Error("Invalid document keys");parentOf(i,e,!0)[e.at(-1)]=[...a??[],...c.map(n.copy)]}for(const[t,s]of Object.entries(e.$pull??{})){const e=splitPath(t),a=getPath(i,e);if(void 0===a)continue;if(!Array.isArray(a))throw new Error(`Cannot $pull from "${t}": it is not an array`);const{operators:c}=n,matches=t=>c.isRegExp(s)||c.isOperatorObject(s)?c.matchesCondition(t,s):isPlainObject(s)?isPlainObject(t)&&c.matches(t,s):t===s;parentOf(i,e,!1)[e.at(-1)]=a.filter((t=>!matches(t)))}return i},validateUpdate:validateUpdate,UPDATE_OPERATORS:i}},{}],10:[function(t,e,n){const i=["string","number","integer","boolean","object","array","null","date"],s=["email","date","date-time","uri"],a=new Set(["type","properties","required","additionalProperties","items","enum","const","default","minimum","maximum","exclusiveMinimum","exclusiveMaximum","minLength","maxLength","pattern","format","minItems","maxItems","uniqueItems","title","description","$schema","$id","examples"]);class ValidationError extends Error{constructor(t){const e=t[0];super(1===t.length?`Invalid document: ${e.message}`:`Invalid document: ${e.message} (and ${t.length-1} more)`),this.name="ValidationError",this.errors=t}}const isPlainObject=t=>"[object Object]"===Object.prototype.toString.call(t),isDate=t=>"[object Date]"===Object.prototype.toString.call(t);function typeOf(t){return null===t?"null":Array.isArray(t)?"array":isDate(t)?"date":"number"==typeof t?Number.isInteger(t)?"integer":"number":isPlainObject(t)?"object":typeof t}const c={email:t=>/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t),date:t=>{const e=/^(\d{4})-(\d{2})-(\d{2})$/.exec(t);if(!e)return!1;const n=new Date(Date.UTC(+e[1],+e[2]-1,+e[3]));return n.getUTCMonth()===+e[2]-1&&n.getUTCDate()===+e[3]},"date-time":t=>/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.test(t)&&!isNaN(Date.parse(t)),uri:t=>{try{return Boolean(new URL(t).protocol)}catch(t){return!1}}};function checkSchema(t,e="schema"){if(!isPlainObject(t))throw new Error(`${e} must be an object`);for(const n of Object.keys(t))if(!a.has(n))throw new Error(`${e}: unknown keyword "${n}"`);const n=void 0===t.type?[]:Array.isArray(t.type)?t.type:[t.type];for(const t of n)if(!i.includes(t))throw new Error(`${e}: unknown type "${t}" (use ${i.join(", ")})`);if(void 0!==t.format&&!s.includes(t.format))throw new Error(`${e}: unknown format "${t.format}" (use ${s.join(", ")})`);if(void 0!==t.required&&(!Array.isArray(t.required)||t.required.some((t=>"string"!=typeof t))))throw new Error(`${e}: required must be an array of field names`);if(void 0!==t.enum&&!Array.isArray(t.enum))throw new Error(`${e}: enum must be an array`);if(void 0!==t.pattern)try{new RegExp(t.pattern)}catch(t){throw new Error(`${e}: invalid pattern (${t.message})`)}for(const n of["minimum","maximum","exclusiveMinimum","exclusiveMaximum","minLength","maxLength","minItems","maxItems"])if(void 0!==t[n]&&("number"!=typeof t[n]||!Number.isFinite(t[n])))throw new Error(`${e}: ${n} must be a number`);if(void 0!==t.properties){if(!isPlainObject(t.properties))throw new Error(`${e}: properties must be an object`);for(const[n,i]of Object.entries(t.properties))checkSchema(i,`${e}.properties.${n}`)}void 0!==t.additionalProperties&&"boolean"!=typeof t.additionalProperties&&checkSchema(t.additionalProperties,`${e}.additionalProperties`),void 0!==t.items&&checkSchema(t.items,`${e}.items`)}const describe=t=>"string"==typeof t?JSON.stringify(t.length>40?`${t.slice(0,40)}…`:t):isDate(t)?t.toISOString():JSON.stringify(t),same=(t,e)=>isDate(t)&&isDate(e)?t.getTime()===e.getTime():JSON.stringify(t)===JSON.stringify(e);function validateValue(t,e,n="",i=[]){const s=n||"document",add=(t,e)=>i.push({path:n,rule:t,message:`${s} ${e}`});if(void 0!==e.type){const n=Array.isArray(e.type)?e.type:[e.type];if(!n.some((e=>function(t,e){const n=typeOf(t);return"number"===e?"number"===n||"integer"===n:n===e}(t,e))))return add("type",`must be ${n.join(" or ")} (got ${typeOf(t)})`),i}if(void 0===e.enum||e.enum.some((e=>same(e,t)))||add("enum",`must be one of ${e.enum.map(describe).join(", ")}`),void 0===e.const||same(e.const,t)||add("const",`must be ${describe(e.const)}`),"number"==typeof t&&(void 0!==e.minimum&&t<e.minimum&&add("minimum",`must be >= ${e.minimum}`),void 0!==e.maximum&&t>e.maximum&&add("maximum",`must be <= ${e.maximum}`),void 0!==e.exclusiveMinimum&&t<=e.exclusiveMinimum&&add("exclusiveMinimum",`must be > ${e.exclusiveMinimum}`),void 0!==e.exclusiveMaximum&&t>=e.exclusiveMaximum&&add("exclusiveMaximum",`must be < ${e.exclusiveMaximum}`)),"string"==typeof t){const n=[...t].length;void 0!==e.minLength&&n<e.minLength&&add("minLength",`must have at least ${e.minLength} characters`),void 0!==e.maxLength&&n>e.maxLength&&add("maxLength",`must have at most ${e.maxLength} characters`),void 0===e.pattern||new RegExp(e.pattern,"u").test(t)||add("pattern",`must match ${e.pattern}`),void 0===e.format||c[e.format](t)||add("format",`must be a valid ${e.format}`)}if(Array.isArray(t)&&(void 0!==e.minItems&&t.length<e.minItems&&add("minItems",`must have at least ${e.minItems} items`),void 0!==e.maxItems&&t.length>e.maxItems&&add("maxItems",`must have at most ${e.maxItems} items`),e.uniqueItems&&new Set(t.map((t=>JSON.stringify(t)))).size!==t.length&&add("uniqueItems","must not have repeated items"),void 0!==e.items&&t.forEach(((t,s)=>validateValue(t,e.items,`${n}[${s}]`,i)))),isPlainObject(t)){const s=e.properties??{},child=t=>n?`${n}.${t}`:t;for(const n of e.required??[])void 0===t[n]&&i.push({path:child(n),rule:"required",message:`${child(n)} is required`});for(const[a,c]of Object.entries(t))void 0!==c&&(void 0!==s[a]?validateValue(c,s[a],child(a),i):""===n&&"_id"===a||(!1===e.additionalProperties?i.push({path:child(a),rule:"additionalProperties",message:`${child(a)} is not allowed`}):isPlainObject(e.additionalProperties)&&validateValue(c,e.additionalProperties,child(a),i)))}return i}function applyDefaults(t,e,n){if(isPlainObject(t)&&isPlainObject(e.properties))for(const[i,s]of Object.entries(e.properties))void 0===t[i]&&void 0!==s.default&&(t[i]=n(s.default)),void 0!==t[i]&&applyDefaults(t[i],s,n)}e.exports={compileSchema:function(t){if("function"==typeof t)return{source:t,applyDefaults:()=>{},validate:e=>{const n=t(e);if(!0===n||null==n)return[];if(!1===n)return[{path:"",rule:"custom",message:"document is invalid"}];return(Array.isArray(n)?n:[n]).map((t=>"string"==typeof t?{path:"",rule:"custom",message:t}:{path:"",rule:"custom",...t}))}};const e={type:"object",...t};if(checkSchema(e),"object"!==e.type)throw new Error('schema: a document schema must be of type "object"');return{source:t,validate:t=>validateValue(t,e),applyDefaults:(t,n)=>applyDefaults(t,e,n)}},ValidationError:ValidationError,validateValue:validateValue,checkSchema:checkSchema}},{}],11:[function(t,e,n){const i=t("./dbs/database.js"),{ValidationError:s}=t("./schema/schema.js");class sorla{constructor(){this._dbs=[],this._selectedDb=null}_findDbIndex(t){return this._dbs.findIndex((e=>e.name==t))}dbExists(t){return this._findDbIndex(t)>-1}_selectDb(t){if(!this._dbs.hasOwnProperty(t))throw new Error("Database does not exists");this._selectedDb=this._dbs[t]}createDb(t){if(this.dbExists(t))return!1;let e=this._dbs.length;return this._dbs.push(new i(t)),e}useDb(t){let e=null;this.dbExists(t)||(e=this.createDb(t)),this._selectDb(null===e?this._findDbIndex(t):e)}countDb(){return this._dbs.length}listDatabases(){return this._dbs.map((t=>t.name))}dropDb(t){const e=this._findDbIndex(t);return-1!==e&&(this._selectedDb===this._dbs[e]&&(this._selectedDb=null),this._dbs.splice(e,1),!0)}get db(){return this._selectedDb}}sorla.ValidationError=s,e.exports=sorla,"undefined"!=typeof window&&(window.sorla=sorla)},{"./dbs/database.js":2,"./schema/schema.js":10}],12:[function(t,e,n){e.exports=class{get uuid(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(t){let e=16*Math.random()|0;return("x"==t?e:3&e|8).toString(16)}))}getDotData(t,e){let n=t;for(const t of e.split(".")){if(null===n||"object"!=typeof n||!(t in n))return;n=n[t]}return n}}},{}]},{},[11]);
package/index.d.ts CHANGED
@@ -11,7 +11,11 @@ export type ComparisonOperators = {
11
11
  $lte?: unknown;
12
12
  $in?: unknown[];
13
13
  $nin?: unknown[];
14
- $not?: ComparisonOperators;
14
+ $exists?: boolean;
15
+ $regex?: string | RegExp;
16
+ /** Flags for $regex: i, m, s */
17
+ $options?: string;
18
+ $not?: ComparisonOperators | RegExp;
15
19
  };
16
20
 
17
21
  /** A query: field conditions (value or operators, "a.b" paths allowed) and nested $and / $or / $nor. */
@@ -22,7 +26,87 @@ export type Query = {
22
26
  [field: string]: unknown;
23
27
  };
24
28
 
25
- export type Projection = Record<string, 0>;
29
+ /** Exclusion ({ email: 0 }) or inclusion ({ name: 1 }); only _id can mix both. */
30
+ export type SchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null' | 'date';
31
+
32
+ /** Subset of JSON Schema used to validate documents ("date" accepts Date objects). */
33
+ export interface JSONSchema {
34
+ type?: SchemaType | SchemaType[];
35
+ properties?: Record<string, JSONSchema>;
36
+ required?: string[];
37
+ additionalProperties?: boolean | JSONSchema;
38
+ items?: JSONSchema;
39
+ enum?: unknown[];
40
+ const?: unknown;
41
+ /** Applied on insert and upsert when the field is missing. */
42
+ default?: unknown;
43
+ minimum?: number;
44
+ maximum?: number;
45
+ exclusiveMinimum?: number;
46
+ exclusiveMaximum?: number;
47
+ minLength?: number;
48
+ maxLength?: number;
49
+ pattern?: string;
50
+ format?: 'email' | 'date' | 'date-time' | 'uri';
51
+ minItems?: number;
52
+ maxItems?: number;
53
+ uniqueItems?: boolean;
54
+ title?: string;
55
+ description?: string;
56
+ examples?: unknown[];
57
+ $schema?: string;
58
+ $id?: string;
59
+ }
60
+
61
+ export type SchemaFunction = (document: Document) => true | false | string | Array<string | Partial<ValidationIssue>> | null | undefined;
62
+
63
+ export interface ValidationIssue {
64
+ /** Field path: 'email', 'address.zip', 'tags[1]'; '' for the whole document. */
65
+ path: string;
66
+ rule: string;
67
+ message: string;
68
+ }
69
+
70
+ export declare class ValidationError extends Error {
71
+ name: 'ValidationError';
72
+ errors: ValidationIssue[];
73
+ }
74
+
75
+ export type Projection = Record<string, 0 | 1>;
76
+
77
+ export type UpdateFilter = {
78
+ $set?: Record<string, unknown>;
79
+ $unset?: Record<string, unknown>;
80
+ $inc?: Record<string, number>;
81
+ $push?: Record<string, unknown | { $each: unknown[] }>;
82
+ $pull?: Record<string, unknown>;
83
+ };
84
+
85
+ export interface UpdateOptions {
86
+ /** Insert a document built from the filter when nothing matches. */
87
+ upsert?: boolean;
88
+ }
89
+
90
+ export interface UpdateResult {
91
+ matchedCount: number;
92
+ modifiedCount: number;
93
+ upsertedId: unknown | null;
94
+ }
95
+
96
+ export interface DeleteResult {
97
+ deletedCount: number;
98
+ }
99
+
100
+ export type ChangeType = 'insert' | 'update' | 'replace' | 'delete';
101
+
102
+ export interface ChangeEvent {
103
+ type: ChangeType;
104
+ ids: unknown[];
105
+ /** Copies of the documents (after the change; the removed ones for delete). */
106
+ documents: Document[];
107
+ /** Copies of the documents before an update or replace. */
108
+ previous?: Document[];
109
+ }
26
110
  export type SortSpec = Record<string, 1 | -1>;
27
111
 
28
112
  export interface SortOptions {
@@ -48,7 +132,10 @@ export declare class Cursor {
48
132
  /** Plain copies of the documents. */
49
133
  toArray(): Document[];
50
134
  sort(spec: SortSpec, options?: SortOptions): this;
51
- projection(projection: Projection): void;
135
+ skip(count: number): this;
136
+ /** 0 means no limit. */
137
+ limit(count: number): this;
138
+ projection(projection: Projection): this;
52
139
  push(element: object): void;
53
140
  }
54
141
 
@@ -56,7 +143,8 @@ export declare class Collection {
56
143
  readonly first: Document;
57
144
  readonly last: Document;
58
145
  readonly all: StoredDocument[];
59
- count(): number;
146
+ /** All documents, or the ones matching the query. */
147
+ count(query?: Query): number;
60
148
  /** Inserts a copy of the document and returns its _id. */
61
149
  insertOne(document: Document, callback?: (id: unknown) => void): unknown;
62
150
  /** Validates every document first, then inserts them and returns their ids. */
@@ -64,16 +152,45 @@ export declare class Collection {
64
152
  find(query: Query, projection?: Projection | null): Cursor;
65
153
  /** Data of the first matching document, null when nothing matches. */
66
154
  findOne(query: Query, projection?: Projection | null): Document | null;
155
+ updateOne(filter: Query, update: UpdateFilter, options?: UpdateOptions): UpdateResult;
156
+ updateMany(filter: Query, update: UpdateFilter, options?: UpdateOptions): UpdateResult;
157
+ /** Replaces the document, keeping its _id. */
158
+ replaceOne(filter: Query, replacement: Document, options?: UpdateOptions): UpdateResult;
159
+ deleteOne(filter: Query): DeleteResult;
160
+ /** deleteMany({}) empties the collection. */
161
+ deleteMany(filter: Query): DeleteResult;
162
+ /** Returns a function that unsubscribes. "change" receives every type. */
163
+ on(event: ChangeType | 'change', listener: (event: ChangeEvent) => void): () => void;
164
+ once(event: ChangeType | 'change', listener: (event: ChangeEvent) => void): () => void;
165
+ off(event: ChangeType | 'change', listener?: (event: ChangeEvent) => void): void;
166
+ /** Checks a document against the schema without storing it. */
167
+ validate(document: Document): { valid: boolean; errors: ValidationIssue[] };
168
+ /** Existing documents are not revalidated. null removes the schema. */
169
+ setSchema(schema: JSONSchema | SchemaFunction | null): this;
170
+ getSchema(): JSONSchema | SchemaFunction | null;
171
+ }
172
+
173
+ export interface DatabaseStats {
174
+ name: string;
175
+ documents: number;
176
+ collections: Array<{ name: string; documents: number }>;
67
177
  }
68
178
 
69
179
  export declare class Database {
70
180
  readonly name: string;
71
- createCollection(name: string, validationSchema?: unknown): void;
181
+ /** Inserts, updates and replaces must satisfy the schema; a ValidationError is thrown otherwise. */
182
+ createCollection(name: string, validationSchema?: JSONSchema | SchemaFunction | null): void;
72
183
  countCollections(): number;
184
+ listCollections(): string[];
185
+ /** Any collection by name, also when the name shadows a method. */
186
+ getCollection(name: string): Collection | undefined;
187
+ dropCollection(name: string): boolean;
188
+ getStats(): DatabaseStats;
73
189
  [collection: string]: unknown;
74
190
  }
75
191
 
76
192
  declare class sorla {
193
+ static ValidationError: typeof ValidationError;
77
194
  constructor();
78
195
  /** The selected database. Collections are properties of it: sorla.db.people */
79
196
  readonly db: Database & Record<string, Collection>;
@@ -83,6 +200,9 @@ declare class sorla {
83
200
  /** Selects a database, creating it when needed. */
84
201
  useDb(name: string): void;
85
202
  countDb(): number;
203
+ listDatabases(): string[];
204
+ /** When the dropped database was selected, db becomes null. */
205
+ dropDb(name: string): boolean;
86
206
  }
87
207
 
88
208
  export { sorla };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sorla-db",
3
- "version": "1.2.0",
3
+ "version": "1.4.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
5
  "main": "src/sorla.js",
6
6
  "module": "index.mjs",
@@ -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;
@@ -123,23 +123,75 @@ class cursor {
123
123
  }
124
124
 
125
125
  /**
126
- * 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.
127
130
  * @param json projection
128
131
  * @return cursor
129
132
  */
130
133
  projection(projection) {
131
134
  if(projection === null || typeof projection !== 'object')
132
- 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');
133
154
 
134
- const self = this;
135
155
  this._data.forEach(doc => {
136
- for(const attribute of Object.keys(projection))
137
- {
138
- if(projection[attribute] === 0) {
139
- doc.deleteProp(attribute, self);
140
- }
141
- }
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);
142
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;
143
195
  }
144
196
 
145
197
  /**
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;