@docstack/client 0.0.3 → 0.0.5

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.
Files changed (53) hide show
  1. package/lib/core/attribute.js +406 -0
  2. package/lib/core/attribute.js.map +1 -0
  3. package/lib/core/class.d.ts +13 -14
  4. package/lib/core/class.js +761 -0
  5. package/lib/core/class.js.map +1 -0
  6. package/lib/core/crypto-engine/index.js +229 -0
  7. package/lib/core/crypto-engine/index.js.map +1 -0
  8. package/lib/core/crypto-engine/utils.js +88 -0
  9. package/lib/core/crypto-engine/utils.js.map +1 -0
  10. package/lib/core/datamodel/index.js +1308 -0
  11. package/lib/core/datamodel/index.js.map +1 -0
  12. package/lib/core/domain.js +423 -0
  13. package/lib/core/domain.js.map +1 -0
  14. package/lib/core/index.js +520 -0
  15. package/lib/core/index.js.map +1 -0
  16. package/lib/core/job-engine/index.js +220 -0
  17. package/lib/core/job-engine/index.js.map +1 -0
  18. package/lib/core/policy-engine/index.js +232 -0
  19. package/lib/core/policy-engine/index.js.map +1 -0
  20. package/lib/core/query-engine/accumulators.js +258 -0
  21. package/lib/core/query-engine/accumulators.js.map +1 -0
  22. package/lib/core/query-engine/evaluator.js +179 -0
  23. package/lib/core/query-engine/evaluator.js.map +1 -0
  24. package/lib/core/query-engine/executor.js +405 -0
  25. package/lib/core/query-engine/executor.js.map +1 -0
  26. package/lib/core/query-engine/index.js +4 -0
  27. package/lib/core/query-engine/index.js.map +1 -0
  28. package/lib/core/query-engine/parser.js +515 -0
  29. package/lib/core/query-engine/parser.js.map +1 -0
  30. package/lib/core/query-engine/planner.js +330 -0
  31. package/lib/core/query-engine/planner.js.map +1 -0
  32. package/lib/core/stack.js +1817 -0
  33. package/lib/core/stack.js.map +1 -0
  34. package/lib/core/test-utils/docstack.js +222 -0
  35. package/lib/core/test-utils/docstack.js.map +1 -0
  36. package/lib/core/trigger/index.js +81 -0
  37. package/lib/core/trigger/index.js.map +1 -0
  38. package/lib/index.js +10 -4
  39. package/lib/index.js.map +1 -1
  40. package/lib/index.umd.js +10 -4
  41. package/lib/plugins/pouchdb.js +368 -0
  42. package/lib/plugins/pouchdb.js.map +1 -0
  43. package/lib/utils/crypto/index.js +34 -0
  44. package/lib/utils/crypto/index.js.map +1 -0
  45. package/lib/utils/index.js +58 -0
  46. package/lib/utils/index.js.map +1 -0
  47. package/lib/utils/logger/index.js +20 -0
  48. package/lib/utils/logger/index.js.map +1 -0
  49. package/lib/utils/logger/transport.js +28 -0
  50. package/lib/utils/logger/transport.js.map +1 -0
  51. package/lib/workers/dataModel.js +48 -0
  52. package/lib/workers/dataModel.js.map +1 -0
  53. package/package.json +2 -2
