@docstack/client 0.0.4 → 0.0.6
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/lib/core/attribute.js +406 -0
- package/lib/core/attribute.js.map +1 -0
- package/lib/core/class.d.ts +15 -16
- package/lib/core/class.js +761 -0
- package/lib/core/class.js.map +1 -0
- package/lib/core/crypto-engine/index.js +229 -0
- package/lib/core/crypto-engine/index.js.map +1 -0
- package/lib/core/crypto-engine/utils.js +88 -0
- package/lib/core/crypto-engine/utils.js.map +1 -0
- package/lib/core/datamodel/index.js +1308 -0
- package/lib/core/datamodel/index.js.map +1 -0
- package/lib/core/domain.d.ts +7 -8
- package/lib/core/domain.js +423 -0
- package/lib/core/domain.js.map +1 -0
- package/lib/core/index.js +520 -0
- package/lib/core/index.js.map +1 -0
- package/lib/core/job-engine/index.js +220 -0
- package/lib/core/job-engine/index.js.map +1 -0
- package/lib/core/policy-engine/index.js +232 -0
- package/lib/core/policy-engine/index.js.map +1 -0
- package/lib/core/query-engine/accumulators.js +258 -0
- package/lib/core/query-engine/accumulators.js.map +1 -0
- package/lib/core/query-engine/evaluator.js +179 -0
- package/lib/core/query-engine/evaluator.js.map +1 -0
- package/lib/core/query-engine/executor.js +405 -0
- package/lib/core/query-engine/executor.js.map +1 -0
- package/lib/core/query-engine/index.js +4 -0
- package/lib/core/query-engine/index.js.map +1 -0
- package/lib/core/query-engine/parser.js +515 -0
- package/lib/core/query-engine/parser.js.map +1 -0
- package/lib/core/query-engine/planner.js +330 -0
- package/lib/core/query-engine/planner.js.map +1 -0
- package/lib/core/stack.js +1817 -0
- package/lib/core/stack.js.map +1 -0
- package/lib/core/test-utils/docstack.js +222 -0
- package/lib/core/test-utils/docstack.js.map +1 -0
- package/lib/core/trigger/index.js +81 -0
- package/lib/core/trigger/index.js.map +1 -0
- package/lib/index.js +4 -8231
- package/lib/index.js.map +1 -1
- package/lib/index.umd.js +10 -4
- package/lib/plugins/pouchdb.js +368 -0
- package/lib/plugins/pouchdb.js.map +1 -0
- package/lib/utils/crypto/index.js +34 -0
- package/lib/utils/crypto/index.js.map +1 -0
- package/lib/utils/index.js +58 -0
- package/lib/utils/index.js.map +1 -0
- package/lib/utils/logger/index.js +20 -0
- package/lib/utils/logger/index.js.map +1 -0
- package/lib/utils/logger/transport.js +28 -0
- package/lib/utils/logger/transport.js.map +1 -0
- package/lib/workers/dataModel.js +48 -0
- package/lib/workers/dataModel.js.map +1 -0
- 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
|