sorla-db 1.3.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 +53 -0
- package/dist/sorla.min.js +1 -1
- package/index.d.ts +53 -1
- package/package.json +1 -1
- package/src/query_language/manipulation.js +94 -33
- package/src/schema/schema.js +306 -0
- package/src/sorla.js +4 -0
package/README.md
CHANGED
|
@@ -169,6 +169,59 @@ sorla.db.dropCollection('updatedTags');
|
|
|
169
169
|
sorla.db.getStats(); // { name, documents, collections: [{ name, documents }] }
|
|
170
170
|
```
|
|
171
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
|
+
|
|
172
225
|
## Find Operators
|
|
173
226
|
|
|
174
227
|
### $and
|
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 h=e[i]={exports:{}};t[i][0].call(h.exports,(function(e){return o(t[i][1][e]||e)}),h,h.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":8}],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}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 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 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)),s=e.filter((e=>0===t[e]&&"_id"!==e));if(n.length&&s.length)throw new Error("Projection cannot mix inclusion and exclusion (except for _id)");const i=new Set(n);return 0!==t._id&&i.add("_id"),this._data.forEach((e=>{const a=n.length?Object.keys(e._data).filter((t=>!i.has(t))):[...s,...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 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":11}],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 s=t("../documents/document.js"),i=t("./operators.js"),a=t("../events.js"),{applyUpdate:c}=t("./update.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("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){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){if(!this._isDocument(t))throw new Error("Invalid document type");if(!this._validateKeys(t))throw new Error("Invalid document keys");const e=this._copy(t);if(void 0===e._id)do{e._id=this._generateKey()}while(this._ids().has(e._id));else if(this._ids().has(e._id))throw new Error(`Duplicate _id "${e._id}"`);if(!this._validate(e))throw new Error("Invalid document");return this._documents.push(new s(e)),this._ids().add(e._id),e._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=this._documents.length,i=t.map((t=>this._insertDocument(t)));return this._emitChange("insert",{ids:i,documents:this._documents.slice(s).map((t=>this._copy(t._data)))}),"function"==typeof e&&e(i),i}_updateHelpers(){return{copy:t=>this._copy(t),validateKeys:t=>this._validateKeys(t),operators:this._operators}}_upsertBase(t){const e={};for(const[n,s]of Object.entries(t))n.startsWith("$")||n.includes(".")||this._operators.isRegExp(s)||(this._operators.isOperatorObject(s)?"$eq"in s&&(e[n]=s.$eq):e[n]=s);return e}_update(t,e,n,i){this._requireFilter(t);const a=this._updateHelpers(),u=[];for(let n=0;n<this._documents.length;n++){const s=this._documents[n];if(this._operators.matches(s,t)&&(u.push({index:n,previous:s._data,next:c(s._data,e,a)}),!i))break}if(!u.length&&n.upsert){const n=c(this._upsertBase(t),e,a),s=this._insertDocument(n);return this._emitChange("insert",{ids:[s],documents:[this._copy(this._documents.at(-1)._data)]}),{matchedCount:0,modifiedCount:0,upsertedId:s}}const h=u.filter((t=>JSON.stringify(t.previous)!==JSON.stringify(t.next)));for(const t of h)this._documents[t.index]=new s(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 i=this._documents.findIndex((e=>this._operators.matches(e,t)));if(-1===i){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[i]._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},u=JSON.stringify(a)!==JSON.stringify(c);return u&&(this._documents[i]=new s(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 s=0;for(let i=0;i<this._documents.length;i++){const a=this._documents[i];!e&&n.length||!this._operators.matches(a,t)?this._documents[s++]=a:n.push(a._data)}if(this._documents.length=s,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,"./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 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(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 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":6,"./operators.js":7}],9:[function(t,e,n){const s=["$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 s=t;for(const t of e.slice(0,-1)){if(!(t in s)){if(!n)return null;s[t]={}}if(null===s[t]||"object"!=typeof s[t])throw new Error(`Cannot create the field "${e.join(".")}": "${t}" is not an object`);s=s[t]}return s}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(!s.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 s=n.copy(t);for(const[t,i]of Object.entries(e.$set??{})){const e=splitPath(t);if(null!==i&&"object"==typeof i&&!n.validateKeys(Array.isArray(i)?{value:i}:i))throw new Error("Invalid document keys");parentOf(s,e,!0)[e.at(-1)]=n.copy(i)}for(const t of Object.keys(e.$unset??{})){const e=splitPath(t),n=parentOf(s,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 i=getPath(s,e);if(void 0!==i&&"number"!=typeof i)throw new Error(`Cannot $inc "${t}": it is not a number`);parentOf(s,e,!0)[e.at(-1)]=(i??0)+n}for(const[t,i]of Object.entries(e.$push??{})){const e=splitPath(t),a=getPath(s,e);if(void 0!==a&&!Array.isArray(a))throw new Error(`Cannot $push to "${t}": it is not an array`);const c=isPlainObject(i)&&Array.isArray(i.$each)?i.$each:[i];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(s,e,!0)[e.at(-1)]=[...a??[],...c.map(n.copy)]}for(const[t,i]of Object.entries(e.$pull??{})){const e=splitPath(t),a=getPath(s,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(i)||c.isOperatorObject(i)?c.matchesCondition(t,i):isPlainObject(i)?isPlainObject(t)&&c.matches(t,i):t===i;parentOf(s,e,!1)[e.at(-1)]=a.filter((t=>!matches(t)))}return s},validateUpdate:validateUpdate,UPDATE_OPERATORS:s}},{}],10:[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}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}}e.exports=sorla,"undefined"!=typeof window&&(window.sorla=sorla)},{"./dbs/database.js":2}],11:[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}}},{}]},{},[10]);
|
|
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
|
@@ -27,6 +27,51 @@ export type Query = {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
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
|
+
|
|
30
75
|
export type Projection = Record<string, 0 | 1>;
|
|
31
76
|
|
|
32
77
|
export type UpdateFilter = {
|
|
@@ -118,6 +163,11 @@ export declare class Collection {
|
|
|
118
163
|
on(event: ChangeType | 'change', listener: (event: ChangeEvent) => void): () => void;
|
|
119
164
|
once(event: ChangeType | 'change', listener: (event: ChangeEvent) => void): () => void;
|
|
120
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;
|
|
121
171
|
}
|
|
122
172
|
|
|
123
173
|
export interface DatabaseStats {
|
|
@@ -128,7 +178,8 @@ export interface DatabaseStats {
|
|
|
128
178
|
|
|
129
179
|
export declare class Database {
|
|
130
180
|
readonly name: string;
|
|
131
|
-
|
|
181
|
+
/** Inserts, updates and replaces must satisfy the schema; a ValidationError is thrown otherwise. */
|
|
182
|
+
createCollection(name: string, validationSchema?: JSONSchema | SchemaFunction | null): void;
|
|
132
183
|
countCollections(): number;
|
|
133
184
|
listCollections(): string[];
|
|
134
185
|
/** Any collection by name, also when the name shadows a method. */
|
|
@@ -139,6 +190,7 @@ export declare class Database {
|
|
|
139
190
|
}
|
|
140
191
|
|
|
141
192
|
declare class sorla {
|
|
193
|
+
static ValidationError: typeof ValidationError;
|
|
142
194
|
constructor();
|
|
143
195
|
/** The selected database. Collections are properties of it: sorla.db.people */
|
|
144
196
|
readonly db: Database & Record<string, Collection>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sorla-db",
|
|
3
|
-
"version": "1.
|
|
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",
|
|
@@ -2,6 +2,7 @@ const documents = require('../documents/document.js');
|
|
|
2
2
|
const operatorsCheck = require('./operators.js');
|
|
3
3
|
const emitter = require('../events.js');
|
|
4
4
|
const { applyUpdate } = require('./update.js');
|
|
5
|
+
const { compileSchema, ValidationError } = require('../schema/schema.js');
|
|
5
6
|
|
|
6
7
|
class manipulation extends emitter {
|
|
7
8
|
constructor(validationSchema = null) {
|
|
@@ -24,34 +25,72 @@ class manipulation extends emitter {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
*
|
|
28
|
+
* Sets the validation schema of the collection (JSON Schema subset or a function).
|
|
29
|
+
* Documents inserted, updated or replaced afterwards must satisfy it; existing ones are not checked.
|
|
28
30
|
* @author Anderson Arruda < anderson@sysborg.com.br >
|
|
29
|
-
* @version 1.
|
|
30
|
-
* @param validationSchema
|
|
31
|
+
* @version 1.4.0
|
|
32
|
+
* @param object|function|null validationSchema
|
|
31
33
|
* @return void
|
|
32
34
|
**/
|
|
33
35
|
set validationSchema(validationSchema)
|
|
34
36
|
{
|
|
35
|
-
if(
|
|
36
|
-
return;
|
|
37
|
-
|
|
38
|
-
if(['object', 'function'].indexOf(typeof schemaValidation) == -1)
|
|
37
|
+
if(validationSchema === null || typeof validationSchema === 'undefined')
|
|
39
38
|
{
|
|
40
|
-
|
|
39
|
+
this._schema = null;
|
|
40
|
+
return;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
if(['object', 'function'].indexOf(typeof validationSchema) === -1 || Array.isArray(validationSchema))
|
|
44
|
+
throw new Error('The validation schema must be an object (JSON Schema) or a function');
|
|
45
|
+
|
|
46
|
+
this._schema = compileSchema(validationSchema);
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
|
-
*
|
|
50
|
+
* The schema given to the collection, or null
|
|
51
|
+
* @return object|function|null
|
|
52
|
+
*/
|
|
53
|
+
get validationSchema()
|
|
54
|
+
{
|
|
55
|
+
return this._schema ? this._schema.source : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param object|function|null schema
|
|
60
|
+
* @return this
|
|
61
|
+
*/
|
|
62
|
+
setSchema(schema)
|
|
63
|
+
{
|
|
64
|
+
this.validationSchema = schema;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getSchema()
|
|
69
|
+
{
|
|
70
|
+
return this.validationSchema;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Checks a document against the schema without storing it (e.g. to validate a form)
|
|
75
|
+
* @param object document
|
|
76
|
+
* @return object {valid, errors: [{path, rule, message}]}
|
|
77
|
+
*/
|
|
78
|
+
validate(document)
|
|
79
|
+
{
|
|
80
|
+
const errors = this._schema ? this._schema.validate(document) : [];
|
|
81
|
+
return { valid: errors.length === 0, errors };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Throws a ValidationError when the data does not satisfy the schema
|
|
48
86
|
* @param object data
|
|
49
|
-
* @return
|
|
87
|
+
* @return void
|
|
50
88
|
*/
|
|
51
89
|
_validate(data)
|
|
52
90
|
{
|
|
53
|
-
|
|
54
|
-
|
|
91
|
+
const errors = this._schema ? this._schema.validate(data) : [];
|
|
92
|
+
if(errors.length)
|
|
93
|
+
throw new ValidationError(errors);
|
|
55
94
|
}
|
|
56
95
|
|
|
57
96
|
/**
|
|
@@ -143,6 +182,17 @@ class manipulation extends emitter {
|
|
|
143
182
|
* @return mixed the _id
|
|
144
183
|
*/
|
|
145
184
|
_insertDocument(document)
|
|
185
|
+
{
|
|
186
|
+
return this._store(this._prepare(document));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Copy of a document ready to be stored: _id, schema defaults, keys and schema checked
|
|
191
|
+
* @param object document
|
|
192
|
+
* @param Set reserved ids taken by the same batch
|
|
193
|
+
* @return object
|
|
194
|
+
*/
|
|
195
|
+
_prepare(document, reserved = null)
|
|
146
196
|
{
|
|
147
197
|
if(!this._isDocument(document))
|
|
148
198
|
throw new Error('Invalid document type');
|
|
@@ -150,21 +200,36 @@ class manipulation extends emitter {
|
|
|
150
200
|
if(!this._validateKeys(document))
|
|
151
201
|
throw new Error('Invalid document keys');
|
|
152
202
|
|
|
203
|
+
const taken = id => this._ids().has(id) || (reserved !== null && reserved.has(id));
|
|
153
204
|
const data = this._copy(document);
|
|
154
205
|
if(typeof data._id === 'undefined')
|
|
155
206
|
{
|
|
156
207
|
do {
|
|
157
208
|
data._id = this._generateKey();
|
|
158
|
-
} while(
|
|
209
|
+
} while(taken(data._id));
|
|
159
210
|
}
|
|
160
|
-
else if(
|
|
211
|
+
else if(taken(data._id))
|
|
161
212
|
{
|
|
162
213
|
throw new Error(`Duplicate _id "${data._id}"`);
|
|
163
214
|
}
|
|
164
215
|
|
|
165
|
-
if(
|
|
166
|
-
|
|
216
|
+
if(this._schema)
|
|
217
|
+
{
|
|
218
|
+
this._schema.applyDefaults(data, value => this._copy(value));
|
|
219
|
+
if(!this._validateKeys(data))
|
|
220
|
+
throw new Error('Invalid document keys');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this._validate(data);
|
|
224
|
+
return data;
|
|
225
|
+
}
|
|
167
226
|
|
|
227
|
+
/**
|
|
228
|
+
* @param object data prepared document
|
|
229
|
+
* @return mixed the _id
|
|
230
|
+
*/
|
|
231
|
+
_store(data)
|
|
232
|
+
{
|
|
168
233
|
this._documents.push(new documents(data));
|
|
169
234
|
this._ids().add(data._id);
|
|
170
235
|
return data._id;
|
|
@@ -183,23 +248,16 @@ class manipulation extends emitter {
|
|
|
183
248
|
if(!Array.isArray(documents) || !documents.every(document => this._isDocument(document)))
|
|
184
249
|
throw new Error('Documents must be an array of objects');
|
|
185
250
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
continue;
|
|
194
|
-
|
|
195
|
-
if(given.has(document._id) || this._ids().has(document._id))
|
|
196
|
-
throw new Error(`Duplicate _id "${document._id}"`);
|
|
197
|
-
|
|
198
|
-
given.add(document._id);
|
|
199
|
-
}
|
|
251
|
+
// prepare (and validate) the whole batch before storing anything
|
|
252
|
+
const reserved = new Set();
|
|
253
|
+
const prepared = documents.map(document => {
|
|
254
|
+
const data = this._prepare(document, reserved);
|
|
255
|
+
reserved.add(data._id);
|
|
256
|
+
return data;
|
|
257
|
+
});
|
|
200
258
|
|
|
201
259
|
const start = this._documents.length;
|
|
202
|
-
const ids =
|
|
260
|
+
const ids = prepared.map(data => this._store(data));
|
|
203
261
|
this._emitChange('insert', { ids, documents: this._documents.slice(start).map(doc => this._copy(doc._data)) });
|
|
204
262
|
|
|
205
263
|
if(typeof callback === 'function')
|
|
@@ -269,7 +327,9 @@ class manipulation extends emitter {
|
|
|
269
327
|
if(!this._operators.matches(doc, filter))
|
|
270
328
|
continue;
|
|
271
329
|
|
|
272
|
-
|
|
330
|
+
const next = applyUpdate(doc._data, update, helpers);
|
|
331
|
+
this._validate(next);
|
|
332
|
+
changes.push({ index: i, previous: doc._data, next });
|
|
273
333
|
if(!many) break;
|
|
274
334
|
}
|
|
275
335
|
|
|
@@ -357,6 +417,7 @@ class manipulation extends emitter {
|
|
|
357
417
|
throw new Error('The _id field cannot be changed');
|
|
358
418
|
|
|
359
419
|
const next = { ...this._copy(replacement), _id: previous._id };
|
|
420
|
+
this._validate(next);
|
|
360
421
|
const modified = JSON.stringify(previous) !== JSON.stringify(next);
|
|
361
422
|
if(modified)
|
|
362
423
|
{
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document validation with a subset of JSON Schema.
|
|
3
|
+
*
|
|
4
|
+
* {
|
|
5
|
+
* type: 'object',
|
|
6
|
+
* required: ['name', 'email'],
|
|
7
|
+
* additionalProperties: false,
|
|
8
|
+
* properties: {
|
|
9
|
+
* name: { type: 'string', minLength: 2, maxLength: 80 },
|
|
10
|
+
* email: { type: 'string', format: 'email' },
|
|
11
|
+
* age: { type: ['integer', 'null'], minimum: 0 },
|
|
12
|
+
* role: { enum: ['admin', 'user'], default: 'user' },
|
|
13
|
+
* tags: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
|
14
|
+
* address: { type: 'object', properties: { zip: { type: 'string', pattern: '^\\d{5}-\\d{3}$' } } }
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Extension: type 'date' accepts Date objects (like MongoDB's bsonType 'date').
|
|
19
|
+
* A function schema is also accepted: doc => true | 'message' | ['message', ...] | false.
|
|
20
|
+
*/
|
|
21
|
+
const TYPES = ['string', 'number', 'integer', 'boolean', 'object', 'array', 'null', 'date'];
|
|
22
|
+
const FORMATS = ['email', 'date', 'date-time', 'uri'];
|
|
23
|
+
const KEYWORDS = new Set([
|
|
24
|
+
'type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const', 'default',
|
|
25
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'pattern',
|
|
26
|
+
'format', 'minItems', 'maxItems', 'uniqueItems', 'title', 'description', '$schema', '$id', 'examples'
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
class ValidationError extends Error
|
|
30
|
+
{
|
|
31
|
+
/**
|
|
32
|
+
* @param array errors [{path, rule, message}]
|
|
33
|
+
*/
|
|
34
|
+
constructor(errors)
|
|
35
|
+
{
|
|
36
|
+
const first = errors[0];
|
|
37
|
+
super(errors.length === 1
|
|
38
|
+
? `Invalid document: ${first.message}`
|
|
39
|
+
: `Invalid document: ${first.message} (and ${errors.length - 1} more)`);
|
|
40
|
+
this.name = 'ValidationError';
|
|
41
|
+
this.errors = errors;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isPlainObject = value => Object.prototype.toString.call(value) === '[object Object]';
|
|
46
|
+
const isDate = value => Object.prototype.toString.call(value) === '[object Date]';
|
|
47
|
+
|
|
48
|
+
function typeOf(value)
|
|
49
|
+
{
|
|
50
|
+
if(value === null) return 'null';
|
|
51
|
+
if(Array.isArray(value)) return 'array';
|
|
52
|
+
if(isDate(value)) return 'date';
|
|
53
|
+
if(typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
|
|
54
|
+
if(isPlainObject(value)) return 'object';
|
|
55
|
+
return typeof value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchesType(value, type)
|
|
59
|
+
{
|
|
60
|
+
const actual = typeOf(value);
|
|
61
|
+
if(type === 'number') return actual === 'number' || actual === 'integer';
|
|
62
|
+
return actual === type;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const FORMAT_TESTS = {
|
|
66
|
+
email: value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
|
67
|
+
date: value => {
|
|
68
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
69
|
+
if(!match) return false;
|
|
70
|
+
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
|
71
|
+
return date.getUTCMonth() === +match[2] - 1 && date.getUTCDate() === +match[3];
|
|
72
|
+
},
|
|
73
|
+
'date-time': value => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.test(value) && !isNaN(Date.parse(value)),
|
|
74
|
+
uri: value => {
|
|
75
|
+
try {
|
|
76
|
+
return Boolean(new URL(value).protocol);
|
|
77
|
+
} catch(error) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Checks the schema itself, so typos fail when the schema is set instead of being ignored
|
|
85
|
+
* @param object schema
|
|
86
|
+
* @param string path
|
|
87
|
+
* @return void
|
|
88
|
+
*/
|
|
89
|
+
function checkSchema(schema, path = 'schema')
|
|
90
|
+
{
|
|
91
|
+
if(!isPlainObject(schema))
|
|
92
|
+
throw new Error(`${path} must be an object`);
|
|
93
|
+
|
|
94
|
+
for(const key of Object.keys(schema))
|
|
95
|
+
{
|
|
96
|
+
if(!KEYWORDS.has(key))
|
|
97
|
+
throw new Error(`${path}: unknown keyword "${key}"`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const types = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
101
|
+
for(const type of types)
|
|
102
|
+
{
|
|
103
|
+
if(!TYPES.includes(type))
|
|
104
|
+
throw new Error(`${path}: unknown type "${type}" (use ${TYPES.join(', ')})`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if(schema.format !== undefined && !FORMATS.includes(schema.format))
|
|
108
|
+
throw new Error(`${path}: unknown format "${schema.format}" (use ${FORMATS.join(', ')})`);
|
|
109
|
+
|
|
110
|
+
if(schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some(name => typeof name !== 'string')))
|
|
111
|
+
throw new Error(`${path}: required must be an array of field names`);
|
|
112
|
+
|
|
113
|
+
if(schema.enum !== undefined && !Array.isArray(schema.enum))
|
|
114
|
+
throw new Error(`${path}: enum must be an array`);
|
|
115
|
+
|
|
116
|
+
if(schema.pattern !== undefined)
|
|
117
|
+
{
|
|
118
|
+
try {
|
|
119
|
+
new RegExp(schema.pattern);
|
|
120
|
+
} catch(error) {
|
|
121
|
+
throw new Error(`${path}: invalid pattern (${error.message})`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for(const key of ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', 'minItems', 'maxItems'])
|
|
126
|
+
{
|
|
127
|
+
if(schema[key] !== undefined && (typeof schema[key] !== 'number' || !Number.isFinite(schema[key])))
|
|
128
|
+
throw new Error(`${path}: ${key} must be a number`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if(schema.properties !== undefined)
|
|
132
|
+
{
|
|
133
|
+
if(!isPlainObject(schema.properties))
|
|
134
|
+
throw new Error(`${path}: properties must be an object`);
|
|
135
|
+
|
|
136
|
+
for(const [name, child] of Object.entries(schema.properties))
|
|
137
|
+
checkSchema(child, `${path}.properties.${name}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if(schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean')
|
|
141
|
+
checkSchema(schema.additionalProperties, `${path}.additionalProperties`);
|
|
142
|
+
|
|
143
|
+
if(schema.items !== undefined)
|
|
144
|
+
checkSchema(schema.items, `${path}.items`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const describe = value => {
|
|
148
|
+
if(typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
|
|
149
|
+
if(isDate(value)) return value.toISOString();
|
|
150
|
+
return JSON.stringify(value);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const same = (a, b) => {
|
|
154
|
+
if(isDate(a) && isDate(b)) return a.getTime() === b.getTime();
|
|
155
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Validates a value against a schema
|
|
160
|
+
* @param mixed value
|
|
161
|
+
* @param object schema
|
|
162
|
+
* @param string path field path for error messages ('' for the document)
|
|
163
|
+
* @param array errors collected errors
|
|
164
|
+
* @return array errors
|
|
165
|
+
*/
|
|
166
|
+
function validateValue(value, schema, path = '', errors = [])
|
|
167
|
+
{
|
|
168
|
+
const label = path || 'document';
|
|
169
|
+
const add = (rule, message) => errors.push({ path, rule, message: `${label} ${message}` });
|
|
170
|
+
|
|
171
|
+
if(schema.type !== undefined)
|
|
172
|
+
{
|
|
173
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
174
|
+
if(!types.some(type => matchesType(value, type)))
|
|
175
|
+
{
|
|
176
|
+
add('type', `must be ${types.join(' or ')} (got ${typeOf(value)})`);
|
|
177
|
+
return errors;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if(schema.enum !== undefined && !schema.enum.some(option => same(option, value)))
|
|
182
|
+
add('enum', `must be one of ${schema.enum.map(describe).join(', ')}`);
|
|
183
|
+
|
|
184
|
+
if(schema.const !== undefined && !same(schema.const, value))
|
|
185
|
+
add('const', `must be ${describe(schema.const)}`);
|
|
186
|
+
|
|
187
|
+
if(typeof value === 'number')
|
|
188
|
+
{
|
|
189
|
+
if(schema.minimum !== undefined && value < schema.minimum) add('minimum', `must be >= ${schema.minimum}`);
|
|
190
|
+
if(schema.maximum !== undefined && value > schema.maximum) add('maximum', `must be <= ${schema.maximum}`);
|
|
191
|
+
if(schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) add('exclusiveMinimum', `must be > ${schema.exclusiveMinimum}`);
|
|
192
|
+
if(schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) add('exclusiveMaximum', `must be < ${schema.exclusiveMaximum}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if(typeof value === 'string')
|
|
196
|
+
{
|
|
197
|
+
// count characters, not UTF-16 units, so emoji and accents count once
|
|
198
|
+
const length = [...value].length;
|
|
199
|
+
if(schema.minLength !== undefined && length < schema.minLength) add('minLength', `must have at least ${schema.minLength} characters`);
|
|
200
|
+
if(schema.maxLength !== undefined && length > schema.maxLength) add('maxLength', `must have at most ${schema.maxLength} characters`);
|
|
201
|
+
if(schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(value)) add('pattern', `must match ${schema.pattern}`);
|
|
202
|
+
if(schema.format !== undefined && !FORMAT_TESTS[schema.format](value)) add('format', `must be a valid ${schema.format}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if(Array.isArray(value))
|
|
206
|
+
{
|
|
207
|
+
if(schema.minItems !== undefined && value.length < schema.minItems) add('minItems', `must have at least ${schema.minItems} items`);
|
|
208
|
+
if(schema.maxItems !== undefined && value.length > schema.maxItems) add('maxItems', `must have at most ${schema.maxItems} items`);
|
|
209
|
+
if(schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) add('uniqueItems', 'must not have repeated items');
|
|
210
|
+
if(schema.items !== undefined)
|
|
211
|
+
value.forEach((item, index) => validateValue(item, schema.items, `${path}[${index}]`, errors));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if(isPlainObject(value))
|
|
215
|
+
{
|
|
216
|
+
const properties = schema.properties ?? {};
|
|
217
|
+
const child = name => path ? `${path}.${name}` : name;
|
|
218
|
+
|
|
219
|
+
for(const name of schema.required ?? [])
|
|
220
|
+
{
|
|
221
|
+
if(value[name] === undefined)
|
|
222
|
+
errors.push({ path: child(name), rule: 'required', message: `${child(name)} is required` });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for(const [name, fieldValue] of Object.entries(value))
|
|
226
|
+
{
|
|
227
|
+
if(fieldValue === undefined)
|
|
228
|
+
continue;
|
|
229
|
+
|
|
230
|
+
if(properties[name] !== undefined)
|
|
231
|
+
{
|
|
232
|
+
validateValue(fieldValue, properties[name], child(name), errors);
|
|
233
|
+
}
|
|
234
|
+
else if(!(path === '' && name === '_id'))
|
|
235
|
+
{
|
|
236
|
+
// _id is always allowed on documents
|
|
237
|
+
if(schema.additionalProperties === false)
|
|
238
|
+
errors.push({ path: child(name), rule: 'additionalProperties', message: `${child(name)} is not allowed` });
|
|
239
|
+
else if(isPlainObject(schema.additionalProperties))
|
|
240
|
+
validateValue(fieldValue, schema.additionalProperties, child(name), errors);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return errors;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Fills missing fields that declare a default (recursively for objects present in the document)
|
|
250
|
+
* @param mixed value
|
|
251
|
+
* @param object schema
|
|
252
|
+
* @param function copy
|
|
253
|
+
* @return void
|
|
254
|
+
*/
|
|
255
|
+
function applyDefaults(value, schema, copy)
|
|
256
|
+
{
|
|
257
|
+
if(!isPlainObject(value) || !isPlainObject(schema.properties))
|
|
258
|
+
return;
|
|
259
|
+
|
|
260
|
+
for(const [name, child] of Object.entries(schema.properties))
|
|
261
|
+
{
|
|
262
|
+
if(value[name] === undefined && child.default !== undefined)
|
|
263
|
+
value[name] = copy(child.default);
|
|
264
|
+
|
|
265
|
+
if(value[name] !== undefined)
|
|
266
|
+
applyDefaults(value[name], child, copy);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Compiles a schema (JSON Schema subset or a function) into a validator
|
|
272
|
+
* @param object|function schema
|
|
273
|
+
* @return object {validate(doc): errors, applyDefaults(doc, copy), source}
|
|
274
|
+
*/
|
|
275
|
+
function compileSchema(schema)
|
|
276
|
+
{
|
|
277
|
+
if(typeof schema === 'function')
|
|
278
|
+
{
|
|
279
|
+
return {
|
|
280
|
+
source: schema,
|
|
281
|
+
applyDefaults: () => {},
|
|
282
|
+
validate: doc => {
|
|
283
|
+
const result = schema(doc);
|
|
284
|
+
if(result === true || result === undefined || result === null) return [];
|
|
285
|
+
if(result === false) return [{ path: '', rule: 'custom', message: 'document is invalid' }];
|
|
286
|
+
const messages = Array.isArray(result) ? result : [result];
|
|
287
|
+
return messages.map(message => typeof message === 'string'
|
|
288
|
+
? { path: '', rule: 'custom', message }
|
|
289
|
+
: { path: '', rule: 'custom', ...message });
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const root = { type: 'object', ...schema };
|
|
295
|
+
checkSchema(root);
|
|
296
|
+
if(root.type !== 'object')
|
|
297
|
+
throw new Error('schema: a document schema must be of type "object"');
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
source: schema,
|
|
301
|
+
validate: doc => validateValue(doc, root),
|
|
302
|
+
applyDefaults: (doc, copy) => applyDefaults(doc, root, copy)
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = { compileSchema, ValidationError, validateValue, checkSchema };
|
package/src/sorla.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const database = require('./dbs/database.js');
|
|
2
|
+
const { ValidationError } = require('./schema/schema.js');
|
|
2
3
|
|
|
3
4
|
class sorla{
|
|
4
5
|
/**
|
|
@@ -143,6 +144,9 @@ class sorla{
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
// thrown when a document does not satisfy the collection schema: error.errors = [{path, rule, message}]
|
|
148
|
+
sorla.ValidationError = ValidationError;
|
|
149
|
+
|
|
146
150
|
module.exports = sorla;
|
|
147
151
|
if(typeof window !== 'undefined')
|
|
148
152
|
{
|