beanbagdb 0.5.50 → 0.5.52

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/src/index.js CHANGED
@@ -1,132 +1,316 @@
1
1
  import * as sys_sch from "./system_schema.js";
2
- // import { version } from "../package.json" assert {type :"json"};
3
2
  /**
4
- * This the core class. it is not very useful in itself but can be used to generate a sub class for a specific database for eg CouchDB.
5
- * It takes a db_instance argument, which , this class relies on perform CRUD operations on the data.
6
- * Why have a "dumb" class ? : So that the core functionalities remains in a single place and the multiple Databases can be supported.
3
+ * The core BeanBagDB class abstracts the database logic making it adaptable to both frontend and backend application. It is designed to be independent of any specific database allowing for integration with many databases.
4
+ *
5
+ * **Initialization** : Upon initializing the `BeanBagDB` object, the user must pass a JSON object with essential parameters such as the encryption key. Access to the database is provided through the "api" object which should include asynchronous methods that handle basic CRUD operations and other utility functions.
6
+ *
7
+ * This class can serve a foundation for building database specific `BeanBagDb` classes.
8
+ *
9
+ * **Method types**
10
+
11
+ * The main methods allow users to interact with the database through the BeanBagDB class. All methods follow the snake_case naming convention (lowercase letters separated by underscores).
12
+ *
13
+ * The types of methods include:
14
+ * - Setup methods: These methods are used for setting up the system, such as '{@link ready}' and '{@link initialize}'.
15
+ * - CRUD methods: These methods handle basic database operations like '{@link create}', '{@link read}', '{@link update}', and '{@link delete}'.
16
+ * - Search methods: Like '{@link search}' to find multiple documents based on criteria. (Note: read fetches a single document, while search fetches multiple documents matching a criteria).
17
+ * - Plugin methods: These methods manage loading and using plugins (see {@tutorial plugins}).
18
+ * - Utility methods: These methods don't directly interact with the database but serve specific purposes, such as returning the current UTC timestamp. These methods are prefixed with util_.
19
+ *
20
+ * For more details, see the {@tutorial getting-started} tutorial.
21
+ * The class also includes internal methods intended for use within the class. These methods are not intended for external access and are prefixed with an underscore (they are not visible in the documentation, but you can check the source code).
7
22
  */