@@ -0,0 +1,1817 @@
1
+ import PouchDB from "pouchdb-browser";
2
+ import createLogger from "../utils/logger";
3
+ import Class from "./class";
4
+ import Domain from "./domain";
5
+ import PouchDBFind from 'pouchdb-find';
6
+ import { getSystemPatches } from "./datamodel";
7
+ import { Stack, isClassModel, } from "@docstack/shared";
8
+ import { StackPlugin } from "../plugins/pouchdb";
9
+ import { parse, createPlan, executePlan } from "./query-engine";
10
+ import { JobEngine } from "./job-engine";
11
+ import { PolicyEngine } from "./policy-engine";
12
+ import { CryptoEngine } from "./crypto-engine";
13
+ import { isEncryptedPayload } from "./crypto-engine/utils";
14
+ const logger = createLogger().child({ module: "stack" });
15
+ export const BASE_SCHEMA = {
16
+ "_id": { name: "_id", type: "string", config: { maxLength: 100, primaryKey: true } },
17
+ "~class": { name: "~class", type: "string", config: { maxLength: 100 } },
18
+ "~createTimestamp": { name: "~createTimestamp", type: "integer", config: { min: 0 } },
19
+ "~updateTimestamp": { name: "~updateTimestamp", type: "integer", config: { min: 0 } },
20
+ "description": { name: "description", type: "string", config: { maxLength: 1000 } },
21
+ "active": { name: "active", type: "boolean", config: { defaultValue: true, primaryKey: true } }
22
+ };
23
+ export const CLASS_SCHEMA = Object.assign(Object.assign({}, BASE_SCHEMA), { "~class": { name: "~class", type: "string", config: { defaultValue: "class" } }, "schema": { name: "schema", type: "object", config: { maxLength: 1000, isArray: false } }, "parentClass": { name: "parentClass", type: "foreign_key", config: { isArray: false } } });
24
+ const DOMAIN_SCHEMA = Object.assign(Object.assign({}, BASE_SCHEMA), { "~class": { name: "~class", type: "string", config: { defaultValue: "domain" } }, "schema": {
25
+ name: "schema", type: "object", config: {
26
+ isArray: true,
27
+ defaultValue: {
28
+ "source": {
29
+ name: "source",
30
+ type: "foreign_key",
31
+ config: {
32
+ isArray: false
33
+ }
34
+ },
35
+ "target": {
36
+ name: "target",
37
+ type: "foreign_key",
38
+ config: {
39
+ isArray: false
40
+ }
41
+ }
42
+ }
43
+ }
44
+ },
45
+ // "parentDomain": { name: "parentDomain", type: "foreign_key", config: { isArray: false } },
46
+ "relation": {
47
+ name: "relation", type: "enum", config: {
48
+ isArray: false, values: [
49
+ { value: "1:1" }, { value: "1:N" }, { value: "N:1" }, { value: "N:N" }
50
+ ]
51
+ }
52
+ }, "sourceClass": { name: "sourceClass", type: "foreign_key", config: { isArray: false } }, "targetClass": { name: "targetClass", type: "foreign_key", config: { isArray: false } } });
53
+ /**
54
+ * The core database engine for DocStack client applications.
55
+ *
56
+ * ClientStack provides a complete offline-first datastore built on PouchDB with:
57
+ * - Schema validation and class-based document modeling
58
+ * - SQL-like querying capabilities
59
+ * - Field-level encryption via {@link CryptoEngine}
60
+ * - Access control via {@link PolicyEngine}
61
+ * - Background job execution via {@link JobEngine}
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * // Create a new stack instance
66
+ * const stack = await ClientStack.create('my-app-db');
67
+ *
68
+ * // Authenticate a user
69
+ * const session = await stack.authenticate({ username: 'admin', password: 'secret' });
70
+ *
71
+ * // Query documents using SQL
72
+ * const { rows } = await stack.query('SELECT * FROM Task WHERE isComplete = false');
73
+ * ```
74
+ *
75
+ * @extends Stack
76
+ */
77
+ class ClientStack extends Stack {
78
+ constructor() {
79
+ super();
80
+ /** The current application version string. */
81
+ this.appVersion = "0.0.1";
82
+ this.listeners = [];
83
+ this.modelWorker = null;
84
+ /**
85
+ * Exports all documents from the database.
86
+ * Useful for debugging or creating backups.
87
+ * @returns All documents including their content
88
+ */
89
+ this.dump = async () => {
90
+ const all = await this.db.allDocs({ include_docs: true });
91
+ return all;
92
+ };
93
+ this.applyPatch = async (patch) => {
94
+ const fnLogger = logger.child({ method: "applyPatch", args: { patch } });
95
+ return new Promise(async (resolve, reject) => {
96
+ try {
97
+ fnLogger.info("Attempting to apply patch", { patch });
98
+ fnLogger.info("applyPatch - starting to hydrate patch docs", { docCount: patch.docs.length });
99
+ const hydratedDocs = await Promise.all(patch.docs.map(async (doc) => {
100
+ if (doc._rev === "auto") {
101
+ delete doc._rev;
102
+ const existingDoc = await this.db.get(doc._id);
103
+ if (existingDoc) {
104
+ doc = Object.assign(Object.assign({}, existingDoc), doc);
105
+ }
106
+ }
107
+ return doc;
108
+ }));
109
+ fnLogger.info("applyPatch - hydration complete, calling bulkDocs", { docCount: hydratedDocs.length });
110
+ await this.db.bulkDocs(hydratedDocs, { isPatch: true }).then((result) => {
111
+ fnLogger.warn("applyPatch - bulkDocs completed with result", { result });
112
+ fnLogger.warn("Successfully processed patch", { version: patch.version });
113
+ }).catch((error) => {
114
+ fnLogger.error("applyPatch - bulkDocs error", { error });
115
+ reject(error);
116
+ });
117
+ // Store patch itself
118
+ await this.db.post(Object.assign({ createTimestamp: (new Date()).valueOf() }, patch));
119
+ fnLogger.info("Successfully stored patch", { version: patch.version, target: patch.target });
120
+ resolve(patch.version);
121
+ }
122
+ catch (e) {
123
+ fnLogger.error("Failed to apply patch", e);
124
+ reject(new Error(e));
125
+ }
126
+ });
127
+ };
128
+ this.setListeners = () => {
129
+ const fnLogger = logger.child({ method: "setListeners" });
130
+ // Listening for class model propagation
131
+ this.addEventListener('class-model-propagation-pending', this.onClassModelPropagationStart);
132
+ this.addEventListener('class-model-propagation-complete', this.onClassModelPropagationComplete);
133
+ // fnLogger.info("Setting up class model worker");
134
+ // this.modelWorker = new Worker(require("../workers/dataModel"), {type: "module"});
135
+ fnLogger.info("Setting up class model changes listener");
136
+ const classModelChanges = this.onClassModelChanges();
137
+ /*
138
+ this.modelWorker.onmessage = (event) => {
139
+ const { status, className, message } = event.data;
140
+
141
+ this.dispatchEvent(new CustomEvent('class-model-propagation-complete', {
142
+ detail: { className: className, success: status === 'success', message }
143
+ }));
144
+
145
+ if (status === 'error') {
146
+ fnLogger.error(`Model worker error for class '${className}': ${message}`);
147
+ }
148
+ };
149
+ */
150
+ // Store listener for later
151
+ this.listeners.push(classModelChanges);
152
+ };
153
+ /**
154
+ * @description Clears all listeners from the Stack
155
+ */
156
+ this.removeAllListeners = () => {
157
+ this.removeEventListener('class-model-propagation-pending', this.onClassModelPropagationStart);
158
+ this.removeEventListener('class-model-propagation-complete', this.onClassModelPropagationComplete);
159
+ if (this.listeners.length > 0) {
160
+ for (const listener of this.listeners) {
161
+ if (listener && typeof listener.cancel === 'function') {
162
+ try {
163
+ listener.cancel();
164
+ }
165
+ catch (error) {
166
+ logger.warn('Error while cancelling listener', { error });
167
+ }
168
+ }
169
+ }
170
+ this.listeners = [];
171
+ }
172
+ };
173
+ /**
174
+ * @description When a class model propagation starts write the ~lock document to the database.
175
+ * It prevents any further modifications on the class data model
176
+ * @param event
177
+ */
178
+ this.onClassModelPropagationStart = (event) => {
179
+ const className = event.detail.className;
180
+ const fnLogger = logger.child({ method: "onClassModelPropagationStart", className });
181
+ this.addClassLock(className).then(() => {
182
+ fnLogger.info(`Lock created successfully for class: '${className}'`);
183
+ }).catch(error => {
184
+ fnLogger.error(`Error creating lock for '${className}': ${error}`);
185
+ });
186
+ };
187
+ /**
188
+ * @description When a class model propagation comes to completion remove the corresponding
189
+ * ~lock from the database
190
+ * @param event
191
+ */
192
+ this.onClassModelPropagationComplete = (event) => {
193
+ const fnLogger = logger.child({ method: "onClassModelPropagationComplete", args: { event } });
194
+ const className = event.detail.className;
195
+ this.clearClassLock(className).then(() => {
196
+ fnLogger.info(`Lock removed successfully for class: '${className}'`);
197
+ }).catch(error => {
198
+ fnLogger.error(`Error removing lock for '${className}': ${error}`);
199
+ });
200
+ ;
201
+ };
202
+ /**
203
+ * @returns PouchDB.Core.Changes<{}>
204
+ */
205
+ this.onClassModelChanges = () => {
206
+ const fnLogger = logger.child({ listener: "classModelChanges" });
207
+ const classModelChanges = this.db.changes({
208
+ since: 'now',
209
+ live: true,
210
+ include_docs: true,
211
+ filter: (doc) => {
212
+ return doc["~class"] == "class";
213
+ }
214
+ }).on("change", async (change) => {
215
+ const doc = change.doc;
216
+ if (doc && isClassModel(doc) && doc.active) {
217
+ const className = doc.name;
218
+ // Invalidate cached version if present
219
+ fnLogger.info(`Class model was updated. Clearing '${className}' from cache.`);
220
+ delete this.cache[className];
221
+ fnLogger.info(`Successfully cleared '${className}' from cache.`);
222
+ }
223
+ else if (doc && isClassModel(doc) && !doc.active) {
224
+ const className = doc.name;
225
+ fnLogger.info(`Class was deleted. Removing from '${className} from cache.'`);
226
+ } // else
227
+ });
228
+ return classModelChanges;
229
+ };
230
+ this.onClassLock = (className) => {
231
+ const classLockListener = this.db.changes({
232
+ since: 'now',
233
+ live: true,
234
+ include_docs: true,
235
+ filter: (doc) => {
236
+ return doc["~class"] == "~lock" && doc._id == `~lock-propagation-${className}`;
237
+ }
238
+ });
239
+ this.listeners.push(classLockListener);
240
+ return classLockListener;
241
+ };
242
+ this.addClassLock = async (className) => {
243
+ const fnLogger = logger.child({ method: "addClassLock", args: { className } });
244
+ try {
245
+ const existing = await this.db.get(`~lock-propagation-${className}`);
246
+ let _rev = undefined;
247
+ if (existing) {
248
+ _rev = existing._rev;
249
+ }
250
+ const response = await this.db.put({
251
+ _id: `~lock-propagation-${className}`,
252
+ "~class": `~Lock`,
253
+ _rev
254
+ });
255
+ fnLogger.info(`Adding class lock response`, { response });
256
+ return response.ok;
257
+ }
258
+ catch (e) {
259
+ fnLogger.error(`Error while adding class lock: ${e}`);
260
+ return false;
261
+ }
262
+ };
263
+ this.clearClassLock = async (className) => {
264
+ const fnLogger = logger.child({ method: "clearClassLock", args: { className } });
265
+ try {
266
+ const doc = await this.db.get(`~lock-propagation-${className}`);
267
+ fnLogger.info(`Fetched class lock`, { document: doc });
268
+ const response = await this.db.remove(doc);
269
+ fnLogger.info(`Removing class lock response`, { response });
270
+ return response.ok;
271
+ }
272
+ catch (e) {
273
+ fnLogger.error(`Error while adding class lock: ${e}`);
274
+ return false;
275
+ }
276
+ };
277
+ this.onClassDoc = (className) => {
278
+ const onClassDocListener = this.db.changes({
279
+ since: 'now',
280
+ live: true,
281
+ include_docs: true,
282
+ filter: (doc) => {
283
+ return doc["~class"] == className;
284
+ }
285
+ });
286
+ this.listeners.push(onClassDocListener);
287
+ return onClassDocListener;
288
+ };
289
+ /**
290
+ * Closes the stack and cleans up all resources.
291
+ * Removes event listeners and terminates background workers.
292
+ */
293
+ this.close = () => {
294
+ this.removeAllListeners();
295
+ if (this.modelWorker)
296
+ this.modelWorker.terminate();
297
+ };
298
+ /**
299
+ * Retrieves a Class instance by name.
300
+ * Results are cached for 15 minutes to improve performance.
301
+ *
302
+ * @param className - The name or ID of the class to retrieve
303
+ * @param fresh - If `true`, bypasses the cache and fetches from database
304
+ * @returns The Class instance, or `null` if not found
305
+ *
306
+ * @example
307
+ * ```typescript
308
+ * const taskClass = await stack.getClass('Task');
309
+ * if (taskClass) {
310
+ * const tasks = await taskClass.getCards();
311
+ * }
312
+ * ```
313
+ */
314
+ this.getClass = async (className, fresh = false) => {
315
+ const fnLogger = logger.child({ method: "getClass", args: { className, fresh } });
316
+ if (!fresh) {
317
+ // Check if class is in cache and not expired
318
+ if (this.cache[className] && this.cache[className] instanceof Class && Date.now() < this.cache[className].ttl) {
319
+ fnLogger.info("Retrieving class from cache", { ttl: this.cache[className].ttl });
320
+ return this.cache[className];
321
+ }
322
+ }
323
+ const classObj = await Class.fetch(this, className);
324
+ if (classObj) {
325
+ classObj.ttl = Date.now() + 60000 * 15; // 15 minutes expiration
326
+ this.cache[className] = classObj;
327
+ }
328
+ return classObj;
329
+ };
330
+ /**
331
+ * Retrieves a Domain instance by name.
332
+ * Results are cached for 15 minutes to improve performance.
333
+ *
334
+ * @param domainName - The name or ID of the domain to retrieve
335
+ * @param fresh - If `true`, bypasses the cache and fetches from database
336
+ * @returns The Domain instance, or `null` if not found
337
+ */
338
+ this.getDomain = async (domainName, fresh = false) => {
339
+ const fnLogger = logger.child({ method: "getDomain", args: { domainName, fresh } });
340
+ if (!fresh) {
341
+ if (this.cache[domainName] && Date.now() < this.cache[domainName].ttl) {
342
+ fnLogger.info("Retrieving domain from cache", { ttl: this.cache[domainName].ttl });
343
+ return this.cache[domainName];
344
+ }
345
+ }
346
+ const domainObj = await Domain.fetch(this, domainName);
347
+ if (domainObj) {
348
+ domainObj.ttl = Date.now() + 60000 * 15; // 15 minutes expiration
349
+ this.cache[domainName] = domainObj;
350
+ }
351
+ return domainObj;
352
+ };
353
+ /**
354
+ * Finds multiple documents matching a PouchDB/Mango-style selector.
355
+ * Automatically filters to only active documents and applies access policies.
356
+ *
357
+ * @typeParam T - The expected document type
358
+ * @param selector - A PouchDB/Mango query selector
359
+ * @param fields - Optional list of fields to return
360
+ * @param skip - Number of documents to skip (for pagination)
361
+ * @param limit - Maximum number of documents to return
362
+ * @returns Object containing matching documents array
363
+ *
364
+ * @example
365
+ * ```typescript
366
+ * const result = await stack.findDocuments({
367
+ * '~class': { $eq: 'Task' },
368
+ * isComplete: { $eq: false }
369
+ * });
370
+ * console.log('Found tasks:', result.docs.length);
371
+ * ```
372
+ */
373
+ this.findDocuments = async (selector, fields, skip, limit) => {
374
+ var _a;
375
+ const fnLogger = logger.child({ method: "findDocuments", args: { selector, fields, skip, limit } });
376
+ // By default request for only active documents
377
+ if (!selector.hasOwnProperty("active")) {
378
+ selector["active"] = true;
379
+ }
380
+ let indexFields = Object.keys(selector);
381
+ fnLogger.info("Produced index fields from selector", { indexFields });
382
+ let result = {
383
+ docs: []
384
+ };
385
+ try {
386
+ // [TODO] This breaks find method and even db!!
387
+ // let indexResult = await this.db.createIndex({
388
+ // index: { fields: indexFields }
389
+ // });
390
+ // fnLogger.info("Index result", indexResult);
391
+ let foundResult = await this.db.find({
392
+ selector: selector,
393
+ fields: fields,
394
+ skip: skip,
395
+ limit: limit
396
+ });
397
+ if (selector.hasOwnProperty("username")) {
398
+ console.log("Found result", { result: foundResult, selector });
399
+ }
400
+ fnLogger.info("Found", {
401
+ result: foundResult,
402
+ selector: selector,
403
+ });
404
+ const readableDocs = [];
405
+ for (const doc of foundResult.docs) {
406
+ const canRead = await this.policyEngine.isReadableDocument(doc);
407
+ if (!canRead) {
408
+ fnLogger.info("Based on policies, document is not readable", { docId: doc._id, docClass: doc["~class"] });
409
+ console.log("Based on policies, document is not readable", { docId: doc._id, docClass: doc["~class"] });
410
+ continue;
411
+ }
412
+ const encryptedKeys = this.cryptoEngine.identifyEncryptedKeys(doc);
413
+ const classObj = encryptedKeys.length || (fields && fields.length)
414
+ ? (_a = (await this.getClass(doc["~class"], true))) !== null && _a !== void 0 ? _a : undefined
415
+ : undefined;
416
+ const processedDoc = await this.processReadableDocument(doc, classObj, fields, encryptedKeys);
417
+ if (processedDoc) {
418
+ readableDocs.push(processedDoc);
419
+ }
420
+ }
421
+ result = { docs: readableDocs, selector, skip, limit };
422
+ return result;
423
+ }
424
+ catch (e) {
425
+ fnLogger.error("findDocument - error", e);
426
+ throw e;
427
+ }
428
+ };
429
+ this.getClassModel = async (className) => {
430
+ // TODO: understand whether to use name of _id field
431
+ let selector = {
432
+ $or: [
433
+ { name: { $eq: className } },
434
+ { _id: { $eq: className } }
435
+ ],
436
+ // _id: { $eq: className },
437
+ "~class": { $in: ["class", "~self"] }
438
+ };
439
+ try {
440
+ let response = await this.db.find({ selector });
441
+ if (response == null)
442
+ return null;
443
+ let result = response.docs[0];
444
+ logger.info("getClassModel - result", { result: result });
445
+ return result;
446
+ }
447
+ catch (e) {
448
+ logger.info("getClassModel - error", e);
449
+ throw new Error(e);
450
+ }
451
+ };
452
+ this.getDomainModel = async (domainName) => {
453
+ let selector = {
454
+ "~class": { $eq: "domain" },
455
+ name: { $eq: domainName }
456
+ };
457
+ try {
458
+ let response = await this.findDocument(selector);
459
+ if (response == null)
460
+ return null;
461
+ let result = response;
462
+ logger.info("getDomainModel - result", { result: result });
463
+ return result;
464
+ }
465
+ catch (e) {
466
+ logger.info("getDomainModel - error", e);
467
+ throw new Error(e);
468
+ }
469
+ };
470
+ // TODO: move listener to stack field, for easier un-registering
471
+ // TODO: Change into getClass("Class").getCards()
472
+ this.getClassModels = async (conf = {}) => {
473
+ const { listen, filter, search } = conf;
474
+ const selector = { "~class": { $eq: "class" } };
475
+ if (Array.isArray(filter) && filter.length > 0) {
476
+ // TODO: Consider checking against name field instead of _id
477
+ selector._id = { $in: filter };
478
+ }
479
+ // Case 2: A search query (partial match)
480
+ else if (search && typeof search === 'string') {
481
+ // Mango doesn’t have full regex support, so we use $regex via the pouchdb-find plugin.
482
+ selector.$or = [
483
+ { _id: { $regex: RegExp(search, "i") } },
484
+ { name: { $regex: RegExp(search, "i") } },
485
+ { description: { $regex: RegExp(search, "i") } }
486
+ ];
487
+ }
488
+ const fields = ['_id', 'name', 'description', 'schema', '~class', '_rev'];
489
+ const response = await this.findDocuments(selector, fields);
490
+ const result = response.docs;
491
+ if (!conf.listen) {
492
+ return { list: result };
493
+ }
494
+ // Create a live listener via PouchDB changes feed
495
+ const listener = this.db.changes({
496
+ since: 'now',
497
+ live: true,
498
+ include_docs: true,
499
+ selector
500
+ });
501
+ this.listeners.push(listener);
502
+ return {
503
+ list: result,
504
+ listener
505
+ };
506
+ };
507
+ this.getClasses = async (conf) => {
508
+ const classNames = conf.filter;
509
+ const searchFilter = conf.search;
510
+ const fnLogger = logger.child({ method: "getClasses" });
511
+ fnLogger.info("Requesting");
512
+ const { list: classModels, listener } = await this.getClassModels({
513
+ listen: true, filter: classNames, search: searchFilter
514
+ });
515
+ fnLogger.info("Received class models", { classModels });
516
+ const classList = [];
517
+ // Get current class list
518
+ for (const classModel of classModels) {
519
+ fnLogger.info(`Building class "${classModel.name}"`);
520
+ const classObj = await Class.buildFromModel(this, classModel);
521
+ classList.push(classObj);
522
+ }
523
+ // Queue for occasional addition/deletion
524
+ if (listener) {
525
+ listener.on("change", async (change) => {
526
+ if (!change.deleted) {
527
+ const className = change.id;
528
+ fnLogger.info(`Received class model change with "${className}"`);
529
+ const existingIndex = classList.findIndex(c => c.model._id === className);
530
+ const classObj = await Class.buildFromModel(this, change.doc);
531
+ if (existingIndex === -1) {
532
+ classList.push(classObj);
533
+ }
534
+ else {
535
+ classList[existingIndex] = classObj;
536
+ }
537
+ const evt = new CustomEvent("classListChange", { detail: classList });
538
+ this.dispatchEvent(evt);
539
+ }
540
+ else {
541
+ // remove from classList without altering the array reference
542
+ const idx = classList.findIndex(c => c.model._id === change.id);
543
+ if (idx !== -1) {
544
+ classList.splice(idx, 1);
545
+ const evt = new CustomEvent("classListChange", { detail: classList });
546
+ this.dispatchEvent(evt);
547
+ }
548
+ }
549
+ });
550
+ }
551
+ fnLogger.info("Completed inital classes build");
552
+ return classList;
553
+ };
554
+ this.getDomainModels = async (conf = {}) => {
555
+ const { listen, filter, search } = conf;
556
+ const selector = { "~class": { $eq: "domain" } };
557
+ if (Array.isArray(filter) && filter.length > 0) {
558
+ // TODO: Consider checking against name field instead of _id
559
+ selector._id = { $in: filter };
560
+ }
561
+ // Case 2: A search query (partial match)
562
+ else if (search && typeof search === 'string') {
563
+ // Mango doesn’t have full regex support, so we use $regex via the pouchdb-find plugin.
564
+ selector.$or = [
565
+ { _id: { $regex: RegExp(search, "i") } },
566
+ { name: { $regex: RegExp(search, "i") } },
567
+ { description: { $regex: RegExp(search, "i") } }
568
+ ];
569
+ }
570
+ const fields = ['_id', 'name', 'description', 'schema', '~class', '_rev'];
571
+ const response = await this.findDocuments(selector, fields);
572
+ const result = response.docs;
573
+ if (!conf.listen) {
574
+ return { list: result };
575
+ }
576
+ // Create a live listener via PouchDB changes feed
577
+ const listener = this.db.changes({
578
+ since: 'now',
579
+ live: true,
580
+ include_docs: true,
581
+ selector
582
+ });
583
+ return {
584
+ list: result,
585
+ listener
586
+ };
587
+ };
588
+ this.getDomains = async (conf) => {
589
+ const classNames = conf.filter;
590
+ const searchFilter = conf.search;
591
+ const fnLogger = logger.child({ method: "getDomains" });
592
+ fnLogger.info("Requesting");
593
+ const { list: domainModels, listener } = await this.getDomainModels({
594
+ listen: true, filter: classNames, search: searchFilter
595
+ });
596
+ fnLogger.info("Received class models", { domainModels });
597
+ const domainList = [];
598
+ // Get current class list
599
+ for (const domainModel of domainModels) {
600
+ fnLogger.info(`Building class "${domainModel.name}"`);
601
+ const domain = await Domain.buildFromModel(this, domainModel);
602
+ domainList.push(domain);
603
+ }
604
+ // Queue for occasional addition/deletion
605
+ if (listener) {
606
+ listener.on("change", async (change) => {
607
+ if (!change.deleted) {
608
+ const domainName = change.id;
609
+ fnLogger.info(`Received class model change with "${domainName}"`);
610
+ const existingIndex = domainList.findIndex(c => c.model._id === domainName);
611
+ const domain = await Domain.buildFromModel(this, change.doc);
612
+ if (existingIndex === -1) {
613
+ domainList.push(domain);
614
+ }
615
+ else {
616
+ domainList[existingIndex] = domain;
617
+ }
618
+ const evt = new CustomEvent("domainListChange", { detail: domainList });
619
+ this.dispatchEvent(evt);
620
+ }
621
+ else {
622
+ // remove from classList without altering the array reference
623
+ const idx = domainList.findIndex(c => c.model._id === change.id);
624
+ if (idx !== -1) {
625
+ domainList.splice(idx, 1);
626
+ const evt = new CustomEvent("domainListChange", { detail: domainList });
627
+ this.dispatchEvent(evt);
628
+ }
629
+ }
630
+ });
631
+ }
632
+ fnLogger.info("Completed inital domains build");
633
+ return domainList;
634
+ };
635
+ this.addClass = async (classObj) => {
636
+ const fnLogger = logger.child({ method: "addClass", args: { class: classObj.name } });
637
+ const classOrigin = await this.getClass(classObj.type);
638
+ if (classOrigin == null) {
639
+ fnLogger.error("Class originator not found", { classType: classObj.type });
640
+ throw new Error(`Class originator ${classObj.type} not found in stack`);
641
+ }
642
+ let classModel = classObj.getModel();
643
+ fnLogger.info("Got class model", { classModel });
644
+ try {
645
+ const result = await classOrigin.addCard(classModel);
646
+ fnLogger.info("Added class card", { result });
647
+ await this.ensureDefaultPolicyForClass(result);
648
+ return result;
649
+ }
650
+ catch (e) {
651
+ fnLogger.error("Error adding class card", { error: e });
652
+ const message = (e === null || e === void 0 ? void 0 : e.message) || "Failed to add class card";
653
+ throw new Error(message);
654
+ }
655
+ // let existingDoc = await this.getClassModel(classModel.name);
656
+ // if ( existingDoc == null ) {
657
+ // let resultDoc = await this.createDoc(classModel.name, 'class', CLASS_SCHEMA, classModel);
658
+ // fnLogger.info("Result", {result: resultDoc});
659
+ // // TODO: Consider creating a design doc for easier filtering
660
+ // return resultDoc as ClassModel;
661
+ // } else {
662
+ // return existingDoc;
663
+ // }
664
+ };
665
+ this.addDomain = async (domainObj) => {
666
+ const fnLogger = logger.child({ method: "addDomain", args: { domain: domainObj.name } });
667
+ let domainModel = domainObj.getModel();
668
+ fnLogger.info("Got domain model", { domainModel });
669
+ let existingDoc = await this.getDomainModel(domainModel.name);
670
+ if (existingDoc == null) {
671
+ let resultDoc = await this.createDoc(domainModel.name, 'domain', DOMAIN_SCHEMA, domainModel);
672
+ fnLogger.info("Result", { result: resultDoc });
673
+ // TODO: Consider creating a design doc for easier filtering
674
+ return resultDoc;
675
+ }
676
+ else {
677
+ return existingDoc;
678
+ }
679
+ };
680
+ this.updateClass = async (classObj) => {
681
+ const fnLogger = logger.child({ method: "updateClass", args: { class: classObj.name } });
682
+ let result = await this.createDoc(classObj.getId(), 'class', classObj, classObj.getModel());
683
+ fnLogger.info("Result", result);
684
+ return result;
685
+ };
686
+ this.addDesignDocumentPKs = async (className, pKs, temp = false) => {
687
+ const fnLogger = logger.child({ method: 'addDesignDocumentPKs', args: { className, pKs } });
688
+ // Construct the compound key string dynamically
689
+ const keyString = pKs.map(key => `doc.${key}`).join(', ');
690
+ // The 'map' function as a string
691
+ const mapCode = `function (doc) {
692
+ const hasAllKeys = ${pKs.map(key => `doc.${key}`).join(' && ')};
693
+ if (hasAllKeys && doc["~class"] === '${className}') {
694
+ emit([${keyString}], doc._id);
695
+ }
696
+ }`;
697
+ fnLogger.info("Generated map code", { code: mapCode });
698
+ let designDocId = `_design/${className}-group`;
699
+ if (temp)
700
+ designDocId = `_design/${className}-group-temp`;
701
+ const ddoc = {
702
+ _id: designDocId,
703
+ views: {
704
+ 'by_pKeys': {
705
+ map: mapCode
706
+ }
707
+ },
708
+ _rev: undefined,
709
+ };
710
+ fnLogger.info("Prepared design document", { ddoc });
711
+ try {
712
+ // Use 'get' to check if the design doc already exists
713
+ const existingDoc = await this.db.get(designDocId);
714
+ ddoc._rev = existingDoc._rev; // Add _rev to update the existing doc
715
+ await this.db.put(ddoc);
716
+ fnLogger.info('Design document updated successfully.');
717
+ }
718
+ catch (err) {
719
+ if (err.name === 'not_found') {
720
+ // Doc doesn't exist, create it
721
+ await this.db.put(ddoc);
722
+ fnLogger.info('Design document created successfully.');
723
+ }
724
+ else {
725
+ fnLogger.error('Error saving design document:', err);
726
+ throw err;
727
+ }
728
+ }
729
+ return designDocId;
730
+ };
731
+ /**
732
+ * Creates or updates a single document in the database.
733
+ *
734
+ * If `docId` is provided and the document exists, it will be updated.
735
+ * If `docId` is `null`, a new ID will be auto-generated in the format `{type}-{incrementalId}`.
736
+ * Access policies are enforced before writing.
737
+ *
738
+ * @param docId - The document ID, or `null` to auto-generate
739
+ * @param type - The class name (e.g., 'Task', 'User')
740
+ * @param classObj - The Class instance or schema definition for validation
741
+ * @param params - The document data to save
742
+ * @returns The created or updated document
743
+ * @throws Error if policy check fails or document type conflicts
744
+ *
745
+ * @example
746
+ * ```typescript
747
+ * // Create with auto-generated ID
748
+ * const task = await stack.createDoc(null, 'Task', taskClass, {
749
+ * title: 'New Task',
750
+ * isComplete: false
751
+ * });
752
+ *
753
+ * // Update existing document
754
+ * await stack.createDoc('Task-123', 'Task', taskClass, {
755
+ * title: 'Updated Title'
756
+ * });
757
+ * ```
758
+ */
759
+ this.createDoc = async (docId, type, classObj, params) => {
760
+ var _a;
761
+ const fnLogger = logger.child({ method: "createDoc", args: { docId, type, params } });
762
+ fnLogger.info("Creating document");
763
+ let schema = {};
764
+ if (classObj instanceof Class) {
765
+ schema = classObj.buildSchema();
766
+ }
767
+ else {
768
+ schema = classObj;
769
+ }
770
+ let db = this.db, doc = null, isNewDoc = false, newDocId = "";
771
+ try {
772
+ if (docId) {
773
+ const existingDoc = await this.getDocument(docId);
774
+ fnLogger.info("Retrieved doc", { existingDoc });
775
+ // console.log("Existing doc", {existingDoc, params})
776
+ if (existingDoc && existingDoc["~class"] === type) {
777
+ fnLogger.info("Assigning existing doc", { doc: existingDoc });
778
+ doc = Object.assign({}, existingDoc);
779
+ }
780
+ else if (existingDoc && existingDoc["~class"] !== type) {
781
+ fnLogger.error("Existing document type differs");
782
+ throw new Error("createDoc - Existing document type differs");
783
+ }
784
+ else {
785
+ isNewDoc = true;
786
+ newDocId = docId;
787
+ doc = this.prepareDoc(newDocId, type, params, "~class");
788
+ }
789
+ }
790
+ else {
791
+ isNewDoc = true;
792
+ newDocId = `${type}-${(this.lastDocId + 1)}`;
793
+ doc = this.prepareDoc(newDocId, type, params, "~class");
794
+ fnLogger.info("Generated docId", { newDocId });
795
+ }
796
+ fnLogger.info("Doc BEFORE elaboration (i.e. merge)", { doc, params });
797
+ let doc_ = Object.assign(Object.assign(Object.assign({}, doc), params), { _rev: doc._rev, "~updateTimestamp": new Date().getTime() });
798
+ if (type === "~User" || type === "User") {
799
+ const groups = doc_.groupId;
800
+ if (!groups || (Array.isArray(groups) && groups.length === 0)) {
801
+ doc_.groupId = ["Group-Default"];
802
+ }
803
+ }
804
+ if (type === "~UserSession" || type === "UserSession") {
805
+ let sessionGroups = doc_.groupId;
806
+ if (!sessionGroups || (Array.isArray(sessionGroups) && sessionGroups.length === 0)) {
807
+ const sessionUserId = doc_.userId;
808
+ if (sessionUserId) {
809
+ const relatedUser = await this.getDocument(sessionUserId).catch(() => null);
810
+ if (relatedUser === null || relatedUser === void 0 ? void 0 : relatedUser.groupId) {
811
+ sessionGroups = relatedUser.groupId;
812
+ }
813
+ }
814
+ if (!sessionGroups || (Array.isArray(sessionGroups) && sessionGroups.length === 0)) {
815
+ sessionGroups = ["Group-Default"];
816
+ }
817
+ doc_.groupId = sessionGroups;
818
+ }
819
+ }
820
+ if ((_a = doc_["~class"]) === null || _a === void 0 ? void 0 : _a.startsWith("Account-")) {
821
+ // console.log("Doc after merge", { doc_ })
822
+ }
823
+ fnLogger.info("Doc AFTER elaboration (i.e. merge)", { doc_ });
824
+ await this.policyEngine.ensureWriteAllowed(type, doc_);
825
+ let response = await db.put(doc_);
826
+ // Find me
827
+ fnLogger.info("Response after put", { "response": response });
828
+ if (response.ok && isNewDoc) {
829
+ await this.incrementLastDocId();
830
+ docId = response.id;
831
+ }
832
+ else if (response.ok) {
833
+ docId = response.id;
834
+ }
835
+ else {
836
+ fnLogger.error("Error, check logs", { "response": response });
837
+ throw new Error("createDoc - Error, check logs");
838
+ }
839
+ }
840
+ catch (e) {
841
+ if (e.name === 'conflict') {
842
+ fnLogger.info("Conflict! Ignoring..");
843
+ // TODO: Handle conflict!
844
+ }
845
+ else {
846
+ fnLogger.info("Problem while putting doc", {
847
+ "error": e,
848
+ "document": doc
849
+ });
850
+ throw new Error("createDoc - Problem while putting doc" + e);
851
+ }
852
+ }
853
+ return doc;
854
+ };
855
+ /**
856
+ * Creates or updates multiple documents in a single batch operation.
857
+ * More efficient than calling {@link createDoc} multiple times.
858
+ *
859
+ * @param docs - Array of document specifications with optional docId and params
860
+ * @param type - The class name for all documents
861
+ * @param classObj - The Class instance or schema definition for validation
862
+ * @returns Array of created or updated documents
863
+ * @throws Error if policy check fails for any document
864
+ *
865
+ * @example
866
+ * ```typescript
867
+ * const tasks = await stack.createDocs([
868
+ * { docId: null, params: { title: 'Task 1' } },
869
+ * { docId: null, params: { title: 'Task 2' } },
870
+ * { docId: 'Task-existing', params: { title: 'Updated' } }
871
+ * ], 'Task', taskClass);
872
+ * ```
873
+ */
874
+ this.createDocs = async (docs, type, classObj) => {
875
+ const fnLogger = logger.child({ method: "createDocs", args: { docs } });
876
+ let schema = {};
877
+ if (classObj instanceof Class) {
878
+ schema = classObj.buildSchema();
879
+ }
880
+ else {
881
+ schema = classObj;
882
+ }
883
+ fnLogger.info("Determined schema", { schema });
884
+ let db = this.db;
885
+ const documents = [];
886
+ let newDocsIds = [];
887
+ for (const draft of docs) {
888
+ let { docId, params } = draft;
889
+ let doc = null;
890
+ let isNewDoc = false;
891
+ try {
892
+ if (docId) {
893
+ const existingDoc = await this.getDocument(docId);
894
+ fnLogger.info("retrieved doc", { existingDoc });
895
+ if (existingDoc && existingDoc["~class"] === type) {
896
+ fnLogger.info("createDocs - assigning existing doc", { doc: existingDoc });
897
+ doc = Object.assign({}, existingDoc);
898
+ }
899
+ else if (existingDoc && existingDoc["~class"] !== type) {
900
+ throw new Error("createDocs - Existing document type differs");
901
+ }
902
+ else {
903
+ isNewDoc = true;
904
+ doc = this.prepareDoc(docId, type, params, "~class");
905
+ }
906
+ }
907
+ else {
908
+ docId = `${type}-${(this.lastDocId + 1)}`;
909
+ doc = this.prepareDoc(docId, type, params, "~class");
910
+ isNewDoc = true;
911
+ fnLogger.info("Generated docId", docId);
912
+ }
913
+ fnLogger.info("Doc BEFORE elaboration (i.e. merge)", { doc, params });
914
+ const doc_ = Object.assign(Object.assign(Object.assign({}, doc), params), { _id: docId, _rev: doc._rev, "~updateTimestamp": new Date().getTime() });
915
+ fnLogger.info("Doc AFTER elaboration (i.e. merge)", { doc_ });
916
+ await this.policyEngine.ensureWriteAllowed(type, doc_);
917
+ documents.push(doc_);
918
+ if (isNewDoc)
919
+ newDocsIds.push(docId);
920
+ }
921
+ catch (e) {
922
+ fnLogger.error("createDocs - Problem while preparing doc", {
923
+ "error": e,
924
+ "document": doc
925
+ });
926
+ throw new Error("createDocs - Problem while preparing doc" + e);
927
+ }
928
+ }
929
+ try {
930
+ const response = await db.bulkDocs(documents);
931
+ fnLogger.info("Response after bulkDocs", { "response": response });
932
+ // Increment lastDocId based on number of new docs created
933
+ const newDocsCount = response.filter(res => res.id != null && newDocsIds.includes(res.id)).length;
934
+ fnLogger.info(`Successfully created ${newDocsCount} new documents.`);
935
+ for (let i = 0; i < newDocsCount; i++) {
936
+ await this.incrementLastDocId();
937
+ }
938
+ }
939
+ catch (e) {
940
+ fnLogger.error("createDocs - Problem while putting docs", {
941
+ "error": e,
942
+ "documents": documents
943
+ });
944
+ throw new Error("createDocs - Problem while putting docs" + e);
945
+ }
946
+ return documents;
947
+ };
948
+ /**
949
+ * Creates a relation document linking two entities via a Domain.
950
+ * Relation documents represent relationships between documents (e.g., 1:N, N:N).
951
+ *
952
+ * @param docId - The relation document ID, or `null` to auto-generate
953
+ * @param relationName - A descriptive name for this relation instance
954
+ * @param domainObj - The Domain defining the relationship type
955
+ * @param params - The relation parameters including source and target references
956
+ * @returns The created relation document, or `null` on error
957
+ *
958
+ * @example
959
+ * ```typescript
960
+ * const relation = await stack.createRelationDoc(
961
+ * null,
962
+ * 'ProjectTask',
963
+ * projectTaskDomain,
964
+ * {
965
+ * sourceClass: 'Project',
966
+ * targetClass: 'Task',
967
+ * sourceId: 'Project-1',
968
+ * targetId: 'Task-42'
969
+ * }
970
+ * );
971
+ * ```
972
+ */
973
+ this.createRelationDoc = async (docId, relationName, domainObj, params) => {
974
+ const fnLogger = logger.child({ method: "createRelationDoc", args: { docId, relationName, params } });
975
+ fnLogger.info("Creating relation document");
976
+ let db = this.db, doc = null, isNewDoc = false;
977
+ try {
978
+ if (docId) {
979
+ const existingDoc = await this.db.get(docId);
980
+ fnLogger.info("retrieved doc", { existingDoc });
981
+ if (existingDoc && existingDoc["~domain"] === domainObj.name) {
982
+ fnLogger.info("Assigning existing doc", { doc: existingDoc });
983
+ doc = Object.assign({}, existingDoc);
984
+ }
985
+ else if (existingDoc && existingDoc["~domain"] !== domainObj.name) {
986
+ fnLogger.error("Existing document type differs");
987
+ throw new Error("createDoc - Existing document type differs");
988
+ }
989
+ else {
990
+ fnLogger.warn("No relation document");
991
+ isNewDoc = true;
992
+ doc = this.prepareDoc(docId, domainObj.name, params, "~domain");
993
+ }
994
+ }
995
+ else {
996
+ docId = `${domainObj.name}-${(this.lastDocId + 1)}`;
997
+ doc = this.prepareDoc(docId, domainObj.name, params, "~domain");
998
+ isNewDoc = true;
999
+ fnLogger.info("Generated docId", docId);
1000
+ }
1001
+ fnLogger.info("Doc BEFORE elaboration (i.e. merge)", { doc, params });
1002
+ const doc_ = Object.assign(Object.assign(Object.assign({}, doc), params), { _id: docId, _rev: doc._rev, "~updateTimestamp": new Date().getTime() });
1003
+ fnLogger.info("Doc AFTER elaboration (i.e. merge)", { doc_ });
1004
+ let response = await db.put(doc_);
1005
+ fnLogger.info("Response after put", { "response": response });
1006
+ if (response.ok && isNewDoc) {
1007
+ await this.incrementLastDocId();
1008
+ docId = response.id;
1009
+ }
1010
+ else if (response.ok) {
1011
+ docId = response.id;
1012
+ }
1013
+ else {
1014
+ fnLogger.error("Error, check logs", { "response": response });
1015
+ throw new Error("createDoc - Error, check logs");
1016
+ }
1017
+ }
1018
+ catch (e) {
1019
+ fnLogger.error("Error while creating relation document", { error: e });
1020
+ }
1021
+ return doc;
1022
+ };
1023
+ /**
1024
+ * Creates multiple relation documents in a single batch operation.
1025
+ * More efficient than calling {@link createRelationDoc} multiple times.
1026
+ *
1027
+ * @param docs - Array of relation specifications
1028
+ * @param relationName - A descriptive name for these relations
1029
+ * @param domainObj - The Domain defining the relationship type
1030
+ * @returns Array of created relation documents
1031
+ *
1032
+ * @example
1033
+ * ```typescript
1034
+ * const relations = await stack.createRelationDocs([
1035
+ * { docId: null, params: { sourceClass: 'Project', targetClass: 'Task', sourceId: 'Project-1', targetId: 'Task-1' } },
1036
+ * { docId: null, params: { sourceClass: 'Project', targetClass: 'Task', sourceId: 'Project-1', targetId: 'Task-2' } }
1037
+ * ], 'ProjectTasks', projectTaskDomain);
1038
+ * ```
1039
+ */
1040
+ this.createRelationDocs = async (docs, relationName, domainObj) => {
1041
+ const fnLogger = logger.child({ method: "createRelationDocs", args: { docs, relationName } });
1042
+ let db = this.db;
1043
+ const documents = [];
1044
+ let newDocsIds = [];
1045
+ for (const draft of docs) {
1046
+ let { docId, params } = draft;
1047
+ let doc = null;
1048
+ let isNewDoc = false;
1049
+ try {
1050
+ if (docId) {
1051
+ const existingDoc = await db.get(docId);
1052
+ fnLogger.info("retrieved doc", { existingDoc });
1053
+ if (existingDoc && existingDoc["~domain"] === domainObj.name) {
1054
+ fnLogger.info("createRelationDocs - assigning existing doc", { doc: existingDoc });
1055
+ doc = Object.assign({}, existingDoc);
1056
+ }
1057
+ else if (existingDoc && existingDoc["~domain"] !== domainObj.name) {
1058
+ throw new Error("createRelationDocs - Existing document type differs");
1059
+ }
1060
+ else {
1061
+ isNewDoc = true;
1062
+ doc = this.prepareDoc(docId, domainObj.name, params, "~domain");
1063
+ }
1064
+ }
1065
+ else {
1066
+ docId = `${domainObj.name}-${(this.lastDocId + 1)}`;
1067
+ doc = this.prepareDoc(docId, domainObj.name, params, "~domain");
1068
+ isNewDoc = true;
1069
+ fnLogger.info("Generated docId", docId);
1070
+ }
1071
+ fnLogger.info("Doc BEFORE elaboration (i.e. merge)", { doc, params });
1072
+ const doc_ = Object.assign(Object.assign(Object.assign({}, doc), params), { _id: docId, _rev: doc._rev, "~updateTimestamp": new Date().getTime() });
1073
+ fnLogger.info("Doc AFTER elaboration (i.e. merge)", { doc_ });
1074
+ documents.push(doc_);
1075
+ if (isNewDoc)
1076
+ newDocsIds.push(docId);
1077
+ }
1078
+ catch (e) {
1079
+ fnLogger.error("createRelationDocs - Problem while preparing doc", {
1080
+ "error": e,
1081
+ "document": doc
1082
+ });
1083
+ throw new Error("createRelationDocs - Problem while preparing doc" + e);
1084
+ }
1085
+ }
1086
+ try {
1087
+ // console.log("Documents to be created", {documents});
1088
+ const response = await db.bulkDocs(documents);
1089
+ fnLogger.info("Response after bulkDocs", { "response": response });
1090
+ // Increment lastDocId based on number of new docs created
1091
+ const newDocsCount = response.filter(res => res.id != null && newDocsIds.includes(res.id)).length;
1092
+ fnLogger.info(`Successfully created ${newDocsCount} new documents.`);
1093
+ for (let i = 0; i < newDocsCount; i++) {
1094
+ await this.incrementLastDocId();
1095
+ }
1096
+ }
1097
+ catch (e) {
1098
+ fnLogger.error("createRelationDocs - Problem while putting docs", {
1099
+ "error": e,
1100
+ "documents": documents
1101
+ });
1102
+ throw new Error("createRelationDocs - Problem while putting docs" + e);
1103
+ }
1104
+ return documents;
1105
+ };
1106
+ /**
1107
+ * Sets the active param of a document to false
1108
+ * @param _id
1109
+ * @returns Promise<boolean>
1110
+ */
1111
+ this.deleteDocument = async (_id) => {
1112
+ const fnLogger = logger.child({ method: "deleteDocument", args: { _id } });
1113
+ const doc = await this.db.get(_id);
1114
+ if (doc) {
1115
+ try {
1116
+ const targetClass = doc["~class"];
1117
+ await this.policyEngine.ensureWriteAllowed(targetClass, doc);
1118
+ await this.db.put(Object.assign(Object.assign({}, doc), { active: false }));
1119
+ return true;
1120
+ }
1121
+ catch (e) {
1122
+ fnLogger.error(`Error while deleting document: ${e}`, { document: doc });
1123
+ return false;
1124
+ }
1125
+ }
1126
+ else {
1127
+ fnLogger.error("Found no document with given id");
1128
+ return false;
1129
+ }
1130
+ };
1131
+ /**
1132
+ * Executes a SQL query against the local database.
1133
+ * Supports SELECT, JOIN, WHERE, ORDER BY, GROUP BY, and UNION operations.
1134
+ *
1135
+ * @param sql - The SQL query string
1136
+ * @param params - Optional query parameters for prepared statements
1137
+ * @returns Object containing result rows and the parsed AST
1138
+ *
1139
+ * @example
1140
+ * ```typescript
1141
+ * // Simple select
1142
+ * const { rows } = await stack.query('SELECT * FROM Task WHERE isComplete = false');
1143
+ *
1144
+ * // Join with ordering
1145
+ * const { rows } = await stack.query(`
1146
+ * SELECT t.title, u.username AS assignee
1147
+ * FROM Task AS t
1148
+ * JOIN User AS u ON u._id = t.assigneeId
1149
+ * ORDER BY t.createdAt DESC
1150
+ * `);
1151
+ *
1152
+ * // With parameters
1153
+ * const { rows } = await stack.query('SELECT * FROM Task WHERE priority = ?', 'high');
1154
+ * ```
1155
+ */
1156
+ this.query = async (sql, ...params) => {
1157
+ const fnLogger = logger.child({ method: "query", args: { sql, params } });
1158
+ fnLogger.info("Executing query");
1159
+ let astList = [];
1160
+ try {
1161
+ astList = parse(sql);
1162
+ fnLogger.info("Produced AST", { astList });
1163
+ }
1164
+ catch (error) {
1165
+ error.ast = astList.length > 0 ? astList[0] : null;
1166
+ throw error;
1167
+ }
1168
+ // A UNION query is treated as a single execution, not a loop over ASTs.
1169
+ if (astList.length > 0) {
1170
+ try {
1171
+ const plan = createPlan(astList);
1172
+ const rows = await executePlan(this, plan, params);
1173
+ // The AST for the whole query (including unions) is the list
1174
+ fnLogger.info("Query executed successfully", { rows, astList });
1175
+ return { rows, ast: astList };
1176
+ }
1177
+ catch (error) {
1178
+ error.ast = astList; // Attach full AST list to error for debugging
1179
+ throw error;
1180
+ }
1181
+ }
1182
+ // Handle case where query is empty or only comments
1183
+ return { rows: [], ast: null };
1184
+ };
1185
+ // Private constructor to prevent direct instantiation
1186
+ this.cache = {};
1187
+ }
1188
+ async initialize(conn, options) {
1189
+ // Store the connection string and options
1190
+ this.connection = conn;
1191
+ this.options = options;
1192
+ this.cryptoEngineDisabled = Boolean(options === null || options === void 0 ? void 0 : options.disableCryptoEngine);
1193
+ if (options === null || options === void 0 ? void 0 : options.name) {
1194
+ this.name = options === null || options === void 0 ? void 0 : options.name;
1195
+ }
1196
+ const connRegExp = /(?<=db-).*/;
1197
+ const match = conn.match(connRegExp);
1198
+ if (match) {
1199
+ this.name = match[0];
1200
+ }
1201
+ else {
1202
+ this.name = conn;
1203
+ }
1204
+ // PouchDB.plugin((await import('pouchdb-adapter-node-websql')).default);
1205
+ // PouchDB.plugin((await import('pouchdb-adapter-websql')).default);
1206
+ // Load default plugins
1207
+ PouchDB.plugin(PouchDBFind);
1208
+ PouchDB.plugin(StackPlugin(PouchDB, this, conn));
1209
+ // Validation plugin
1210
+ if (options === null || options === void 0 ? void 0 : options.plugins) {
1211
+ for (let plugin of options.plugins) {
1212
+ PouchDB.plugin(plugin);
1213
+ }
1214
+ }
1215
+ this.db = new PouchDB(conn);
1216
+ this.cache = {
1217
+ // empty at init
1218
+ };
1219
+ this.jobEngine = new JobEngine(this);
1220
+ this.policyEngine = new PolicyEngine(this);
1221
+ this.cryptoEngine = new CryptoEngine(this);
1222
+ }
1223
+ /**
1224
+ * Returns the underlying PouchDB database instance.
1225
+ * @returns The PouchDB database
1226
+ */
1227
+ getDb() {
1228
+ return this.db;
1229
+ }
1230
+ /**
1231
+ * Retrieves information about the database including document count and update sequence.
1232
+ * @returns Database information object
1233
+ */
1234
+ async getDbInfo() {
1235
+ return this.db.info();
1236
+ }
1237
+ /**
1238
+ * Returns the name of the underlying PouchDB database.
1239
+ * @returns The database name string
1240
+ */
1241
+ getDbName() {
1242
+ return this.db.name;
1243
+ }
1244
+ /**
1245
+ * Checks if the crypto engine was disabled during stack initialization.
1246
+ * @returns `true` if encryption is disabled, `false` otherwise
1247
+ */
1248
+ isCryptoEngineDisabled() {
1249
+ return this.cryptoEngineDisabled;
1250
+ }
1251
+ /**
1252
+ * Sets the current authentication session.
1253
+ * Called automatically by {@link authenticate}, but can be set manually for custom auth flows.
1254
+ * @param proof - The authentication session proof containing session and encryption keys
1255
+ */
1256
+ setAuthSession(proof) {
1257
+ this.authSession = proof;
1258
+ }
1259
+ /**
1260
+ * Clears the current authentication session and removes the document encryption key.
1261
+ * Call this when a user logs out.
1262
+ */
1263
+ clearAuthSession() {
1264
+ this.authSession = undefined;
1265
+ this.cryptoEngine.setDocumentKey(null);
1266
+ }
1267
+ async ensureDefaultPolicyForClass(targetClass) {
1268
+ const fnLogger = logger.child({ method: "ensureDefaultPolicyForClass", targetClass: targetClass._id });
1269
+ const existingPolicy = await this.findDocument({
1270
+ "~class": { $eq: "~Policy" },
1271
+ targetClass: { $elemMatch: { $eq: targetClass._id } }
1272
+ });
1273
+ if (existingPolicy) {
1274
+ return;
1275
+ }
1276
+ const policyDoc = {
1277
+ _id: `Policy-${targetClass._id}`,
1278
+ "~class": "~Policy",
1279
+ rule: "return session && session.sessionStatus === 'active';",
1280
+ description: `Default policy for ${targetClass.name || targetClass._id}`,
1281
+ targetClass: [targetClass._id],
1282
+ };
1283
+ fnLogger.info("Creating default policy", { policyDoc });
1284
+ try {
1285
+ await this.db.bulkDocs([policyDoc]);
1286
+ }
1287
+ catch (error) {
1288
+ throw new Error(`Failed to create default policy for ${targetClass._id}: ${(error === null || error === void 0 ? void 0 : error.message) || error}`);
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Creates and initializes a new ClientStack instance.
1293
+ * This is the primary way to instantiate a stack - the constructor is private.
1294
+ *
1295
+ * @param conn - The connection string or database name
1296
+ * @param options - Optional configuration including plugins, patches, and credentials
1297
+ * @returns A fully initialized ClientStack instance
1298
+ *
1299
+ * @example
1300
+ * ```typescript
1301
+ * // Basic initialization
1302
+ * const stack = await ClientStack.create('my-app-db');
1303
+ *
1304
+ * // With authentication
1305
+ * const stack = await ClientStack.create('my-app-db', {
1306
+ * credentials: { username: 'admin', password: 'secret' }
1307
+ * });
1308
+ *
1309
+ * // With custom patches
1310
+ * const stack = await ClientStack.create('my-app-db', {
1311
+ * patches: [myCustomPatch]
1312
+ * });
1313
+ * ```
1314
+ */
1315
+ static async create(conn, options) {
1316
+ const stack = new ClientStack();
1317
+ await stack.initialize(conn, options);
1318
+ await stack.initdb();
1319
+ if ((options === null || options === void 0 ? void 0 : options.patches) && options.patches.length) {
1320
+ const patches = await stack.findDocuments({
1321
+ "~class": { $eq: "patch" }
1322
+ });
1323
+ for (const patch of options.patches.filter(p => !patches.docs.find(existing => existing.version === p.version
1324
+ && existing.target === p.target))) {
1325
+ await stack.applyPatch(patch);
1326
+ }
1327
+ }
1328
+ if (options === null || options === void 0 ? void 0 : options.credentials) {
1329
+ await stack.authenticate(options.credentials);
1330
+ }
1331
+ return stack;
1332
+ }
1333
+ /**
1334
+ * Authenticates a user and establishes a session.
1335
+ *
1336
+ * This method:
1337
+ * 1. Looks up the user by username
1338
+ * 2. Executes the configured authentication job (e.g., password verification)
1339
+ * 3. Creates a new session document
1340
+ * 4. Sets up encryption keys for the session
1341
+ *
1342
+ * @param credentials - The user's login credentials containing username and password
1343
+ * @returns The authentication session proof containing session info and encryption keys
1344
+ * @throws Error if the user is not found or authentication fails
1345
+ *
1346
+ * @example
1347
+ * ```typescript
1348
+ * const proof = await stack.authenticate({
1349
+ * username: 'john.doe',
1350
+ * password: 'securePassword123'
1351
+ * });
1352
+ * console.log('Logged in as:', proof.session.username);
1353
+ * ```
1354
+ */
1355
+ async authenticate(credentials) {
1356
+ var _a, _b, _c;
1357
+ const { username, password } = credentials;
1358
+ const userQuery = await this.db.find({
1359
+ selector: {
1360
+ "~class": { $eq: "~User" },
1361
+ username: { $eq: username },
1362
+ active: { $eq: true }
1363
+ }
1364
+ });
1365
+ const user = userQuery.docs.length ? userQuery.docs[0] : null;
1366
+ if (!user) {
1367
+ throw new Error(`User '${username}' not found`);
1368
+ }
1369
+ const authModuleId = user.authMethod || "AuthMod-Classic";
1370
+ const authModule = await this.db.get(authModuleId);
1371
+ const jobId = authModule.jobId;
1372
+ const run = await this.jobEngine.executeJob(jobId, {
1373
+ password,
1374
+ salt: user.keyDerivationSalt,
1375
+ keyDerivationSalt: user.keyDerivationSalt,
1376
+ });
1377
+ const derivedKey = (_b = (_a = run.finalMetadata) === null || _a === void 0 ? void 0 : _a.derivedKey) !== null && _b !== void 0 ? _b : (_c = run.initialMetadata) === null || _c === void 0 ? void 0 : _c.derivedKey;
1378
+ const userGroups = Array.isArray(user.groupId)
1379
+ ? user.groupId
1380
+ : user.groupId
1381
+ ? [user.groupId]
1382
+ : ["Group-Default"];
1383
+ const randomBytes = new Uint8Array(8);
1384
+ globalThis.crypto.getRandomValues(randomBytes);
1385
+ const hexId = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('');
1386
+ const sessionId = `session-${globalThis.crypto.randomUUID ? globalThis.crypto.randomUUID() : hexId}`;
1387
+ const sessionDoc = {
1388
+ _id: sessionId,
1389
+ "~class": "~UserSession",
1390
+ userId: user._id || user.username,
1391
+ groupId: userGroups,
1392
+ username: user.username,
1393
+ sessionId,
1394
+ sessionStart: new Date().toISOString(),
1395
+ sessionStatus: "active",
1396
+ };
1397
+ const sessionClassModel = (await this.getClassModel("~UserSession")) || (await this.getClassModel("UserSession"));
1398
+ const sessionSchema = (sessionClassModel === null || sessionClassModel === void 0 ? void 0 : sessionClassModel.schema) || {};
1399
+ await this.createDoc(sessionDoc._id, sessionDoc["~class"], sessionSchema, sessionDoc);
1400
+ // TODO: Initially the wrappedDocumentKey is missing for the system user,
1401
+ // perhaps the documentKey hasn't been set yet?
1402
+ const documentKey = await this.cryptoEngine.unwrapAndStoreDocumentKey(user.wrappedDocumentKey, derivedKey);
1403
+ const proof = { session: sessionDoc, derivedKey, documentKey: documentKey !== null && documentKey !== void 0 ? documentKey : undefined };
1404
+ this.setAuthSession(proof);
1405
+ await this.ensureCryptoMarkerEncryption();
1406
+ return proof;
1407
+ }
1408
+ async getLastDocId() {
1409
+ let lastDocId = 0;
1410
+ try {
1411
+ let doc = await this.db.get("lastDocId");
1412
+ lastDocId = doc.value;
1413
+ }
1414
+ catch (e) {
1415
+ if (e.name === 'not_found') {
1416
+ logger.info("getLastDocId - not found. Must be first initialization.");
1417
+ return lastDocId;
1418
+ }
1419
+ logger.error("checkdb - something went wrong", { "error": e });
1420
+ }
1421
+ return lastDocId;
1422
+ }
1423
+ async getSystem() {
1424
+ try {
1425
+ let doc = await this.db.get("~system");
1426
+ return doc;
1427
+ }
1428
+ catch (e) {
1429
+ if (e.name === 'not_found') {
1430
+ logger.info("get System - not found", e);
1431
+ return null;
1432
+ }
1433
+ logger.error("getSystem - something went wrong", { "error": e });
1434
+ throw new Error(e);
1435
+ }
1436
+ }
1437
+ async loadPatches(schemaVersion) {
1438
+ const fnLogger = logger.child({ method: "loadPatches" });
1439
+ try {
1440
+ fnLogger.info("loadPatches - loading patches");
1441
+ const patches = await getSystemPatches(schemaVersion || "0.0.0");
1442
+ fnLogger.warn(`loadPatches - loaded ${patches.length} patches`);
1443
+ return patches;
1444
+ }
1445
+ catch (e) {
1446
+ fnLogger.error("loadPatches - something went wrong", e);
1447
+ throw new Error(e);
1448
+ }
1449
+ }
1450
+ async applyPatches(schemaVersion) {
1451
+ const fnLogger = logger.child({ method: "applyPatches", args: { schemaVersion } });
1452
+ let _schemaVersion = schemaVersion;
1453
+ try {
1454
+ const patches = await this.loadPatches(_schemaVersion);
1455
+ for (let patch of patches) {
1456
+ _schemaVersion = await this.applyPatch(patch);
1457
+ }
1458
+ if (_schemaVersion) {
1459
+ fnLogger.warn("Successfully applied patches till version", { version: _schemaVersion });
1460
+ this.schemaVersion = _schemaVersion;
1461
+ return _schemaVersion;
1462
+ }
1463
+ else {
1464
+ fnLogger.info("No patches were provided or applied");
1465
+ throw new Error("applyPatches - No patches were provided or applied");
1466
+ }
1467
+ }
1468
+ catch (e) {
1469
+ fnLogger.error("Something went wrong", e);
1470
+ throw new Error(e);
1471
+ }
1472
+ }
1473
+ // Method that verifies wether the system information are updated
1474
+ // applies patches too
1475
+ // TODO: Test if works corrrectly with multiple patch files
1476
+ async checkSystem() {
1477
+ let systemDoc = await this.getSystem();
1478
+ console.log("System doc rev", { systemDoc });
1479
+ let _systemDoc;
1480
+ const dbInfo = await this.getDbInfo();
1481
+ logger.info("checkSystem - current system doc", { system: systemDoc });
1482
+ if (!systemDoc) {
1483
+ _systemDoc = {
1484
+ _id: "~system",
1485
+ appVersion: this.appVersion,
1486
+ dbInfo: dbInfo,
1487
+ schemaVersion: undefined,
1488
+ startupTime: (new Date()).valueOf()
1489
+ };
1490
+ // schemaVersion will be added after applying patches
1491
+ let schemaVersion = await this.applyPatches(_systemDoc.schemaVersion);
1492
+ console.log("Applied patches, new schema version:", schemaVersion);
1493
+ _systemDoc.schemaVersion = schemaVersion;
1494
+ }
1495
+ else {
1496
+ logger.info("checkSystem - system doc already exists. Checking for updates", systemDoc);
1497
+ // apply patches if needed
1498
+ let schemaVersion = await this.applyPatches(systemDoc.schemaVersion);
1499
+ _systemDoc = Object.assign(Object.assign({}, systemDoc), { appVersion: this.appVersion, dbInfo: dbInfo, schemaVersion: schemaVersion, startupTime: (new Date()).valueOf() });
1500
+ }
1501
+ // Update systemDoc
1502
+ try {
1503
+ await this.db.put(_systemDoc);
1504
+ }
1505
+ catch (e) {
1506
+ console.log("Got system doc", _systemDoc);
1507
+ logger.error("checkSystem - There was a problem while updating system", { error: e });
1508
+ throw new Error(e);
1509
+ }
1510
+ logger.info("checkSystem - updated system", { system: _systemDoc });
1511
+ }
1512
+ // Database initialization should be about making sure that all the documents
1513
+ // representing the base data model for this framework are present
1514
+ // perform tasks like applying patches, creating indexes, etc.
1515
+ async initdb() {
1516
+ logger.warn("initdb - starting initialization", { "stackName": this.name });
1517
+ await this.ensureCryptoConfigDocument();
1518
+ logger.warn("initdb - crypto config ensured", { "stackName": this.name });
1519
+ await this.initIndex();
1520
+ logger.warn("initdb - index initialized", { "stackName": this.name });
1521
+ await this.checkSystem();
1522
+ logger.warn("initdb - system checked", { "stackName": this.name });
1523
+ this.setListeners();
1524
+ logger.warn("initdb - listeners set, initialization complete", { "stackName": this.name });
1525
+ return this;
1526
+ }
1527
+ async ensureCryptoConfigDocument() {
1528
+ const markerId = ClientStack.CRYPTO_CONFIG_DOC_ID;
1529
+ let existing = null;
1530
+ try {
1531
+ existing = await this.db.get(markerId);
1532
+ }
1533
+ catch (error) {
1534
+ debugger;
1535
+ if ((error === null || error === void 0 ? void 0 : error.name) === "not_found" || (error === null || error === void 0 ? void 0 : error.status) === 404) {
1536
+ existing = null;
1537
+ }
1538
+ else {
1539
+ throw error;
1540
+ }
1541
+ }
1542
+ if (!this.cryptoEngineDisabled && !this.cryptoEngine.getDocumentKey()) {
1543
+ const randomBytes = new Uint8Array(32);
1544
+ globalThis.crypto.getRandomValues(randomBytes);
1545
+ const documentKey = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('');
1546
+ await this.cryptoEngine.setDocumentKey(documentKey);
1547
+ logger.info("Initialized new document key");
1548
+ }
1549
+ if (existing) {
1550
+ this.validateCryptoConfig(existing);
1551
+ return existing;
1552
+ }
1553
+ const markerDoc = {
1554
+ _id: markerId,
1555
+ cryptoEngineDisabled: this.cryptoEngineDisabled,
1556
+ createdAt: new Date().toISOString(),
1557
+ };
1558
+ if (!this.cryptoEngineDisabled) {
1559
+ const randomBytes = new Uint8Array(12);
1560
+ globalThis.crypto.getRandomValues(randomBytes);
1561
+ const encryptedMarker = await this.cryptoEngine.encryptValueForMarker({
1562
+ nonce: Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join(''),
1563
+ });
1564
+ if (encryptedMarker) {
1565
+ markerDoc.encryptedMarker = encryptedMarker;
1566
+ }
1567
+ }
1568
+ try {
1569
+ await this.db.put(markerDoc);
1570
+ return markerDoc;
1571
+ }
1572
+ catch (error) {
1573
+ if ((error === null || error === void 0 ? void 0 : error.status) === 409 || (error === null || error === void 0 ? void 0 : error.name) === "conflict") {
1574
+ const current = await this.db.get(markerId);
1575
+ this.validateCryptoConfig(current);
1576
+ return current;
1577
+ }
1578
+ throw error;
1579
+ }
1580
+ }
1581
+ async ensureCryptoMarkerEncryption() {
1582
+ if (this.cryptoEngineDisabled || !this.cryptoEngine.isEnabled())
1583
+ return;
1584
+ const markerId = ClientStack.CRYPTO_CONFIG_DOC_ID;
1585
+ const markerDoc = await this.db.get(markerId).catch((error) => {
1586
+ if ((error === null || error === void 0 ? void 0 : error.name) === "not_found" || (error === null || error === void 0 ? void 0 : error.status) === 404)
1587
+ return null;
1588
+ throw error;
1589
+ });
1590
+ if (!markerDoc || isEncryptedPayload(markerDoc.encryptedMarker))
1591
+ return;
1592
+ const randomBytes = new Uint8Array(12);
1593
+ globalThis.crypto.getRandomValues(randomBytes);
1594
+ const encryptedMarker = await this.cryptoEngine.encryptValueForMarker({
1595
+ nonce: Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join(''),
1596
+ });
1597
+ if (!encryptedMarker)
1598
+ return;
1599
+ markerDoc.encryptedMarker = encryptedMarker;
1600
+ await this.db.put(markerDoc);
1601
+ }
1602
+ validateCryptoConfig(existing) {
1603
+ const storedDisabled = Boolean(existing.cryptoEngineDisabled);
1604
+ if (storedDisabled !== this.cryptoEngineDisabled) {
1605
+ throw new Error(storedDisabled
1606
+ ? "Stack was initialized with crypto engine disabled; re-open it with disableCryptoEngine set to true."
1607
+ : "Stack requires the crypto engine; remove disableCryptoEngine to continue.");
1608
+ }
1609
+ if (!storedDisabled && isEncryptedPayload(existing.encryptedMarker) && !this.cryptoEngine.isEnabled()) {
1610
+ throw new Error("Crypto engine must be enabled to access this stack because it contains encrypted data.");
1611
+ }
1612
+ }
1613
+ async initIndex() {
1614
+ try {
1615
+ let lastDocId = await this.getLastDocId();
1616
+ // logger.info("initdb - res", res)
1617
+ if (!lastDocId) {
1618
+ lastDocId = Number(lastDocId);
1619
+ // logger.info("initdb - initializing db")
1620
+ try {
1621
+ let response = await this.db.put({
1622
+ _id: "lastDocId",
1623
+ value: ++lastDocId
1624
+ });
1625
+ if (response.ok)
1626
+ this.lastDocId = lastDocId;
1627
+ else
1628
+ throw new Error("Got problem while putting doc" + response);
1629
+ }
1630
+ catch (error) {
1631
+ if ((error === null || error === void 0 ? void 0 : error.status) === 409 || (error === null || error === void 0 ? void 0 : error.name) === "conflict") {
1632
+ const existing = await this.db.get("lastDocId");
1633
+ this.lastDocId = Number(existing.value);
1634
+ return;
1635
+ }
1636
+ throw error;
1637
+ }
1638
+ }
1639
+ else {
1640
+ logger.info("initdb - db already initialized, consider purge");
1641
+ }
1642
+ this.lastDocId = Number(lastDocId);
1643
+ }
1644
+ catch (e) {
1645
+ logger.error("initdb - something went wrong", e);
1646
+ throw new Error(e);
1647
+ }
1648
+ }
1649
+ // static async build( that: ClientStack ) {
1650
+ // let result = await that.initdb();
1651
+ // return result;
1652
+ // }
1653
+ /**
1654
+ * Retrieves a single document by its ID.
1655
+ *
1656
+ * @typeParam T - The expected document type
1657
+ * @param docId - The document ID to retrieve
1658
+ * @returns The document, or `null` if not found
1659
+ *
1660
+ * @example
1661
+ * ```typescript
1662
+ * const task = await stack.getDocument<TaskDocument>('Task-123');
1663
+ * if (task) {
1664
+ * console.log(task.title);
1665
+ * }
1666
+ * ```
1667
+ */
1668
+ async getDocument(docId) {
1669
+ let doc = undefined;
1670
+ try {
1671
+ doc = await this.db.get(docId);
1672
+ }
1673
+ catch (e) {
1674
+ if (e.name === 'not_found') {
1675
+ logger.info("getDocument - not found", e);
1676
+ return null;
1677
+ }
1678
+ logger.info("getDocument - error", e);
1679
+ throw new Error(e);
1680
+ }
1681
+ return doc;
1682
+ }
1683
+ async getDocRevision(docId) {
1684
+ let _rev = null;
1685
+ try {
1686
+ let doc = await this.getDocument(docId);
1687
+ if (doc)
1688
+ _rev = doc._rev;
1689
+ }
1690
+ catch (e) {
1691
+ logger.info("getDocRevision - error", e);
1692
+ throw new Error(e);
1693
+ }
1694
+ return _rev;
1695
+ }
1696
+ async processReadableDocument(doc, classObj, fields, precomputedEncryptedKeys) {
1697
+ if (!this.cryptoEngine.isEnabled()) {
1698
+ return doc;
1699
+ }
1700
+ const encryptedKeys = precomputedEncryptedKeys !== null && precomputedEncryptedKeys !== void 0 ? precomputedEncryptedKeys : this.cryptoEngine.identifyEncryptedKeys(doc, classObj);
1701
+ if (!encryptedKeys.length && (!fields || !fields.length)) {
1702
+ return doc;
1703
+ }
1704
+ const clone = Object.assign({}, doc);
1705
+ const hasDocumentKey = Boolean(this.cryptoEngine.getDocumentKey());
1706
+ if (hasDocumentKey && encryptedKeys.length) {
1707
+ await this.cryptoEngine.decryptDocument(clone, classObj, encryptedKeys);
1708
+ }
1709
+ else if (encryptedKeys.length) {
1710
+ for (const key of encryptedKeys) {
1711
+ if (clone[key] !== undefined) {
1712
+ clone[key] = null;
1713
+ }
1714
+ }
1715
+ }
1716
+ const encryptedKeySet = new Set(encryptedKeys);
1717
+ const visibleKeys = Object.keys(clone).filter((key) => {
1718
+ if (key === "_id" || key === "_rev" || key === "~rev" || key === "~class" || key === "active" || key === "~createTimestamp" || key === "~updateTimestamp" || key === "description") {
1719
+ return false;
1720
+ }
1721
+ if (fields && fields.length) {
1722
+ return fields.includes(key) && clone[key] !== undefined;
1723
+ }
1724
+ return clone[key] !== undefined;
1725
+ });
1726
+ if (!hasDocumentKey && encryptedKeySet.size) {
1727
+ const nonEncryptedVisible = visibleKeys.filter((key) => !encryptedKeySet.has(key));
1728
+ if (!nonEncryptedVisible.length) {
1729
+ return null;
1730
+ }
1731
+ }
1732
+ if (!visibleKeys.length) {
1733
+ return null;
1734
+ }
1735
+ return clone;
1736
+ }
1737
+ /**
1738
+ * Finds a single document matching a selector.
1739
+ * Convenience wrapper around {@link findDocuments} that returns the first match.
1740
+ *
1741
+ * @typeParam T - The expected document type
1742
+ * @param selector - A PouchDB/Mango query selector
1743
+ * @param fields - Optional list of fields to return
1744
+ * @param skip - Number of documents to skip
1745
+ * @param limit - Maximum number of documents to check
1746
+ * @returns The first matching document, or `null` if none found
1747
+ */
1748
+ async findDocument(selector, fields = undefined, skip = undefined, limit = undefined) {
1749
+ let result = await this.findDocuments(selector, fields, skip, limit);
1750
+ return result.docs.length > 0 ? result.docs[0] : null;
1751
+ }
1752
+ async incrementLastDocId() {
1753
+ let docId = "lastDocId", _rev = await this.getDocRevision(docId);
1754
+ if (_rev) {
1755
+ await this.db.put({
1756
+ _id: "lastDocId",
1757
+ _rev: _rev,
1758
+ value: ++this.lastDocId
1759
+ });
1760
+ return this.lastDocId;
1761
+ }
1762
+ // throw new Error
1763
+ }
1764
+ // The idea of this method is to be called from within the server (like CLI command)
1765
+ //
1766
+ async reset() {
1767
+ await this.destroyDb();
1768
+ // wait a few seconds
1769
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1770
+ await this.initialize(this.connection, this.options);
1771
+ await this.initdb();
1772
+ return this;
1773
+ }
1774
+ async destroyDb() {
1775
+ const fnLogger = logger.child({ method: "destroyDb" });
1776
+ try {
1777
+ this.db.destroy(null, () => {
1778
+ fnLogger.info("Destroyed db");
1779
+ return true;
1780
+ });
1781
+ }
1782
+ catch (e) {
1783
+ fnLogger.error(`Error while destroying db: ${e}`);
1784
+ return false;
1785
+ }
1786
+ }
1787
+ // This method is similar to destroyDb, but intended to be called from the client (not to destroy the main db)
1788
+ // TODO: Right now this allows to clear any db
1789
+ // there should be more restrictions
1790
+ static async clear(conn) {
1791
+ return new Promise((resolve, reject) => {
1792
+ try {
1793
+ let db = new PouchDB(conn);
1794
+ db.destroy(null, () => {
1795
+ logger.info("clear - Destroyed db");
1796
+ resolve(true);
1797
+ });
1798
+ }
1799
+ catch (e) {
1800
+ logger.error("clear - Error while destroying db" + e);
1801
+ reject(false);
1802
+ }
1803
+ });
1804
+ }
1805
+ prepareDoc(_id, type, params, metaKey = "~class") {
1806
+ logger.info("prepareDoc - given args", { _id: _id, type: type, params: params });
1807
+ params["_id"] = _id;
1808
+ params[metaKey] = type;
1809
+ params["~createTimestamp"] = new Date().getTime();
1810
+ params["active"] = true;
1811
+ logger.info("prepareDoc - after elaborations", { params });
1812
+ return params;
1813
+ }
1814
+ }
1815
+ ClientStack.CRYPTO_CONFIG_DOC_ID = "~crypto-engine-config";
1816
+ export default ClientStack;
1817
+ //# sourceMappingURL=stack.js.map