@cboulanger/zotero-sync-couchbase
Version:
Couchbase store for @retorquere/zotero-sync
293 lines (292 loc) • 10 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Library = exports.Store = void 0;
const couchbase_1 = __importDefault(require("couchbase"));
/**
* Helper function to create a couchbase collection. Resolves with the collection object once it has
* been created
* @param {Bucket} bucket
* @param {String} scopeName
* @param {String} collectionName
* @param {Number} timeout
* @return {Promise<Collection>}
*/
async function createCbCollection(bucket, scopeName, collectionName, timeout) {
// create scope
try {
await bucket.collections().createScope(scopeName);
//console.log(`Created scope ${bucketName}.${scopeName}`);
}
catch (e) {
if (!e.toString().includes("exists")) {
throw e;
}
}
// create collection
let collSpec = {
name: collectionName,
scopeName: scopeName,
maxExpiry: 0
};
try {
await bucket.collections().createCollection(collSpec);
//console.log(`Created collection ${bucketName}.${scopeName}.${storeName}`);
}
catch (e) {
if (!e.toString().includes("exists")) {
throw e;
}
}
const now = new Date().getTime();
let isTimedOut = false;
let collection;
while (!collection && !isTimedOut) {
try {
collection = await bucket.scope(scopeName).collection(collectionName);
}
catch (e) {
console.log(e.message);
}
await new Promise(resolve => setTimeout(resolve, 100));
isTimedOut = new Date().getTime() > now + timeout;
}
if (isTimedOut) {
throw new Error(`Timeout of ${timeout} ms reached when creating collection ${bucket.name}.${scopeName}.${collectionName}`);
}
// how can I tell Typescript that at this point, collection will always be of type Collection?
// @ts-ignore
return collection;
}
/**
* Helper function to create a primary index on a collection. Returns a promise that resolves
* when the index is online.
* @param {Cluster} cluster
* @param {String} bucketName
* @param {String} scopeName
* @param {String} collectionName
* @param {Number} timeout
*/
async function createPrimaryIndex(cluster, bucketName, scopeName, collectionName, timeout) {
let query = `create primary index on default:\`${bucketName}\`.\`${scopeName}\`.\`${collectionName}\``;
try {
await cluster.query(query);
//console.log(`Created primary index on default:${this.bucketName}.${scopeName}.${collectionName}`);
}
catch (e) {
let err = e.context ? e.context : e;
if (err.first_error_message && err.first_error_message.includes("already exists")) {
return;
}
throw e;
}
const now = new Date().getTime();
let isTimedOut = false;
while (!isTimedOut) {
let query = `SELECT RAW state FROM system:indexes WHERE name = "#primary"
AND bucket_id = "${bucketName}"
AND scope_id ="${scopeName}"
AND keyspace_id ="${collectionName}"`;
let result = (await cluster.query(query)).rows;
if (result.length && result[0].toString() === "online") {
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
isTimedOut = new Date().getTime() > now + timeout;
}
if (isTimedOut) {
throw new Error(`Timeout of ${timeout} ms reached when creating primary index on ${bucketName}.${scopeName}.${collectionName}`);
}
}
class Store {
constructor(url, username, password, options = {}) {
this.url = url;
this.username = username;
this.password = password;
this.options = options;
this.bucketName = options.bucketName || "zotero";
this.timeout = options.timeout || 5000;
this.libraries = [];
}
/**
* Returns the couchbase Cluster instance
* @return Promise<Cluster>
*/
async getCluster() {
if (!this.cluster) {
this.cluster = await couchbase_1.default.connect(this.url, {
username: this.username,
password: this.password,
});
}
return this.cluster;
}
/**
* Returns the couchbase bucket in which the zotero data is stored
* @returns {Promise<Bucket>}
*/
async getBucket() {
return (await this.getCluster()).bucket(this.bucketName);
}
/**
* Given a zotero REST API user-or-group prefix (e.g. users/12345 or groups/12345), return
* a library id that can be stored as a scope name in couchbase.
* @param {String} user_or_group_prefix
* @protected
*/
createScopeNameFromPrefix(user_or_group_prefix) {
const impl = this.options.createScopeNameFromPrefixFunc || this.createScopeNameFromPrefixImpl;
return impl(user_or_group_prefix);
}
/**
* The default implementation shortens the prefix to "u12345" or "g12345",
* respectively.
* @param {String} user_or_group_prefix
* @protected
*/
createScopeNameFromPrefixImpl(user_or_group_prefix) {
return user_or_group_prefix
.replace(/roups\/|sers\//, "")
.replace("/", "");
}
/**
* Removes a library from the store
* @implements Zotero.Store.remove
* @param user_or_group_prefix
*/
async remove(user_or_group_prefix) {
const scopeName = this.createScopeNameFromPrefix(user_or_group_prefix);
const bucket = await this.getBucket();
try {
await bucket.collections().dropScope(scopeName);
}
catch (e) {
console.error(e);
}
this.libraries = this.libraries.filter(prefix => prefix !== user_or_group_prefix);
}
/**
* Gets a library, creating it if it doesn't exist.
* @implements Zotero.Store.get
* @param user_or_group_prefix
* @return {Promise<Library>}
*/
async get(user_or_group_prefix) {
const library = new Library(this, user_or_group_prefix);
if (!this.libraries.includes(user_or_group_prefix)) {
this.libraries.push(user_or_group_prefix);
}
return await library.init();
}
}
exports.Store = Store;
/**
* Implementation of a Zotero library object
*/
class Library {
constructor(store, user_or_group_prefix) {
// interface properties
this.name = "";
this.version = 0;
this.synchronizedObjectTypes = ["items", "collections"];
this.store = store;
this.user_or_group_prefix = user_or_group_prefix;
this.cbCollections = new Map;
}
/**
* Returns "user" or "group"
*/
getType() {
return this.user_or_group_prefix.startsWith("/users") ? "user" : "group";
}
/**
* Initialize the library instance. This creates the necessary couchbase
* collections. Resolves with the library instance when done.
*/
async init() {
const cluster = await this.store.getCluster();
const bucket = await this.store.getBucket();
const scopeName = this.store.createScopeNameFromPrefix(this.user_or_group_prefix);
// create collections for synchronized zotero types plus one for sync metadata
const collectionNames = this.synchronizedObjectTypes.concat("meta");
for (const collectionName of collectionNames) {
const cbColl = await createCbCollection(bucket, scopeName, collectionName, this.store.timeout);
await createPrimaryIndex(cluster, bucket.name, scopeName, collectionName, this.store.timeout);
this.cbCollections.set(collectionName, cbColl);
}
const metaCollection = this.cbCollections.get("meta");
try {
this.name = (await metaCollection.get("name")).content;
this.version = (await metaCollection.get("version")).content;
}
catch (e) {
if (!e.message.includes("document not found")) {
throw e;
}
}
return this;
}
/**
* Adds a Zotero collection object
* @param {Zotero.Collection} collection
*/
async add_collection(collection) {
const cbCollection = this.cbCollections.get("collections");
await cbCollection.upsert(collection.key, collection);
}
/**
* Removes a Zotero collection object
* @param {string[]} keys
*/
async remove_collections(keys) {
const cbCollection = this.cbCollections.get("collections");
for (const key of keys) {
try {
await cbCollection.remove(key);
}
catch (e) {
if (!e.message.includes("document not found")) {
throw e;
}
}
}
}
/**
* Adds a Zotero item object
* @param {Zotero.Item.Any} item
*/
async add(item) {
const cbCollection = this.cbCollections.get("items");
await cbCollection.upsert(item.key, item);
}
/**
* Removes an Zotero item object
* @param {string[]} keys
*/
async remove(keys) {
const cbCollection = this.cbCollections.get("items");
for (const key of keys) {
try {
await cbCollection.remove(key);
}
catch (e) {
if (!e.message.includes("document not found")) {
throw e;
}
}
}
}
/**
* Saves the Library
* @param {String?} name Descriptive Name of the library
* @param {Number} version
*/
async save(name, version) {
const metaCollection = this.cbCollections.get("meta");
await metaCollection.upsert("version", version);
await metaCollection.upsert("name", name);
}
}
exports.Library = Library;