8
- class BeanBagDB {
23
+ export class BeanBagDB {
9
24
  /**
10
- * @param {object} db_instance - Database object
11
- * db_instance object contains 3 main keys :
12
- * - `name` : the name of the local database
13
- * - `encryption_key`: this is required for encrypting documents
14
- * - `api` : this is an object that must contain database specific functions. This includes : `insert(doc)`: takes a doc and runs the db insertion function, `update(updated_doc)` : gets the updated document and updates it in the DB, `search(query)`: takes a query to fetch data from the DB (assuming array of JSON is returned ), `get(id)`: takes a document id and returns its content, `createIndex(filter)`: to create an index in the database based on a filter
15
- * - `utils` : this includes `encrypt`, `decrypt`
25
+ * Initializes the BeanBagDB instance.
26
+ *
27
+ * @param {object} db_instance - Database configuration object.
28
+ * @param {string} db_instance.name - The name of the local database.
29
+ * @param {string} db_instance.encryption_key - A key for encrypting documents (minimum 20 characters).
30
+ * @param {object} db_instance.api - The API object containing database-specific CRUD operations.
31
+ * @param {function} db_instance.api.insert - Inserts a document into the database.
32
+ * @param {function} db_instance.api.update - Updates an existing document in the database.
33
+ * @param {function} db_instance.api.delete - Deletes a document from the database.
34
+ * @param {function} db_instance.api.search - Searches for documents based on a query (returns an array of JSON).
35
+ * @param {function} db_instance.api.get - Retrieves a document by its ID.
36
+ * @param {function} db_instance.api.createIndex - Creates an index in the database based on a filter.
37
+ * @param {object} db_instance.utils - Utility functions for encryption and other operations.
38
+ * @param {function} db_instance.utils.encrypt - Encrypts a document.
39
+ * @param {function} db_instance.utils.decrypt - Decrypts a document.
40
+ * @param {function} db_instance.utils.ping - Checks the database connection.
41
+ * @param {function} db_instance.utils.validate_schema - Validates the database schema.
16
42
  */
17
43
  constructor(db_instance) {
18
- // data validation checks
19
- this._check_required_fields(["name", "encryption_key", "api", "utils"],db_instance)
20
- this._check_required_fields(["insert", "update", "delete", "search","get","createIndex"],db_instance.api)
21
- this._check_required_fields(["encrypt", "decrypt","ping","validate_schema"],db_instance.utils)
44
+ this.util_check_required_fields(["name", "encryption_key", "api", "utils", "db_name"],db_instance)
45
+ this.util_check_required_fields(["insert", "update", "delete", "search", "get", "createIndex"],db_instance.api)
46
+ this.util_check_required_fields(["encrypt", "decrypt", "ping", "validate_schema"],db_instance.utils)
22
47
 
23
- if(db_instance.encryption_key.length>20){throw new Error("encryption_key must have at least 20 letters")}
24
- // db name should not be blank,
48
+ if (db_instance.encryption_key.length < 20) {
49
+ throw new ValidationError([{ message: BeanBagDB.error_codes.key_short }]);
50
+ }
25
51
 
26
- this.name = db_instance.name;
27
52
  this.encryption_key = db_instance.encryption_key;
28
-
53
+ this.db_name = db_instance.db_name; // couchdb,pouchdb etc...
29
54
  this.db_api = db_instance.api;
30
55
  this.utils = db_instance.utils;
31
-
32
- this._version = "0.5.0"
33
- this.ready_check = { initialized: false, latest: false };
56
+ this.meta = {
57
+ database_name: db_instance.name,
58
+ backend_database: this.db_name,
59
+ beanbagdb_version_db: null,
60
+ };
61
+ this._version = this._get_current_version();
62
+ this.active = false;
63
+ this.plugins = {};
34
64
  console.log("Run ready() now");
65
+ }
66
+
67
+ /**
68
+ * Static property containing predefined error codes for common errors.
69
+ * These error codes can be used throughout the class to handle specific exceptions.
70
+ *
71
+ * @property {Object} error_codes - An object with key-value pairs representing error codes and their messages.
72
+ * @property {string} error_codes.key_short - The encryption key must contain at least 20 characters.
73
+ * @property {string} error_codes.not_active - The database is not ready. Run the `ready()` method first.
74
+ * @property {string} error_codes.schema_not_found - No schema found for the specified name.
75
+ * @property {string} error_codes.doc_not_found - No document found for the provided search criteria.
76
+ */
77
+ static error_codes = {
78
+ key_short: "The encryption key must at least contain 20 characters",
79
+ not_active: "Database is not ready. Run ready() first",
80
+ schema_not_found: "Schema not found",
81
+ doc_not_found: "Document with selected criteria does not exists",
82
+ };
83
+
84
+ ////////////////////////////////////////////////////////////////////////
85
+ //////////////////// Setup methods /////////////////////////////////////
86
+ ////////////////////////////////////////////////////////////////////////
87
+
88
+ /**
89
+ * Database object metadata
90
+ * @typedef {Object} DBMetaData
91
+ * @property {string} database_name - The name of the local database.
92
+ * @property {string} backend_database - The type of backend database (e.g., CouchDB, PouchDB).
93
+ * @property {number} beanbagdb_version_db - The version of the BeanBagDB in the database.
94
+ * @property {number} beanbagdb_version_code - The current version code of the BeanBagDB.
95
+ * @property {boolean} ready_to_use - Indicates whether the database is ready to be used (active state).
96
+ */
35
97
 
36
- this.plugins = {}
98
+ /**
99
+ * Retrieves metadata for the current database object.
100
+ * @return {DBMetaData} An object containing system metadata.
101
+ * @todo Include additional metadata: document count, schema count, records for each schema, size of the database.
102
+ */
103
+ metadata() {
104
+ // returns system data
105
+ return {
106
+ ...this.meta,
107
+ beanbagdb_version_code: this._version,
108
+ ready_to_use: this.active,
109
+ };
37
110
  }
38
111
 
39
112
  /**
40
- * This is to check if the database is ready to be used. It it important to run this after the class is initialized.
113
+ * Checks if the database is ready to be used. It is important to run this method after the class is initialized.
114
+ *
115
+ * This method performs the following actions:
116
+ * - Pings the database.
117
+ * - Searches the database for the `system_settings.beanbagdb_version` document.
118
+ * - Sets the class state as active if the version matches the current BeanBagDB version.
119
+ * - If the version does not match, calls `initialize()` to set up the database to the latest version.
120
+ * @todo Code to ping the DB and throw Connection error if failed to connect
121
+ * @async
122
+ * @returns {Promise<void>} - Resolves when the database has been verified and initialized.
41
123
  */
42
124
  async ready() {
43
- console.log("Checking...");
44
- // @TODO : ping the database
45
- // this._version = await getPackageVersion()
46
- this.ready_check = await this._check_ready_to_use();
47
- if (this.ready_check.initialized) {
48
- console.log("Ready to use!");
125
+ // TODO Ping db
126
+ let version_search = await this.db_api.search({
127
+ selector: { schema: "system_settings", "data.name": "beanbagdb_version" },
128
+ });
129
+ if (version_search.docs.length > 0) {
130
+ let doc = version_search.docs[0];
131
+ this.active = doc["data"]["value"] == this._version;
132
+ this.meta.beanbagdb_version_db = doc["data"]["value"];
133
+ }
134
+ if (this.active) {
135
+ console.log("Ready");
136
+ } else {
137
+ await this.initialize();
49
138
  }
50
- }
51
-
52
- check_if_ready(){
53
- return this.ready_check.ready
54
139
  }
55
140
 
56
141
  /**
57
- * Initializes the database making it ready to be used. Typically, required to run after every time package is updated to a new version.
58
- * See the documentation on the architecture of the DB to understand what default schemas are required for a smooth functioning of the database
142
+ * Initializes the database with the required schemas.
143
+ *
144
+ * This method is responsible for:
145
+ * - Verifying the existence and latest version of the `schema_schema` document.
146
+ * - Upgrading or inserting a new system schema if the version is outdated or missing.
147
+ * - Logging initialization steps in the system logs.
148
+ * - Updating the database version if needed.
149
+ *
150
+ * This method is usually called automatically by '{@link ready}' if required but can be run manually if needed.
151
+ *
152
+ * @async
153
+ * @returns {Promise<void>} - Resolves when the initialization is complete.
154
+ * @throws {Error} - Throws an error if schema initialization fails.
59
155
  */
60
- async initialize_db() {
156
+ async initialize() {
157
+ // this works on its own but is usually called by ready automatically if required
158
+
159
+ // check for schema_scehma : if yes, check if latest and upgrade if required, if no create a new schema doc
160
+ let logs = ["init started"];
61
161
  try {
62
- if (this.ready_check.initialized == false) {
63
- // add the meta-schemas doc
162
+ let schema = await this.get_schema("schema");
163
+ if (schema["data"]["version"] != sys_sch.schema_schema.version) {
164
+ logs.push("old schema_schema v " + schema["data"]["version"]);
165
+ let full_doc = await this.db_api.get(schema["_id"]);
166
+ full_doc["data"] = { ...sys_sch.schema_schema };
167
+ full_doc["meta"]["updated_on"] = this.util_get_now_unix_timestamp();
168
+ await this.db_api.update(full_doc);
169
+ logs.push("new schema_schema v " + sys_sch.schema_schema.version);
170
+ }
171
+ } catch (error) {
172
+ console.log(error);
173
+ if (error instanceof DocNotFoundError) {
174
+ console.log("iiii")
175
+ //error.message == BeanBagDB.error_codes.schema_not_found) {
176
+ console.log("...adding new ");
177
+ // inserting new schema_schema doc
64
178
  let schema_schema_doc = this._get_blank_doc("schema");
65
179
  schema_schema_doc.data = sys_sch.schema_schema;
66
180
  await this.db_api.insert(schema_schema_doc);
67
- // add system schemas
68
- let keys = Object.keys(sys_sch.system_schemas);
69
- for (let index = 0; index < keys.length; index++) {
70
- const element = sys_sch.system_schemas[keys[index]];
71
- let schema_record = this._get_blank_schema_doc(
181
+ logs.push("init schema_schema v " + sys_sch.schema_schema.version);
182
+ }
183
+ }
184
+
185
+ let keys = Object.keys(sys_sch.system_schemas);
186
+ for (let index = 0; index < keys.length; index++) {
187
+ const schema_name = sys_sch.system_schemas[keys[index]]["name"];
188
+ const schema_data = sys_sch.system_schemas[keys[index]];
189
+ try {
190
+ // console.log(schema_name)
191
+ let schema1 = await this.get_schema(schema_name);
192
+ if (schema1["data"]["version"] != schema_data.version) {
193
+ logs.push("old " + schema_name + " v " + schema1["data"]["version"]);
194
+ let full_doc = await this.db_api.get(schema1["_id"]);
195
+ full_doc["data"] = { ...schema_data };
196
+ full_doc["meta"]["updated_on"] = this.util_get_now_unix_timestamp();
197
+ await this.db_api.update(full_doc);
198
+ logs.push("new " + schema_name + " v " + schema_data.version);
199
+ }
200
+ } catch (error) {
201
+ console.log(error);
202
+ if (error instanceof DocNotFoundError) {
203
+ // inserting new schema doc
204
+ let new_schema_doc = this._get_blank_schema_doc(
72
205
  "schema",
73
206
  sys_sch.schema_schema["schema"],
74
- element
207
+ schema_data
75
208
  );
76
- await this.db_api.insert(schema_record);
209
+ await this.db_api.insert(new_schema_doc);
210
+ logs.push("init " + schema_name + " v " + schema_data.version);
77
211
  }
78
- // create an index
79
- await this.db_api.createIndex({
80
- index: { fields: ["schema", "data", "meta"] },
81
- });
82
- console.log("Database Indexed.");
83
- // create the log doc
84
- const log_schema = sys_sch.system_schemas["logs"]["schema"];
85
- let log_doc = this._get_blank_schema_doc("system_logs", log_schema, {
86
- logs: [
87
- {
88
- message: `Database is initialized with version ${this._version}.`,
89
- on: this._get_now_unix_timestamp(),
90
- human_date: new Date().toLocaleString(),
91
- },
92
- ],
93
- });
94
- await this.db_api.insert(log_doc);
95
- // create the setting doc
96
- const setting_schema = sys_sch.system_schemas["settings"]["schema"];
97
- let setting_doc = this._get_blank_schema_doc(
98
- "system_settings",
99
- setting_schema,
100
- {
101
- name: "beanbagdb_version",
102
- value: this._version,
103
- user_editable: false,
104
- }
105
- );
106
- await this.db_api.insert(setting_doc);
107
- // finally update the flags
108
- this.ready_check.initialized = true;
109
- this.ready_check.latest = true;
110
- console.log("Database initialized");
111
- } else {
112
- console.log("Database already initialized");
113
- if (!this.ready_check.latest) {
114
- // update to latest schema
115
- this._update_system_schema();
212
+ }
213
+ }
214
+ // store the logs in the log_doc , generate it for the first time
215
+ // console.log(logs)
216
+ if (logs.length > 1) {
217
+ // version needs to be updated in the object as well as settings and must be logged
218
+ logs.push("Init done");
219
+
220
+ await this.save_setting_doc("system_logs", {
221
+ value: { text: logs.join(","), added: this.util_get_now_unix_timestamp() },
222
+ on_update_array: "append",
223
+ });
224
+ await this.save_setting_doc("beanbagdb_version", {
225
+ value: this._version,
226
+ });
227
+
228
+ this.meta.beanbagdb_version_db = this._version;
229
+ this.active = true;
230
+ } else {
231
+ // no new updates were done
232
+ console.log("Database already up to date");
233
+ }
234
+ }
235
+
236
+
237
+ /**
238
+ * Updates a setting document if it already exists in the database or creates a new document
239
+ * Inserts or updates a setting in the system settings schema.
240
+ *
241
+ * This method either:
242
+ * - Updates an existing document if the setting with the given `name` already exists in the database.
243
+ * - Inserts a new document if no matching setting is found.
244
+ *
245
+ * If the setting exists and the `value` is an array, the behavior depends on the `on_update_array` key:
246
+ * - `"append"`: Appends the new value to the existing array.
247
+ * - `"update"`: Replaces the current array with the new value.
248
+ *
249
+ * @async
250
+ * @param {string} name - The name of the setting to insert or update.
251
+ * @param {object} new_data - The new data to insert or update.
252
+ * @param {*} new_data.value - The value to insert or update.
253
+ * @param {string} [new_data.on_update_array] - Optional behavior for handling arrays, either "append" or "update".
254
+ * @param {object} [schema={}] - Optional schema to validate the data against (currently not implemented).
255
+ * @returns {Promise<object>} - The updated or newly inserted document.
256
+ * @throws {Error} - Throws an error if `new_data` or `new_data.value` is not provided, or if `on_update_array` is invalid.
257
+ */
258
+ async save_setting_doc(name, new_data, schema = {}) {
259
+ // TODO implement schema check
260
+ if (!new_data) {
261
+ throw new Error("No data provided");
262
+ }
263
+ if (!new_data.value) {
264
+ throw new Error("No value provided");
265
+ }
266
+
267
+ let doc_search = await this.db_api.search({
268
+ selector: { schema: "system_settings", "data.name": name },
269
+ });
270
+ if (doc_search.docs.length > 0) {
271
+ // doc already exists, check schema and update it : if it exists then it's value already exists and can be
272
+ let doc = { ...doc_search.docs[0] };
273
+ if (Array.isArray(doc.data.value)) {
274
+ let append_type = doc.data.on_update_array;
275
+ if (append_type == "append") {
276
+ doc["data"]["value"].push(new_data.value);
277
+ } else if (append_type == "update") {
278
+ doc["data"]["value"] = new_data.value;
116
279
  } else {
117
- console.log("Database already up to date");
280
+ throw new Error("Invalid on update array value");
118
281
  }
282
+ } else {
283
+ doc["data"]["value"] = new_data.value;
119
284
  }
120
- } catch (error) {
121
- console.log(error);
122
- throw error;
285
+ // finally update it
286
+ doc["meta"]["updated_on"] = this.util_get_now_unix_timestamp();
287
+ await this.db_api.update(doc);
288
+ return doc;
289
+ } else {
290
+ // doc does not exists, generate a new one
291
+ let new_val = { value: new_data.value };
292
+
293
+ if (new_data.on_update_array) {
294
+ // this indicates the provided value is initial value inside the array
295
+ new_val.value = [new_data.value];
296
+ new_val.on_update_array = new_data.on_update_array;
297
+ }
298
+ let new_doc = this._get_blank_doc("system_settings");
299
+ new_doc["data"] = {
300
+ name: name,
301
+ ...new_val,
302
+ };
303
+ let d = await this.db_api.insert(new_doc);
304
+ return d;
123
305
  }
124
306
  }
125
307
 
308
+
126
309
  /**
127
310
  * Adds indexes for all the schemas in the data base. This is important to make search faster. This must be done every time a new schema is introduced in the database
128
311
  */
129
312
  async update_indexes() {
313
+ this._check_ready_to_use();
130
314
  // @TODO check this. i don't the index created this way are actually useful in search.
131
315
  let all_schemas_docs = await this.db_api.search({
132
316
  selector: { schema: "schema" },
@@ -140,86 +324,243 @@ class BeanBagDB {
140
324
  await this.db_api.createIndex({ index: { fields: indexes } });
141
325
  }
142
326
 
327
+
328
+
329
+ ////////////////////////////////////////////////////////
330
+ /////////////// CRUD operations /////////////////////
331
+ ///////////////////////////////////////////////////////
332
+
143
333
  /**
144
- * Validates a data object against a provided JSON schema
145
- * It relies on the Ajv package to make the validation.
146
- * @param {Object} schema_obj - The JSON schema object to validate against
147
- * @param {Object} data_obj - The data object to validate
148
- * @throws {Error} If the data object does not conform to the schema
334
+ * Creates a document for the given schema into the database.
335
+ *
336
+ * This method validates the input data and schema before inserting a new document into the database.
337
+ *
338
+ * @async
339
+ * @param {string} schema - The schema name for the document, e.g., "contact".
340
+ * @param {object} data - The document data, e.g., { "name": "", "mobile": "", ... }.
341
+ * @param {object} [meta={}] - Optional metadata associated with the document.
342
+ * @param {object} [settings={}] - Optional settings that may affect document creation behavior.
343
+ * @returns {Promise<{id: string}>} - A promise that resolves with the newly inserted document's ID.
344
+ * @throws {Error} - Throws an error if insertion checks fail or if there is an issue with the database operation.
149
345
  */
150
- validate_data(schema_obj, data_obj) {
151
- const {valid,validate} = this.utils.validate_schema(schema_obj, data_obj)
152
- //const ajv = new Ajv({code: {esm: true}}) // options can be passed, e.g. {allErrors: true}
153
- //const validate = ajv.compile(schema_obj);
154
- //const valid = validate(data_obj);
155
- if (!valid) {
156
- console.log(validate.errors);
157
- throw new Error(validate.errors);
346
+ async create(schema, data, meta = {}, settings = {}) {
347
+ this._check_ready_to_use();
348
+ try {
349
+ let doc_obj = await this._insert_pre_checks(schema, data, settings);
350
+ let new_rec = await this.db_api.insert(doc_obj);
351
+ return { id: new_rec["id"] };
352
+ } catch (error) {
353
+ throw error;
158
354
  }
159
355
  }
160
356
 
161
- validate_schema_object(schema_doc){
162
- let errors = []
163
- if(!schema_doc["schema"]["type"]){
164
- errors.push("Schema must have the field schema.'type' which can only be 'object' ")
165
- }else{
166
- if(schema_doc["schema"]["type"]!="object"){
167
- errors.push("The schema.'type' value is invalid.Only 'object' allowed")
168
- }
357
+ /**
358
+ * Reads a document from the database based on the provided criteria.
359
+ *
360
+ * There are three valid ways to search for one document:
361
+ * 1. By `_id` (e.g., `{ "_id": "document_id" }`)
362
+ * 2. By `link` (e.g., `{ "link": "some_link" }`)
363
+ * 3. By schema's primary key (e.g., `{ "schema": "schema_name", "data": { "primary_key_1": "value", "primary_key_2": "value" }}`)
364
+ *
365
+ * If the document does not exist, an error will be thrown.
366
+ *
367
+ * @param {Object} criteria - The search criteria for the document.
368
+ * @param {string} [criteria._id] - The document ID for direct lookup.
369
+ * @param {string} [criteria.link] - A unique link identifier for the document.
370
+ * @param {string} [criteria.schema] - The schema name used when searching by primary keys.
371
+ * @param {Object} [criteria.data] - Data object containing the schema's primary keys for search.
372
+ *
373
+ * @param {boolean} [include_schema=false] - Whether to include the schema object in the returned result.
374
+ *
375
+ * @returns {Promise<Object>} - Returns an object with the document (`doc`) and optionally the schema (`schema`).
376
+ *
377
+ * @throws {DocNotFoundError} If no document is found for the given criteria.
378
+ * @throws {ValidationError} If invalid search criteria are provided.
379
+ */
380
+ async read(criteria, include_schema = false) {
381
+ // todo : decrypt doc
382
+ this._check_ready_to_use();
383
+ let obj = { doc: none };
384
+ if (criteria._id) {
385
+ let doc = await this.db_api.get(criteria._id);
386
+ obj.doc = doc;
387
+ } else if (criteria.link) {
388
+ let linkSearch = await this.db_api.search({selector: { "meta.link": criteria.link },});
389
+ if (linkSearch.docs.length == 0) {throw new DocNotFoundError(BeanBagDB.error_codes.doc_not_found);}
390
+ obj.doc = linkSearch.docs[0];
391
+ } else if (criteria.schema & criteria.data) {
392
+ let pkSearch = await this.db_api.search({selector: { "schema": criteria.schema, "data":criteria.data },});
393
+ if (pkSearch.docs.length == 0) {throw new DocNotFoundError(BeanBagDB.error_codes.doc_not_found);}
394
+ obj.doc = pkSearch.docs[0];
395
+ } else {
396
+ throw new ValidationError(`Invalid criteria to read a document. Valid ways : {"schema":"schema_name","data":{...primary key}} or {"_id":""} or {"link":""} `);
169
397
  }
170
- if(!schema_doc["schema"]["properties"]){
171
- errors.push("The schema.'properties' object does not exists")
172
- }else{
173
- if(typeof(schema_doc["schema"]["properties"])!="object"){
174
- errors.push("Invalid schema.properties. It must be an object and must have atleast one field inside.")
175
- }
176
- if(Object.keys(schema_doc["schema"]["properties"]).length==0){
177
- errors.push("You must define at least one property")
178
- }
398
+
399
+ if (include_schema) {
400
+ obj.schema = await this.get_schema(obj.doc.schema);
179
401
  }
402
+ return obj;
403
+ }
404
+
405
+
406
+ /**
407
+ * Updates the data and metadata of a document.
408
+ *
409
+ * **Frequently Asked Questions**:
410
+ *
411
+ * - **Which data fields can be edited?**
412
+ * - All fields except for the ones listed in the schema's `settings.non_editable_fields` can be edited. If this setting is blank, all fields are editable by default.
413
+ *
414
+ * - **Are primary key fields editable?**
415
+ * - Yes, but a validation check ensures that primary key policies are not violated before the update is applied.
416
+ *
417
+ *
418
+ * @param {Object} doc_search_criteria - The criteria used to search for the document (e.g., {"_id": "document_id"}, {"link": "some_link"}, {"schema": "schema_name", "data": {primary_key_fields}}).
419
+ * @param {String} rev_id - The document's revision ID (`_rev`) used for version control and conflict detection.
420
+ * @param {Object} updates - The updated values for the document, structured as `{data: {}, meta: {}}`. Only the fields to be updated need to be provided.
421
+ * @param {String} [update_source="api"] - Identifies the source of the update (default: "api").
422
+ * @param {Boolean} [save_conflict=true] - If `true`, conflicting updates will be saved separately in case of revision mismatches.
423
+ *
424
+ * **Behavior**:
425
+ * - Retrieves the document based on the provided search criteria.
426
+ * - Checks the revision ID to detect potential conflicts. (To be implemented: behavior when the `rev_id` does not match).
427
+ * - Validates editable fields against `schema.settings.editable_fields` (or allows editing of all fields if not specified).
428
+ * - Performs primary key conflict checks if multiple records are allowed (`single_record == false`).
429
+ * - Encrypts fields if encryption is required by the schema settings.
430
+ * - Updates the `meta` fields (such as `updated_on` and `updated_by`) and saves the updated document to the database.
431
+ *
432
+ * **Returns**:
433
+ * @returns {Object} The result of the document update operation.
434
+ *
435
+ * **Errors**:
436
+ * - Throws an error if a document with the same primary keys already exists (and `single_record == false`).
437
+ * - Throws a `DocUpdateError` if a primary key conflict is detected during the update.
438
+ *
439
+ * @throws {DocUpdateError} - If a document with conflicting primary keys already exists.
440
+ * @throws {ValidationError} - If the provided data or metadata is invalid according to the schema.
441
+ */
442
+ async update(
443
+ doc_search_criteria,
444
+ rev_id,
445
+ updates,
446
+ update_source = "api",
447
+ save_conflict = true
448
+ ) {
449
+ this._check_ready_to_use();
450
+ // making a big assumption here : primary key fields cannot be edited
451
+ // so updating the doc will not generate primary key conflicts
452
+ let req_data = await this.read(doc_search_criteria, true);
453
+ let schema = req_data.schema;
454
+ let full_doc = req_data.doc;
455
+
456
+ // @TODO fix this : what to do if the rev id does not match
457
+ // if (full_doc["_rev"] != rev_id) {
458
+ // // throw error , save conflicting doc separately by default
459
+ // if (save_conflict) {
460
+ // // save conflicting doc todo
461
+ // }
462
+ // }
463
+
464
+ // update new value depending on settings.non_editable_fields (if does not exists, all fields are editable)
465
+ let all_fields = Object.keys(schema.schema.properties);
466
+ let unedit_fields = schema.settings["non_editable_fields"];
467
+ let edit_fields = all_fields.filter(
468
+ (item) => !unedit_fields.includes(item)
469
+ );
180
470
 
181
- if(!schema_doc["schema"]["additionalProperties"]){
182
- errors.push("The schema.'additionalProperties' field is required")
183
- }else{
184
- if(typeof(schema_doc["schema"]["additionalProperties"])!="boolean"){
185
- errors.push("Invalid schema.additionalProperties. It must be a boolean value")
471
+ // now generate the new doc with updates
472
+ let allowed_updates = this.util_filter_object(updates.data, edit_fields);
473
+ let updated_data = { ...full_doc.data, ...allowed_updates };
474
+
475
+ this.util_validate_data(schema.schema, updated_data);
476
+
477
+ // primary key check if multiple records can be created
478
+ if ( schema.settings["single_record"] == false && schema.settings["primary_keys"].length > 0) {
479
+ let pri_fields = schema.settings["primary_keys"];
480
+ let search_criteria = { schema: schema.name };
481
+ pri_fields.map((itm) => {search_criteria["data." + itm] = updated_data[itm];});
482
+ let search = await this.search({ selection: search_criteria });
483
+ if (search.docs.length > 0) {
484
+ if (search.docs.length == 1) {
485
+ let thedoc = search.docs[0];
486
+ if (thedoc["_id"] != doc_id) {
487
+ throw new DocUpdateError([{message:"Update not allowed. Document with the same primary key already exists",}]);
488
+ }
489
+ } else {
490
+ throw new Error("There is something wrong with the schema primary keys");
491
+ }
186
492
  }
187
493
  }
188
494
 
189
- if(errors.length>0){
190
- throw new Error("Schema validation errors- "+errors.join(","))
495
+ // encrypt the data
496
+ full_doc["data"] = updated_data;
497
+ full_doc = this._encrypt_doc(schema, full_doc);
498
+
499
+ if (updates.meta) {
500
+ let m_sch = sys_sch.editable_metadata_schema;
501
+ let editable_fields = Object.keys(m_sch["properties"]);
502
+ let allowed_meta = this.util_filter_object(updates.meta, editable_fields);
503
+ this.util_validate_data(m_sch, allowed_meta);
504
+ full_doc["meta"] = { ...full_doc["meta"], ...allowed_meta };
191
505
  }
506
+
507
+ full_doc.meta["updated_on"] = this.util_get_now_unix_timestamp();
508
+ full_doc.meta["updated_by"] = update_source;
509
+ let up = await this.db_api.update(full_doc);
510
+ return up;
511
+ }
512
+
513
+
514
+ /**
515
+ * Deletes a document from the database by its ID.
516
+ *
517
+ * @param {String} doc_id - The ID of the document to delete.
518
+ * @throws {DocNotFoundError} If the document with the specified ID does not exist.
519
+ * @throws {ValidationError} If the database is not ready to use.
520
+ */
521
+ async delete(doc_id) {
522
+ this._check_ready_to_use();
523
+ await this.db_api.delete(doc_id);
192
524
  }
193
525
 
526
+
527
+ ////////////////////////////////////////////////////////
528
+ ////////////////// Search ////////////////////////////
529
+ ///////////////////////////////////////////////////////
530
+
531
+
194
532
  /**
195
- * Returns a document with the provided ID
196
- * @param {String} doc_id - the doc Id (not the primary key)
197
- * @param {Boolean} include_schema - whether to include the schema doc as well
198
- * @returns {Object} {doc} or {doc,schema}
533
+ * Searches for documents in the database for the specified query. The query are Mango queries.
534
+ * One field is mandatory : Schema
535
+ * E.g
536
+ * @param {Object} criteria
199
537
  */
200
- async get(doc_id,include_schema=false) {
201
- let doc = await this.db_api.get(doc_id);
202
- let schema = await this.get_schema_doc(doc.schema);
203
- doc = this._decrypt_doc(schema, doc);
204
- if(include_schema){
205
- return {doc,schema}
206
- }
207
- return {doc};
538
+ async search(criteria) {
539
+ this._check_ready_to_use();
540
+ if (!criteria["selector"]) {
541
+ throw new Error("Invalid search query.");
542
+ }
543
+ if (!criteria["selector"]["schema"]) {
544
+ throw new Error("The search criteria must contain the schema");
545
+ }
546
+ const results = await this.db_api.search(criteria);
547
+ return results;
208
548
  }
209
549
 
210
- /**
211
- * Returns schema document for the given schema name s
550
+ /**
551
+ * Returns schema document for the given schema name
212
552
  * @param {String} schema_name - Schema name
213
553
  */
214
- async get_schema_doc(schema_name) {
215
- let schemaSearch = await this.db_api.search({
216
- selector: { schema: "schema", "data.name": schema_name },
217
- });
218
- if (schemaSearch.docs.length == 0) {
219
- throw new Error("Schema not found");
554
+ async get_schema(schema_name) {
555
+ let schemaSearch = await this.db_api.search({
556
+ selector: { schema: "schema", "data.name": schema_name },
557
+ });
558
+ // console.log(schemaSearch)
559
+ if (schemaSearch.docs.length == 0) {
560
+ throw new DocNotFoundError([{message:BeanBagDB.error_codes.schema_not_found}]);
561
+ }
562
+ return schemaSearch.docs[0];
220
563
  }
221
- return schemaSearch.docs[0]["data"];
222
- }
223
564
 
224
565
  /**
225
566
  * Fetches a document based on a given schema and primary key.
@@ -230,7 +571,9 @@ class BeanBagDB {
230
571
  * @returns object
231
572
  */
232
573
  async get_doc(schema_name, primary_key = {}) {
233
- let s_doc = await this.get_schema_doc(schema_name);
574
+ this._check_ready_to_use();
575
+ let schema_doc = await this.get_schema(schema_name);
576
+ let s_doc = schema_doc["data"];
234
577
  let doc_obj;
235
578
  if (
236
579
  s_doc["settings"]["primary_keys"] &&
@@ -261,223 +604,62 @@ class BeanBagDB {
261
604
  return doc_obj;
262
605
  }
263
606
 
264
- /**
265
- * Searches for documents in the database for the specified query. The query are Mango queries.
266
- * One field is mandatory : Schema
267
- * E.g
268
- * @param {Object} criteria
269
- */
270
- async search(criteria) {
271
- if (!criteria["selector"]) {
272
- throw new Error("Invalid search query.");
273
- }
274
- if (!criteria["selector"]["schema"]) {
275
- throw new Error("The search criteria must contain the schema");
276
- }
277
- const results = await this.db_api.search(criteria);
278
- return results;
279
- }
280
607
 
281
- /**
282
- * Inserts a doc for the given schema
283
- * @param {String} schema e.g "contact"
284
- * @param {Object} data e.g {"name":"","mobile":""...}
285
- * @param {Object} settings (optional)
286
- */
287
- async insert(schema, data, meta= {},settings = {}) {
288
- try {
289
- let doc_obj = await this._insert_pre_checks(schema, data, settings);
290
- let new_rec = await this.db_api.insert(doc_obj);
291
- return { id: new_rec["id"] };
292
- } catch (error) {
293
- console.log(error);
294
- throw error;
295
- }
296
- }
297
-
298
-
299
-
300
-
301
- //** Update data */
302
- /**
303
- * Update data and meta of a doc.
304
- *
305
- * - Q: Which data fields can be edited ?
306
- * - A: Depends on the setting.editable_fields. If this is blank, then all fields are editable.
307
- * - Q: Are primary key fields editable ?
308
- * - A: Yes. before making the update, a check is done to ensue the primary key policy is not violated
309
- *
310
- * @param {String} doc_id
311
- * @param {String} rev_id
312
- * @param {*} schema_name
313
- * @param {doc_obj} updates {data:{},meta:{}}, need not be the full document, just the new values of all/some fields
314
- * @param {Boolean} save_conflict = true -
315
- * @returns
316
- */
317
- async update(doc_id, rev_id, updates, update_source="api",save_conflict = true) {
318
- // making a big assumption here : primary key fields cannot be edited
319
- // so updating the doc will not generate primary key conflicts
320
- let req_data = await this.get(doc_id,true);
321
- let schema = req_data.schema // await this.get_schema_doc(schema_name);
322
- let full_doc = req_data.doc // await this.get(doc_id)["doc"];
323
-
324
- // @TODO fix this : what to do if the rev id does not match
325
- // if (full_doc["_rev"] != rev_id) {
326
- // // throw error , save conflicting doc separately by default
327
- // if (save_conflict) {
328
- // // save conflicting doc todo
329
- // }
330
- // }
331
-
332
- // blank check
333
-
334
- // update new value depending on settings.editable_fields (if does not exists, all fields are editable)
335
- let edit_fields = Object.keys(schema.schema.properties)
336
- if(schema.settings["editable_fields"]&&schema.settings["editable_fields"].length>0){
337
- edit_fields = schema.settings["editable_fields"]
338
- }
339
-
340
- // now generate the new doc with updates
341
- let allowed_updates = this._filterObject(updates.data,edit_fields);
342
- let updated_data = { ...full_doc.data, ...allowed_updates };
343
-
344
- // validate data
345
- this.validate_data(schema.schema, updated_data);
346
-
347
- // primary key check if multiple records can be created
348
- if(schema.settings["single_record"]==false){
349
- if(schema.settings["primary_keys"]&&schema.settings["primary_keys"].length>0){
350
- let pri_fields = schema.settings["primary_keys"]
351
- let search_criteria = {schema:schema.name}
352
- pri_fields.map(itm=>{search_criteria["data."+itm] = updated_data[itm]})
353
- let search = await this.search({selection:search_criteria})
354
- if(search.docs.length>0){
355
- if(search.docs.length==1){
356
- let thedoc = search.docs[0]
357
- if(thedoc["_id"]!=doc_id){
358
- throw new Error("Update not allowed. Document with the same primary key already exists")
359
- }
360
- }else{
361
- throw new Error("There is something wrong with the schema")
362
- }
363
- }
364
- }
365
- }
366
-
367
- // encrypt the data
368
-
369
- full_doc["data"] = updated_data
370
- full_doc = this._encrypt_doc(schema,full_doc);
371
-
372
- if(updates.meta){
373
- let m_sch = sys_sch.editable_metadata_schema
374
- let editable_fields = Object.keys(m_sch["properties"])
375
- let allowed_meta = this._filterObject(updates.meta,editable_fields)
376
- this.validate_data(m_sch,allowed_meta)
377
- full_doc["meta"] = {...full_doc["meta"],...allowed_meta}
378
- }
379
-
380
- full_doc.meta["updated_on"] = this._get_now_unix_timestamp()
381
- full_doc.meta["updated_by"] = update_source
382
- let up = await this.db_api.update(full_doc);
383
- return up;
384
- }
385
-
386
- async delete(doc_id) {
387
- await this.db_api.delete(doc_id)
388
- }
389
-
390
-
391
- async load_plugin(plugin_name,plugin_module){
392
- this.plugins[plugin_name] = {}
393
- for (let func_name in plugin_module){
394
- if(typeof plugin_module[func_name]=='function'){
395
- this.plugins[plugin_name][func_name] = plugin_module[func_name].bind(null,this)
608
+ async load_plugin(plugin_name, plugin_module) {
609
+ this._check_ready_to_use();
610
+ this.plugins[plugin_name] = {};
611
+ for (let func_name in plugin_module) {
612
+ if (typeof plugin_module[func_name] == "function") {
613
+ this.plugins[plugin_name][func_name] = plugin_module[func_name].bind(
614
+ null,
615
+ this
616
+ );
396
617
  }
397
618
  }
398
619
  // Check if the plugin has an on_load method and call it
399
- if (typeof this.plugins[plugin_name].on_load === 'function') {
620
+ if (typeof this.plugins[plugin_name].on_load === "function") {
400
621
  await this.plugins[plugin_name].on_load();
401
622
  }
402
623
  }
403
624
 
404
- //////// Helper method ////////
405
-
406
- _generate_random_link(){
407
- const dictionary = ['rain', 'mars', 'banana', 'earth', 'kiwi', 'mercury', 'fuji', 'hurricane', 'matterhorn', 'snow', 'saturn', 'jupiter', 'peach', 'wind', 'pluto', 'apple', 'k2', 'storm', 'venus', 'denali', 'cloud', 'sunshine', 'mango', 'drizzle', 'pineapple', 'aconcagua', 'gasherbrum', 'apricot', 'neptune', 'fog', 'orange', 'blueberry', 'kilimanjaro', 'uranus', 'grape', 'storm', 'montblanc', 'lemon', 'chooyu', 'raspberry', 'cherry', 'thunder', 'vinson', 'breeze', 'elbrus', 'everest', 'parbat', 'makalu', 'nanga', 'kangchenjunga', 'lightning', 'cyclone', 'comet', 'asteroid', 'pomegranate', 'nectarine', 'clementine', 'strawberry', 'tornado', 'avalanche', 'andes', 'rockies', 'himalayas', 'pyrenees', 'carpathians', 'cascade', 'etna', 'vesuvius', 'volcano', 'tundra', 'whirlwind', 'iceberg', 'eclipse', 'zephyr', 'tropic', 'monsoon', 'aurora'];
408
- return Array.from({ length: 4 }, () => dictionary[Math.floor(Math.random() * dictionary.length)]).join('-');
409
- }
410
-
411
- _check_required_fields(requiredFields,obj){
412
- for (const field of requiredFields) {
413
- if (!obj[field]) {throw new Error(`${field} is required`);}
414
- }
415
- }
625
+ ///////////////////////////////////////////////////////////
626
+ //////////////// Internal methods ////////////////////////
627
+ //////////////////////////////////////////////////////////
416
628
 
417
629
  /**
418
- *
419
- * @param {*} obj
420
- * @param {*} fields
421
- * @returns
422
- */
423
- _filterObject(obj, fields) {
424
- return fields.reduce((filteredObj, field) => {
425
- if (Object.prototype.hasOwnProperty.call(obj, field)) {
426
- filteredObj[field] = obj[field];
427
- }
428
- return filteredObj;
429
- }, {});
430
- }
431
-
432
- /**
433
- * Checks if the selected database is initialized for working with BeanBagDB. Also throws a warning if package version does not match with database version.
434
- * Every time a database is initialized, a setting document `beanbagdb_version` is added. If this does not exists, the database is not initialized. If it exists but does not match the current version, a warning is shown.
435
- * @returns {object} {initialized:boolean,latest:boolean}
630
+ * @private
631
+ * @returns {number}
436
632
  */
437
- async _check_ready_to_use() {
438
- // @TODO check if ready to use in major API methods
439
- let check = { initialized: false, latest: false };
440
- // @TODO this is not really fool proof. check all the required docs, they have the system_generated flag
441
- // what if some user mistakenly modifies or deletes some of the required docs ?
442
- let version_search = await this.db_api.search({
443
- selector: { schema: "system_settings", "data.name": "beanbagdb_version" },
633
+ _get_current_version() {
634
+ // current version is the sum of versions of all system defined schemas
635
+ let sum = sys_sch.schema_schema.version;
636
+ let keys = Object.keys(sys_sch.system_schemas).map((item) => {
637
+ sum = sum + sys_sch.system_schemas[item].version;
444
638
  });
445
- if (version_search.docs.length > 0) {
446
- let doc = version_search.docs[0];
447
- check.initialized = true;
448
- check.latest = doc["data"]["value"] == this._version;
639
+ if (sum == NaN) {
640
+ throw Error("Error in system schema version numbers");
449
641
  }
450
- if (check.initialized == false) {
451
- console.warn(
452
- "This database is not ready to be used. It is not initialized. Run `initialize_db()` first"
453
- );
454
- }
455
- if ((check.latest == false) & (check.initialized == true)) {
456
- console.warn(
457
- "This database is not updated with the latest version. Run `initialize_db()` again to update to the latest version"
458
- );
459
- }
460
- return check;
461
- }
462
-
463
- /**
464
- * To update the system schema or reset to a stable version to ensure functioning of the BeanBagDB
465
- */
466
- async _update_system_schema() {
467
- console.log("Todo");
642
+ return sum;
468
643
  }
469
644
 
470
645
  /**
471
- * Returns the current Unix timestamp in seconds.
472
- * divide by 1000 (Date.now gives ms) to convert to seconds. 1 s = 1000 ms
473
- * @returns {number}
646
+ * Checks if the database is ready to use.
647
+ *
648
+ * This method verifies if the database is in an active state. If the database is not ready,
649
+ * it throws an error indicating that the database is inactive.
650
+ *
651
+ * @private
652
+ * @throws {Error} - Throws an error if the database is not active.
474
653
  */
475
- _get_now_unix_timestamp() {
476
- return Math.floor(Date.now() / 1000);
654
+ _check_ready_to_use() {
655
+ if (!this.active) {
656
+ throw new Error(BeanBagDB.error_codes.not_active);
657
+ }
477
658
  }
478
659
 
479
660
  /**
480
661
  * Generates a blank database json object. All objects in the database follow the same structure
662
+ * @private
481
663
  * @param {string} schema_name
482
664
  * @returns {object}
483
665
  */
@@ -488,10 +670,10 @@ class BeanBagDB {
488
670
  let doc = {
489
671
  data: {},
490
672
  meta: {
491
- createdOn: this._get_now_unix_timestamp(),
673
+ created_on: this.util_get_now_unix_timestamp(),
492
674
  tags: [],
493
- app :{},
494
- link : this._generate_random_link() // there is a link by default. overwrite this if user provided one but only before checking if it is unique
675
+ app: {},
676
+ link: this.util_generate_random_link(), // there is a link by default. overwrite this if user provided one but only before checking if it is unique
495
677
  },
496
678
  schema: schema_name,
497
679
  };
@@ -500,13 +682,14 @@ class BeanBagDB {
500
682
 
501
683
  /**
502
684
  * Generates a blank schema doc ready to be inserted to the database. Note that no validation is done. This is for internal use
685
+ * @private
503
686
  * @param {string} schema_name
504
687
  * @param {Object} schema_object
505
688
  * @param {Object} data
506
689
  * @returns {Object}
507
690
  */
508
691
  _get_blank_schema_doc(schema_name, schema_object, data) {
509
- this.validate_data(schema_object, data);
692
+ this.util_validate_data(schema_object, data);
510
693
  let obj = this._get_blank_doc(schema_name);
511
694
  obj["data"] = data;
512
695
  return obj;
@@ -514,6 +697,7 @@ class BeanBagDB {
514
697
 
515
698
  /**
516
699
  * Decrypts a given document using it's schema. The list of encrypted fields : schema_obj.settings.encrypted_fields
700
+ * @private
517
701
  * @param {Object} schema_obj
518
702
  * @param {Object} doc_obj
519
703
  * @returns {Object}
@@ -535,12 +719,12 @@ class BeanBagDB {
535
719
 
536
720
  /**
537
721
  * Encrypts a given doc using it's schema obj.
722
+ * @private
538
723
  * @param {Object} schema_obj
539
724
  * @param {Object} doc_obj
540
725
  * @returns {Object}
541
726
  */
542
727
  _encrypt_doc(schema_obj, doc_obj) {
543
-
544
728
  if (
545
729
  schema_obj.settings["encrypted_fields"] &&
546
730
  schema_obj.settings["encrypted_fields"].length > 0
@@ -557,16 +741,26 @@ class BeanBagDB {
557
741
  }
558
742
 
559
743
  /**
560
- * Checks if the new document is valid and ready to be inserted in the DB.
561
- * List of checks:
562
- * - fetch the schema object and validate the data object against the schema
563
- * - check if the doc with same primary keys already exists
564
- * - replace encrypted fields with encrypted values
565
- * - return the doc
566
- * @param {Object} schema
567
- * @param {Object} data
744
+ * Validates the new document before inserting it into the database.
745
+ *
746
+ * This method performs a series of checks:
747
+ * - Fetches the schema object and validates the `data` object against it.
748
+ * - Checks for existing documents with the same primary keys.
749
+ * - Replaces encrypted fields with their encrypted values.
750
+ *
751
+ * It then generates the document ready for insertion and returns it.
752
+ *
753
+ * @private
754
+ * @param {Object} schema - The schema object or schema name to validate against.
755
+ * @param {Object} data - The data object to be validated and prepared for insertion.
756
+ * @param {Object} [meta={}] - Additional metadata related to the document.
757
+ * @param {Object} [settings={}] - Optional settings to guide special checks.
758
+ *
759
+ * @returns {Promise<Object>} - Returns the validated document object ready for insertion.
760
+ *
761
+ * @throws {Error} If validation fails, or the document already exists.
568
762
  */
569
- async _insert_pre_checks(schema, data,meta={} ,settings = {}) {
763
+ async _insert_pre_checks(schema, data, meta = {}, settings = {}) {
570
764
  // schema search
571
765
  let sch_search = await this.search({
572
766
  selector: { schema: "schema", "data.name": schema },
@@ -576,14 +770,16 @@ class BeanBagDB {
576
770
  }
577
771
  let schemaDoc = sch_search.docs[0]["data"];
578
772
  // validate data
579
- this.validate_data(schemaDoc.schema, data);
773
+ this.util_validate_data(schemaDoc.schema, data);
580
774
 
581
775
  // validate meta
582
- this.validate_data(sys_sch.editable_metadata_schema, meta);
583
-
776
+ this.util_validate_data(sys_sch.editable_metadata_schema, meta);
777
+
584
778
  // duplicate meta.link check
585
- if(meta.link){
586
- let link_search = await this.search({ selector: {"meta.link":meta.link} });
779
+ if (meta.link) {
780
+ let link_search = await this.search({
781
+ selector: { "meta.link": meta.link },
782
+ });
587
783
  console.log(link_search);
588
784
  if (link_search.docs.length > 0) {
589
785
  throw new Error("This link already exists in the database");
@@ -592,9 +788,9 @@ class BeanBagDB {
592
788
 
593
789
  // special checks for special docs
594
790
  // @TODO : for schema dos: settings fields must be in schema field
595
- if(schema=="schema"){
791
+ if (schema == "schema") {
596
792
  //more checks are required
597
- this.validate_schema_object(data)
793
+ this.util_validate_schema_object(data);
598
794
  }
599
795
  // @TODO : check if single record setting is set to true
600
796
 
@@ -629,6 +825,330 @@ class BeanBagDB {
629
825
  doc_obj["data"] = new_data;
630
826
  return doc_obj;
631
827
  }
828
+
829
+ ////////////////////////////////////////////////////////////////////////////////////////////////////
830
+ ////// Utility methods /////////////////////////////////////////////////////////////////////////////
831
+ ///////////////////////////////////////////////////////////////////////////////////////////////////
832
+
833
+
834
+ /**
835
+ * Returns the current Unix timestamp in seconds.
836
+ * divide by 1000 (Date.now gives ms) to convert to seconds. 1 s = 1000 ms
837
+ * @private
838
+ * @returns {number}
839
+ */
840
+ util_get_now_unix_timestamp() {
841
+ return Math.floor(Date.now() / 1000);
842
+ }
843
+
844
+ /**
845
+ * Validates that the required fields are present in the provided object.
846
+ *
847
+ * @param {string[]} requiredFields - An array of field names that are required.
848
+ * @param {object} obj - The object to check for the required fields.
849
+ * @throws {ValidationError} If any of the required fields are missing, an error is thrown.
850
+ */
851
+ util_check_required_fields(requiredFields, obj) {
852
+ for (const field of requiredFields) {
853
+ if (!obj[field]) {
854
+ throw new ValidationError([
855
+ { message: `The field ${field} is required.` },
856
+ ]);
857
+ }
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Filters an object, returning a new object that only contains the specified fields.
863
+ *
864
+ * @param {Object} obj - The object to filter.
865
+ * @param {Array<String>} fields - An array of field names to retain in the filtered object.
866
+ *
867
+ * @returns {Object} - A new object containing only the fields that exist in `obj` from the `fields` array.
868
+ *
869
+ * **Example**:
870
+ *
871
+ * const data = { name: "Alice", age: 25, location: "NY" };
872
+ * const result = util_filter_object(data, ["name", "age"]);
873
+ * // result: { name: "Alice", age: 25 }
874
+ */
875
+ util_filter_object(obj, fields) {
876
+ return fields.reduce((filteredObj, field) => {
877
+ if (Object.prototype.hasOwnProperty.call(obj, field)) {
878
+ filteredObj[field] = obj[field];
879
+ }
880
+ return filteredObj;
881
+ }, {});
882
+ }
883
+
884
+
885
+ /**
886
+ * Validates a data object against a provided JSON schema
887
+ * It relies on the external API provided by the user
888
+ * @param {Object} schema_obj - The JSON schema object to validate against
889
+ * @param {Object} data_obj - The data object to validate
890
+ * @throws {Error} If the data object does not conform to the schema
891
+ */
892
+ util_validate_data(schema_obj, data_obj) {
893
+ const { valid, validate } = this.utils.validate_schema(
894
+ schema_obj,
895
+ data_obj
896
+ );
897
+ //const ajv = new Ajv({code: {esm: true}}) // options can be passed, e.g. {allErrors: true}
898
+ //const validate = ajv.compile(schema_obj);
899
+ //const valid = validate(data_obj);
900
+ if (!valid) {
901
+ throw new ValidationError(validate.errors);
902
+ }
903
+ }
904
+
905
+ /**
906
+ * Validates the structure and content of a schema object.
907
+ *
908
+ * This method checks the following conditions:
909
+ *
910
+ * - The schema must have a 'type' field, which should be 'object'.
911
+ * - The 'properties' field must be an object and contain at least one property.
912
+ * - The 'additionalProperties' field must be present and of type boolean.
913
+ * - Primary keys must be defined in the schema and cannot be of type 'object' or 'array'.
914
+ * - Non-editable fields must be defined in the schema.
915
+ * - Encrypted fields must be defined in the schema, of type 'string', and cannot include primary keys.
916
+ *
917
+ * If any of these conditions are violated, an array of error messages will be
918
+ * collected and thrown as a ValidationError.
919
+ *
920
+ * @param {Object} schema_doc - The schema document to validate.
921
+ * @param {Object} schema_doc.schema - The schema structure containing:
922
+ * @param {String} schema_doc.schema.type - The type of the schema (must be 'object').
923
+ * @param {Object} schema_doc.schema.properties - The properties defined in the schema.
924
+ * @param {Boolean} schema_doc.schema.additionalProperties - Indicates if additional properties are allowed.
925
+ * @param {Object} schema_doc.settings - The settings associated with the schema, including:
926
+ * @param {Array<String>} schema_doc.settings.primary_keys - List of primary keys for the schema.
927
+ * @param {Array<String>} schema_doc.settings.non_editable_fields - Fields that cannot be edited.
928
+ * @param {Array<String>} schema_doc.settings.encrypted_fields - Fields that require encryption.
929
+ *
930
+ * @throws {ValidationError} If any validation checks fail, with an array of error messages.
931
+ */
932
+ util_validate_schema_object(schema_doc) {
933
+ let errors = [{ message: "Schema validation errors " }];
934
+ if (!schema_doc["schema"]["type"]) {
935
+ errors.push({
936
+ message:
937
+ "Schema must have the field schema.'type' which can only be 'object' ",
938
+ });
939
+ } else {
940
+ if (schema_doc["schema"]["type"] != "object") {
941
+ errors.push({
942
+ message: "The schema.'type' value is invalid.Only 'object' allowed",
943
+ });
944
+ }
945
+ }
946
+ if (!schema_doc["schema"]["properties"]) {
947
+ errors.push({
948
+ message: "The schema.'properties' object does not exists",
949
+ });
950
+ } else {
951
+ if (typeof schema_doc["schema"]["properties"] != "object") {
952
+ errors.push({
953
+ message:
954
+ "Invalid schema.properties. It must be an object and must have atleast one field inside.",
955
+ });
956
+ }
957
+ if (Object.keys(schema_doc["schema"]["properties"]).length == 0) {
958
+ errors.push({ message: "You must define at least one property" });
959
+ }
960
+ }
961
+
962
+ if (!schema_doc["schema"]["additionalProperties"]) {
963
+ errors.push({
964
+ message: "The schema.'additionalProperties' field is required",
965
+ });
966
+ } else {
967
+ if (typeof schema_doc["schema"]["additionalProperties"] != "boolean") {
968
+ errors.push({
969
+ message:
970
+ "Invalid schema.additionalProperties. It must be a boolean value",
971
+ });
972
+ }
973
+ }
974
+
975
+ const allKeys = Object.keys(schema_doc["schema"]["properties"]);
976
+ if (schema_doc["settings"]["primary_keys"].length > 0) {
977
+ // check if all keys belong to the schema and are not of type object
978
+ let all_pk_exist = schema_doc["settings"]["primary_keys"].every(
979
+ (item) =>
980
+ allKeys.includes(item) &&
981
+ schema_doc["schema"]["properties"][item]["type"] != "object" &&
982
+ schema_doc["schema"]["properties"][item]["type"] != "array"
983
+ );
984
+
985
+ if (!all_pk_exist) {
986
+ errors.push({
987
+ message:
988
+ "Primary keys invalid. All keys must be defined in the schema and must be non object",
989
+ });
990
+ }
991
+ }
992
+
993
+ if (schema_doc["settings"]["non_editable_fields"].length > 0) {
994
+ // check if all keys belong to the schema
995
+ let all_ne_exist = schema_doc["settings"]["non_editable_fields"].every(
996
+ (item) => allKeys.includes(item)
997
+ );
998
+ if (!all_ne_exist) {
999
+ errors.push({
1000
+ message:
1001
+ "Non editable fields invalid. All fields must be defined in the schema ",
1002
+ });
1003
+ }
1004
+ }
1005
+
1006
+ if (schema_doc["settings"]["encrypted_fields"].length > 0) {
1007
+ // check if all keys belong to the schema and are only string
1008
+ let all_enc_exist = schema_doc["settings"]["encrypted_fields"].every(
1009
+ (item) =>
1010
+ allKeys.includes(item) &&
1011
+ schema_doc["schema"]["properties"][item]["type"] == "string"
1012
+ );
1013
+ if (!all_enc_exist) {
1014
+ errors.push({
1015
+ message:
1016
+ "Invalid encrypted fields. All fields must be defined in the schema and must be string ",
1017
+ });
1018
+ }
1019
+
1020
+ // check : primary keys cannot be encrypted
1021
+ let all_enc_no_pk = schema_doc["settings"]["encrypted_fields"].every(
1022
+ (item) => !schema_doc["settings"]["primary_keys"].includes(item)
1023
+ );
1024
+ if (!all_enc_no_pk) {
1025
+ errors.push({
1026
+ message:
1027
+ "Invalid encrypted fields.Primary key fields cannot be encrypted ",
1028
+ });
1029
+ }
1030
+ }
1031
+
1032
+ /// cannot encrypt primary field keys
1033
+ if (errors.length > 1) {
1034
+ throw new ValidationError(errors);
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Generates a random link composed of four words from a predefined dictionary.
1040
+ *
1041
+ * The words are selected randomly, and the resulting link is formatted as
1042
+ * a hyphen-separated string. This can be useful for creating link for documents.
1043
+ *
1044
+ * @returns {String} A hyphen-separated string containing four randomly
1045
+ * selected words from the dictionary. For example:
1046
+ * "banana-earth-kiwi-rain".
1047
+ *
1048
+ */
1049
+ util_generate_random_link() {
1050
+ // prettier-ignore
1051
+ const dictionary = ['rain', 'mars', 'banana', 'earth', 'kiwi', 'mercury', 'fuji', 'hurricane', 'matterhorn', 'snow', 'saturn', 'jupiter', 'peach', 'wind', 'pluto', 'apple', 'k2', 'storm', 'venus', 'denali', 'cloud', 'sunshine', 'mango', 'drizzle', 'pineapple', 'aconcagua', 'gasherbrum', 'apricot', 'neptune', 'fog', 'orange', 'blueberry', 'kilimanjaro', 'uranus', 'grape', 'storm', 'montblanc', 'lemon', 'chooyu', 'raspberry', 'cherry', 'thunder', 'vinson', 'breeze', 'elbrus', 'everest', 'parbat', 'makalu', 'nanga', 'kangchenjunga', 'lightning', 'cyclone', 'comet', 'asteroid', 'pomegranate', 'nectarine', 'clementine', 'strawberry', 'tornado', 'avalanche', 'andes', 'rockies', 'himalayas', 'pyrenees', 'carpathians', 'cascade', 'etna', 'vesuvius', 'volcano', 'tundra', 'whirlwind', 'iceberg', 'eclipse', 'zephyr', 'tropic', 'monsoon', 'aurora'];
1052
+ return Array.from(
1053
+ { length: 4 },
1054
+ () => dictionary[Math.floor(Math.random() * dictionary.length)]
1055
+ ).join("-");
1056
+ }
632
1057
  }
633
1058
 
634
- export default BeanBagDB;
1059
+ ////////////////// Error classes ////////////////////////////////////////////////////
1060
+
1061
+ /**
1062
+ * This is common for all error classes
1063
+ * @typedef {Object} ErrorItem
1064
+ * @property {string} [instancePath] - The path where the error occurred, optional.
1065
+ * @property {string} message - The error message.
1066
+ */
1067
+
1068
+ /**
1069
+ * Custom error class for validation errors.
1070
+ *
1071
+ * @extends {Error}
1072
+ */
1073
+ export class ValidationError extends Error {
1074
+ /**
1075
+ * Custom error class for validation errors.
1076
+ *
1077
+ * @extends {Error}
1078
+ * @param {ErrorItem[]} [errors=[]] - An array of error objects, each containing details about validation failures.
1079
+ */
1080
+ constructor(errors = []) {
1081
+ // Create a message based on the list of errors
1082
+ //console.log(errors)
1083
+ let error_messages = errors.map(item=>` ${(item.instancePath||" ").replace("/","")} ${item.message} `)
1084
+ let message = `Validation failed with ${errors.length} error(s): ${error_messages.join(",")}`;
1085
+ super(message);
1086
+ this.name = 'ValidationError';
1087
+ this.errors = errors; // Store the list of errors
1088
+ }
1089
+ }
1090
+
1091
+
1092
+
1093
+ /**
1094
+ * Custom error class for document update errors.
1095
+ *
1096
+ * @extends {Error}
1097
+ */
1098
+ export class DocUpdateError extends Error {
1099
+ /**
1100
+ * Custom error class for document update errors.
1101
+ *
1102
+ * @extends {Error}
1103
+ * @param {ErrorItem[]} [errors=[]] - An array of error objects, each containing details about validation failures.
1104
+ */
1105
+ constructor(errors=[]){
1106
+ let error_messages = errors.map(item=>`${item.message}`)
1107
+ let message = `Error in document update. ${error_messages.join(",")}`
1108
+ super(message)
1109
+ this.name = "DocUpdateError";
1110
+ this.errors = errors
1111
+ }
1112
+ }
1113
+
1114
+ /**
1115
+ * Custom error class for document insert errors.
1116
+ *
1117
+ * @extends {Error}
1118
+ */
1119
+ export class DocInsertError extends Error {
1120
+ /**
1121
+ * Custom error class for document insert errors.
1122
+ *
1123
+ * @extends {Error}
1124
+ * @param {ErrorItem[]} [errors=[]] - An array of error objects, each containing details about validation failures.
1125
+ */
1126
+ constructor(errors=[]){
1127
+ let error_messages = errors.map(item=>`${item.message}`)
1128
+ let message = `Error in document insert. ${error_messages.join(",")}`
1129
+ super(message)
1130
+ this.name = "DocInsertError";
1131
+ this.errors = errors
1132
+ }
1133
+ }
1134
+
1135
+ /**
1136
+ * Custom error class for document not found errors.
1137
+ *
1138
+ * @extends {Error}
1139
+ */
1140
+ export class DocNotFoundError extends Error {
1141
+ /**
1142
+ * Custom error class for document not found errors.
1143
+ *
1144
+ * @extends {Error}
1145
+ * @param {ErrorItem[]} [errors=[]] - An array of error objects, each containing details about validation failures.
1146
+ */
1147
+ constructor(errors=[]){
1148
+ let error_messages = errors.map(item=>`${item.message}`)
1149
+ let message = `Error in fetching document. Criteria : ${error_messages.join(",")}`
1150
+ super(message)
1151
+ this.name = "DocNotFoundError";
1152
+ this.errors = errors
1153
+ }
1154
+ }