-
Notifications
You must be signed in to change notification settings - Fork 2
/
index.ts
752 lines (621 loc) · 23.5 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
import AxiosLib, {
AxiosBasicCredentials,
AxiosInstance,
AxiosProxyConfig,
AxiosRequestConfig,
AxiosResponse
} from 'axios';
import inspect from 'logspect';
declare const emit: (key: string, value) => void;
/**
* Indicates whether the request was a success or not (between 200-300).
*/
function isOkay(response: AxiosResponse) {
return response.status >= 200 && response.status < 300;
}
/**
* Determines whether an object is a DavenportError.
*/
export function isDavenportError(error): error is DavenportError {
return error.isDavenport;
}
/**
* A generic view document for listing and counting all objects in the database.
*/
export const GENERIC_LIST_VIEW = {
"name": "all",
"map": function (doc) { emit(doc._id, doc); }.toString(),
"reduce": "_count"
}
/**
* Configures an instance of Axios with auth and validation defaults.
*/
function getAxiosInstance(options: ClientOptions): AxiosInstance {
let auth: AxiosBasicCredentials;
if (options && (options.username || options.password)) {
auth = {
username: options.username,
password: options.password,
}
}
const instance = AxiosLib.create({
// Like fetch, Axios should never throw an error if it receives a response
validateStatus: (status) => true,
auth: auth,
proxy: options && options.proxy
});
return instance;
}
/**
* Configures a Davenport client and database by validating the CouchDB version, creating indexes and design documents, and then returning a client to interact with the database.
*/
export async function configureDatabase<DocType extends CouchDoc>(databaseUrl: string, configuration: DatabaseConfiguration<DocType>, options?: ClientOptions): Promise<Client<DocType>> {
const ax = getAxiosInstance(options);
const dbInfo = await ax.get(databaseUrl);
if (!isOkay(dbInfo)) {
throw new Error(`Failed to connect to CouchDB instance at ${databaseUrl}. ${dbInfo.status} ${dbInfo.statusText}`);
}
const infoBody = dbInfo.data as { version: string };
const version = parseInt(infoBody.version);
if (version < 2) {
inspect(`Warning: Davenport expects your CouchDB instance to be running CouchDB 2.0 or higher. Version detected: ${version}. Some database methods may not work.`)
}
const putResult = await ax.put(`${databaseUrl}/${configuration.name}`, undefined);
const preconditionFailed = 412; /* Precondition Failed - Database already exists. */
if (putResult.status !== preconditionFailed && !isOkay(putResult)) {
throw new DavenportError(`${putResult.status} ${putResult.statusText} ${putResult.data}`, putResult);
}
if (Array.isArray(configuration.indexes) && configuration.indexes.length > 0) {
const data = {
index: {
fields: configuration.indexes
},
name: `${configuration.name}-indexes`,
};
const result = await ax.post(`${databaseUrl}/${configuration.name}/_index`, data, {
headers: {
"Content-Type": "application/json"
},
});
if (!isOkay(result)) {
throw new DavenportError(`Error creating CouchDB indexes on database ${configuration.name}.`, result);
}
}
if (Array.isArray(configuration.designDocs) && configuration.designDocs.length > 0) {
await Promise.all(configuration.designDocs.map(async designDoc => {
const url = `${databaseUrl}/${configuration.name}/_design/${designDoc.name}`;
const getDoc = await ax.get(url);
const okay = isOkay(getDoc);
let docFromDatabase: DesignDoc;
if (!isOkay && getDoc.status !== 404) {
inspect(`Davenport: Failed to retrieve design doc "${designDoc.name}". ${getDoc.status} ${getDoc.statusText}`, getDoc.data);
return;
}
if (!isOkay) {
docFromDatabase = {
_id: `_design/${designDoc.name}`,
_rev: undefined,
language: "javascript",
views: {}
}
} else {
docFromDatabase = getDoc.data;
}
const docViews = designDoc.views;
let shouldUpdate = false;
docViews.forEach(view => {
if (!docFromDatabase.views || !docFromDatabase.views[view.name] || docFromDatabase.views[view.name].map !== view.map || docFromDatabase.views[view.name].reduce !== view.reduce) {
docFromDatabase.views = Object.assign({}, docFromDatabase.views, {
[view.name]: {
map: view.map,
reduce: view.reduce,
}
})
shouldUpdate = true;
}
});
if (shouldUpdate) {
inspect(`Davenport: Creating or updating design doc "${designDoc.name}" for database "${configuration.name}".`);
const result = await ax.put(url, docFromDatabase, {
headers: {
"Content-Type": "application/json",
}
});
if (!isOkay(result)) {
inspect(`Davenport: Could not create or update CouchDB design doc "${designDoc.name}" for database "${configuration.name}". ${result.status} ${result.statusText}`, result.data);
}
}
return Promise.resolve();
}));
}
return new Client<DocType>(databaseUrl, configuration.name, options);
}
/**
* A client for interacting with a CouchDB instance. Use this when you don't want or need to use the `configureClient` function to create a database and set up design docs or indexes.
*/
export class Client<T extends CouchDoc> {
constructor(private baseUrl: string, private databaseName: string, private options: ClientOptions = { warnings: true }) {
this.databaseUrl = `${baseUrl}/${databaseName}/`;
this.axios = getAxiosInstance(options);
}
private axios: AxiosInstance;
private databaseUrl: string;
private get jsonContentTypeHeaders() {
return {
"Content-Type": "application/json"
}
}
private getOption(name: keyof ClientOptions) {
if (!this.options) {
return undefined;
}
return this.options[name];
}
/**
* Checks that the Axios response is okay. If not, a DavenPort error is thrown.
*/
private async checkErrorAndGetBody(result: AxiosResponse) {
if (!isOkay(result)) {
const message = `Error with ${result.config.method} request for CouchDB database ${this.databaseName} at ${result.config.url}. ${result.status} ${result.statusText}`;
throw new DavenportError(message, result);
}
return result.data;
};
/**
* Find matching documents according to the selector.
*/
public async find(options: FindOptions<T>): Promise<T[]> {
const result = await this.axios.post(`${this.databaseUrl}/_find`, options, {
headers: this.jsonContentTypeHeaders
});
const body = await this.checkErrorAndGetBody(result);
if (body.warning && !!this.getOption("warnings")) {
inspect("Davenport warning: Davenport.find result contained warning:", body.warning);
}
return body.docs;
}
/**
* Lists documents in the database. Warning: this result WILL list design documents, and it will force the `include_docs` option to false. If you need to include docs, use .listWithDocs.
*/
public async listWithoutDocs(options: ListOptions = {}): Promise<ListResponse<{ rev: string }>> {
const result = await this.axios.get(`${this.databaseUrl}/_all_docs`, {
params: { ...this.encodeOptions(options), include_docs: false }
});
const body = await this.checkErrorAndGetBody(result) as AllDocsListResult<T>;
return {
offset: body.offset,
total_rows: body.total_rows,
rows: body.rows.map(r => r.value)
}
}
/**
* Lists documents in the database. Warning: this result WILL list design documents, and it will force the `include_docs` option to true. If you don't need to include docs, use .listWithoutDocs.
*/
public async listWithDocs(options: ListOptions = {}): Promise<ListResponse<T>> {
const result = await this.axios.get(`${this.databaseUrl}/_all_docs`, {
params: { ...this.encodeOptions(options), include_docs: true }
});
const body = await this.checkErrorAndGetBody(result) as AllDocsListResult<T>;
return {
offset: body.offset,
total_rows: body.total_rows,
rows: body.rows.map(r => r.doc)
}
}
/**
* Counts all documents in the database. Warning: this result WILL include design documents.
*/
public async count(): Promise<number> {
const result = await this.axios.get(`${this.databaseUrl}/_all_docs`, {
params: {
limit: 0,
}
});
const body = await this.checkErrorAndGetBody(result) as AllDocsListResult<T>;
return body.total_rows;
}
/**
* Counts all documents by the given selector. Warning: this uses more memory than a regular count, because it needs to pull in the _id field of all selected documents. For large queries, it's better to create a dedicated view and use the .view function.
*/
public async countBySelector(selector: DocSelector<T>): Promise<number>
public async countBySelector(selector: Partial<T>): Promise<number>
public async countBySelector(selector): Promise<number> {
const result = await this.find({
fields: ["_id"],
selector,
})
return result.length;
}
/**
* Gets a document with the given id and optional revision id.
*/
public async get(id: string, rev?: string): Promise<T> {
const result = await this.axios.get(this.databaseUrl + id, {
params: { rev }
});
const body = await this.checkErrorAndGetBody(result);
return body;
}
/**
* Inserts, updates or deletes multiple documents at the same time.
*
* Omitting the `_id` property from a document will cause CouchDB to generate the id itself.
*
* When updating a document, the `_rev` property is required.
*
* To delete a document, set the `_deleted` property to `true`.
*
* Note that CouchDB will return in the response an id and revision for every document passed as content to a bulk insert, even for those that were just deleted.
*
* If the `_rev` does not match the current version of the document, then that particular document will not be saved and will be reported as a conflict, but this does not prevent other documents in the batch from being saved.
*
* If the `newEdits` arg is `false` (to push existing revisions instead of creating new ones) the response will not include entries for any of the successful revisions (since their rev IDs are already known to the sender), only for the ones that had errors. Also, the `"conflict"` error will never appear, since in this mode conflicts are allowed.
*
* @param docs An array of documents that will be inserted, updated or deleted.
* @param newEdits A boolean that determines whether to allow new edits or not.
*/
public async bulk(docs: T[], newEdits = true): Promise<BulkResponse> {
const result = await this.axios.post(this.databaseUrl + "_bulk_docs", { docs }, {
headers: this.jsonContentTypeHeaders,
params: { new_edits: newEdits }
})
const body = await this.checkErrorAndGetBody(result);
return body;
}
/**
* Creates a document with a random id. By CouchDB convention, this will only return the id and revision id of the new document, not the document itself.
*/
public async post(data: T): Promise<PostPutCopyResponse> {
const result = await this.axios.post(this.databaseUrl, data, {
headers: this.jsonContentTypeHeaders
});
const body: CouchResponse = await this.checkErrorAndGetBody(result);
return {
id: body.id,
rev: body.rev,
}
}
/**
* Updates or creates a document with the given id. By CouchDB convention, this will only return the id and revision id of the new document, not the document itself.
*/
public async put(id: string, data: T, rev: string): Promise<PostPutCopyResponse> {
if (!rev && !!this.getOption("warnings")) {
inspect(`Davenport warning: no revision specified for Davenport.put function with id ${id}. This may cause a document conflict error.`);
}
const result = await this.axios.put(this.databaseUrl + id, data, {
headers: this.jsonContentTypeHeaders,
params: { rev }
});
const body: CouchResponse = await this.checkErrorAndGetBody(result);
return {
id: body.id,
rev: body.rev,
};
}
/**
* Copies the document with the given id and assigns the new id to the copy. By CouchDB convention, this will only return the id and revision id of the new document, not the document itself.
*/
public async copy(id: string, newId: string): Promise<PostPutCopyResponse> {
const result = await this.axios.request({
url: this.databaseUrl + id,
method: "COPY",
headers: {
Destination: newId
},
});
const body: CouchResponse = await this.checkErrorAndGetBody(result);
return {
id: body.id,
rev: body.rev,
}
}
/**
* Deletes the document with the given id and revision id.
*/
public async delete(id: string, rev: string): Promise<void> {
if (!rev && !!this.getOption("warnings")) {
inspect(`Davenport warning: no revision specified for Davenport.delete function with id ${id}. This may cause a document conflict error.`);
}
const result = await this.axios.delete(this.databaseUrl + id, {
params: { rev }
});
await this.checkErrorAndGetBody(result);
}
/**
* Checks that a document with the given id exists.
*/
public async exists(id: string): Promise<boolean> {
const result = await this.axios.head(this.databaseUrl + id);
return result.status === 200;
}
/**
* Checks that a document that matches the field value exists.
*/
public async existsByFieldValue(value, field: keyof T): Promise<boolean> {
const findResult = await this.find({
fields: ["_id"],
limit: 1,
selector: {
[field]: value
} as any
});
return findResult.length > 0;
}
/**
* Checks that a document matching the selector exists.
*/
public async existsBySelector(selector: DocSelector<T>): Promise<boolean> {
const findResult = await this.find({
fields: ["_id"],
limit: 1,
selector: selector as any,
});
return findResult.length > 0;
}
/**
* Executes a view with the given designDocName and viewName. Will not reduce by default, pass in the { reduce: true } option to reduce.
*/
public async view<DocType>(designDocName: string, viewName: string, options: ViewOptions = { reduce: false }): Promise<ViewResult<DocType>> {
// Ensure reduce is set to false unless explicitly set by the caller.
if (typeof (options.reduce) !== "boolean") {
options.reduce = false;
}
const result = await this.axios.get(`${this.databaseUrl}_design/${designDocName}/_view/${viewName}`, {
params: this.encodeOptions(options),
});
const body = await this.checkErrorAndGetBody(result);
return body;
}
/**
* Executes a view with the given designDocName and viewName. This method will never reduce the result.
*/
public async viewWithDocs<DocType>(designDocName: string, viewName: string, options: ViewOptions = { reduce: false }): Promise<ViewResultWithDocs<DocType>> {
const result = await this.axios.get(`${this.databaseUrl}_design/${designDocName}/_view/${viewName}`, {
params: { ...this.encodeOptions(options), reduce: false, include_docs: true }
});
const body = await this.checkErrorAndGetBody(result);
return body;
}
/**
* Creates the database associated with this client.
*/
public async createDb(url: string = this.databaseUrl): Promise<CreateDatabaseResponse> {
const result = await this.axios.put(url);
if (result.status === 412) {
return {
ok: true,
alreadyExisted: true
}
}
const body: BasicCouchResponse = await this.checkErrorAndGetBody(result);
return {
...body,
alreadyExisted: false
}
}
/**
* Deletes the database associated with this client.
*/
public async deleteDb(url: string = this.databaseUrl): Promise<BasicCouchResponse> {
const result = await this.axios.delete(url);
return await this.checkErrorAndGetBody(result);
}
/**
* Returns database info for the given database.
*/
public async getDbInfo(url: string = this.databaseUrl): Promise<DbInfo> {
const result = await this.axios.get(url);
return await this.checkErrorAndGetBody(result);
}
private encodeOptions(options: ListOptions): object {
let keys = Object.getOwnPropertyNames(options || {}) as (keyof ListOptions)[];
return keys.reduce((requestOptions, key) => {
switch (key) {
case "keys":
case "key":
case "start_key":
case "end_key":
requestOptions[key] = JSON.stringify(options[key]);
break;
default:
requestOptions[key] = options[key];
break;
}
return requestOptions;
}, {});
}
}
export default Client;
export class DavenportError extends Error {
constructor(message, public fullResponse: AxiosResponse) {
super(message);
this.status = fullResponse.status;
this.statusText = fullResponse.statusText;
this.body = fullResponse.data;
this.url = fullResponse.headers.host || fullResponse.headers.HOST;
}
public readonly isDavenport = true;
public status: number;
public statusText: string;
public url: string;
public body: any;
}
export interface CouchDoc {
/**
* The object's database id.
*/
_id?: string;
/**
* The object's database revision.
*/
_rev?: string;
}
export interface PostPutCopyResponse {
id: string;
rev: string;
}
export interface BulkDocumentError {
id: string;
error: "conflict" | "forbidden" | "unauthorized";
reason: string | "Document update conflict.";
}
export type BulkResponse = (PostPutCopyResponse | BulkDocumentError)[]
export interface ViewOptions extends ListOptions {
reduce?: boolean;
group?: boolean;
group_level?: number;
}
export interface ViewResult<DocType> {
offset?: number;
total_rows?: number;
rows: ViewRow<DocType>[];
}
export interface ViewRow<DocType> {
id?: string;
key?: any;
value: DocType;
}
export interface ViewRowWithDoc<DocType> extends ViewRow<DocType> {
doc: DocType;
}
export interface ViewResultWithDocs<DocType> {
offset?: number;
total_rows?: number;
rows: ViewRowWithDoc<DocType>[];
}
export type Key = string | number | Object;
/**
* Options for listing database results.
*/
export interface ListOptions {
limit?: number;
key?: Key;
keys?: Key[];
start_key?: Key | Key[];
end_key?: Key | Key[];
inclusive_end?: boolean;
descending?: boolean;
skip?: number;
}
export interface AllDocsListResult<T> {
rows: {
id: string,
key: string,
value: {
rev: string
},
doc: T
}[],
offset: number,
total_rows: number
}
export interface DbSizes {
file: number;
external: number;
active: number
};
export interface DbOther {
data_size?: number;
}
export interface DbInfo {
db_name: string;
update_seq: string;
sizes: DbSizes;
purge_seq: number;
other?: DbOther;
doc_del_count: number;
doc_count: number;
disk_size: number;
disk_format_version: number;
data_size: number;
compact_running: boolean;
instance_start_time: number;
}
export interface BasicCouchResponse {
ok: boolean;
}
export interface CreateDatabaseResponse extends BasicCouchResponse {
/**
* Whether the database already existed when trying to create it. Determined by CouchDB returning a 412 Precondition Failed response.
*/
alreadyExisted: boolean;
}
interface CouchResponse extends BasicCouchResponse {
id: string;
rev: string;
}
export interface ListResponse<T> {
offset: number;
total_rows: number;
rows: T[];
}
export interface FindOptions<T> {
fields?: string[];
sort?: Object[];
limit?: number;
skip?: number;
use_index?: Object;
selector: Partial<T> | DocSelector<T>;
}
export interface CouchDBView {
map: string;
reduce?: string;
}
export interface DesignDoc extends CouchDoc {
views: { [name: string]: CouchDBView };
language: "javascript";
}
export interface DesignDocConfiguration {
name: string,
views: ({ name: string } & CouchDBView)[]
}
export interface DatabaseConfiguration<T> {
name: string,
indexes?: (keyof T)[],
designDocs?: DesignDocConfiguration[],
}
export interface ClientOptions {
/**
* Whether the Davenport client should log warnings.
*/
warnings?: boolean;
/**
* Username used to make requests with basic auth.
*/
username?: string;
/**
* Password used to make requests with basic auth.
*/
password?: string;
/**
* Proxy configuration object.
*/
proxy?: AxiosProxyConfig;
}
export interface PropSelector {
/**
* Property is equal to this value.
*/
$eq?: any;
/**
* Property is not equal to this value.
*/
$ne?: any;
/**
* Property is greater than this value.
*/
$gt?: any;
/**
* Property is greater than or equal to this value.
*/
$gte?: any;
/**
* Property is less than this value.
*/
$lt?: any;
/**
* Property is lesser than or equal to this value.
*/
$lte?: any;
}
export type DocSelector<T> = Partial<Record<keyof T, PropSelector>>;