diff --git a/CHANGELOG.md b/CHANGELOG.md index bb92295..4fedc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # edge-server-tools +## Unreleased + +- deprecated: `autoReplication`. Use `setupDatabase` instead. + ## 0.2.20 (2024-10-11) - fixed: Add missing TypeScript entrypoint. diff --git a/docs/couch-setup.md b/docs/couch-setup.md index 2bd3e2e..54e9764 100644 --- a/docs/couch-setup.md +++ b/docs/couch-setup.md @@ -9,7 +9,52 @@ - [Enabling Replication](#enabling-replication) - [Background Tasks](#background-tasks) -## Basic Setup +The edge-server-tools package can create & maintain CouchDB databases, design documents, and replications. This maintenance can take place across multiple clusters or servers. + +These tools can also watch for changes to specific synced documents, such as settings. + +## Managing Credentials + +First, create a `CouchPool` object holding your connections to CouchDB. For simple cases, this just takes a URL: + +```js +import { connectCouch } from 'edge-server-tools' + +const pool = connectCouch('http://admin:admin@localhost:5984/') +``` + +Or, if you want do auto-replication, you can provide a list of credentials and the name of the default cluster: + +```js +const pool = connectCouch( + // The preferred cluster to use for most tasks: + 'production', + + // All the clusters we can access: + { + production: { + url: 'https://production.example.com:6984/', + username: 'admin', + password: 'admin', + }, + logs: { + url: 'https://admin:admin@logs.example.com:6984/', + }, + backup: 'https://admin:admin@backup.example.com:6984/' + } +) +``` + +This example has three clusters, named "production", "logs", and "backup". These credentials would normally be stored on disk, perhaps using [cleaner-config](https://www.npmjs.com/package/cleaner-config). The main app server would generally contain all the credentials for the system and be responsible for maintenance tasks, while secondary servers might just connect directly to their own URL's. + +The `pool` object has a `default` property, which corresponds to the default cluster: + +```js +const db = pool.default.use('my-db') +await db.list() +``` + +## Creating Databases First, create a `DatabaseSetup` object to describe the database: @@ -22,11 +67,10 @@ const productsSetup: DatabaseSetup = { Next, when your application starts up, use `setupDatabase` to ensure the database exists and has the right settings: ```ts -const connection = nano(couchUri) -await setupDatabase(connection, productsSetup) +await setupDatabase(pool, productsSetup) ``` -This will ensure that the CouchDB instance contains a database named "products". +If the pool contains credentials to multiple clusters, this will ensure that the database exists on each of them. If you only want to create this database on certain clusters, see the [Replication Setup](#replication-setup) section. If the database needs special CouchDB settings, pass those as `DatabaseSetup.options`: @@ -54,7 +98,7 @@ This example will create a document named "\_design/mango-upc". The contents wil ### makeMangoIndex -Use this function to create Mango index design documents. The first parameter is the name of the view (CouchDB doesn't really use this anywhere), and the second parameter is an array of properties to index over, using CouchDB's "sort syntax". +Use this function to create Mango index design documents. The first parameter is the name of the view (CouchDB doesn't really use this anywhere), and the second parameter is an array of properties to index over, using CouchDB's [sort syntax](https://docs.couchdb.org/en/stable/api/database/find.html#sort-syntax). ```js makeMangoIndex('createdByDate', ['created', 'date']) @@ -107,7 +151,7 @@ makeJsDesign( This example uses the `fixJs` option to convert "const" into "var" for the legacy JavaScript engine. -If you need to access to utility functions, you can use CommonJS to inject them into the view: +If you need access to utility functions, you can use CommonJS to inject them into the view: ```js import { normalizeIp, parseIp } from './ip-utils' @@ -135,7 +179,7 @@ These library functions need to be completely standalone, and must use CouchDB's ## Watching Settings Documents -While CouchDB is good at storing application data, it is also good at storing settings. Instead of using environment variables or JSON files on disk, putting settings inside CouchDB provides a nice admin interface for editing them, as well as automatic replication across the various servers. +CouchDB is a convenient place to store app settings such as API keys or tuning parameters. Doing this provides live updates, automatic replication, and a nice admin interface using Fauxton. To watch a settings document for changes, use the `syncedDocument` helper function: @@ -156,11 +200,12 @@ const settingsSetup: DatabaseSetup = { } // At app boot time: -const connection = nano(couchUri) -await setupDatabase(connection, settingsSetup) +await setupDatabase(pool, settingsSetup) ``` -The `setupDatabase` will perform an initial sync, ensuring the document exists, and will then watch the document for any future changes, keeping it up to date. Simply access the `appSettings.doc` property to see the latest value, or subscribe to changes using the `onChange` method: +The `setupDatabase` will use the `pool.default` connection to perform an initial sync, ensuring the document exists. It will also watch the document for any future changes, keeping it up to date using the cleaner. The [Background Tasks](#background-tasks) section explains how to manage this background process. + +To see the latest document contents, simply access the `appSettings.doc` property. You can also subscribe to changes using the `onChange` method: ```js appSettings.onChange((newSettings) => console.log(newSettings)) @@ -187,31 +232,18 @@ The `setupDatabase` function can automatically create documents in the `_replica "_rev": "12-6ee74490125b924e323807d9363d64a2", "clusters": { "production": { - "url": "https://production.example.com:6984/", - "basicAuth": "ZXhhbXBsZTpleGFtcGxl", + "exclude": ["#archived"], "pushTo": ["logs", "backup"] }, "logs": { - "url": "https://logs.example.com:6984/", - "basicAuth": "ZXhhbXBsZTpleGFtcGxl", - "include": ["logs-*"] + "include": ["logs-*"], + "localOnly": ["settings"] }, - "backup": { - "url": "https://backup.example.com:6984/", - "basicAuth": "ZXhhbXBsZTpleGFtcGxl" - } + "backup": {} } } ``` -This example has three clusters, named "production", "logs", and "backup". Each cluster has a URL, a set of credentials, and options describing what to replicate. You can generate the credential strings by opening a browser console and running: - -```js -btoa('username:password') -``` - -You can also turn the base64 back to plain text by using `atob`. - In this example, the "production" cluster has a `pushTo` list, which tells it to push changes out to the "logs" and "backup" clusters. The `setupDatabase` function will not create any replication documents on the other clusters, since they don't have `pushTo` or `pullFrom` properties. Since the "logs" cluster has an `include` filter, the `setupDatabase` routine will only create and replicate databases with names that start with "logs-" on this cluster. Clusters can also specify an `exclude` filter to avoid creating specific databases, and a `localOnly` filter to create databases but not replicate them. Databases can also have tags, such as "#archived" or "#secret", which also apply to the replicator filters. @@ -238,8 +270,7 @@ const settingsSetup: DatabaseSetup = { } // At app boot time: -const connection = nano(couchUri) -await setupDatabase(connection, settingsSetup) +await setupDatabase(pool, settingsSetup) ``` Finally, pass `replicatorSetup` and `currentCluster` options to the various `setupDatabase` calls in your app: @@ -247,16 +278,13 @@ Finally, pass `replicatorSetup` and `currentCluster` options to the various `set ```js const commonOptions = { replicatorSetup, - - // This could be read from a JSON config file instead: - currentCluster: process.env.HOSTNAME, + // ... } // At app boot time: -const connection = nano(couchUri) -await setupDatabase(connection, settingsSetup, commonOptions) -await setupDatabase(connection, productsSetup, commonOptions) -await setupDatabase(connection, usersSetup, commonOptions) +await setupDatabase(pool, settingsSetup, commonOptions) +await setupDatabase(pool, productsSetup, commonOptions) +await setupDatabase(pool, usersSetup, commonOptions) ``` ## Background Tasks @@ -264,7 +292,7 @@ await setupDatabase(connection, usersSetup, commonOptions) When setting up replication or synced documents, `setupDatabase` will create long-running background processes. These can interfere with CLI tools, which would like to exit quickly when they are done with their work. To avoid this problem, pass a `disableWatching` flag to `setupDatabase`: ```js -setupDatabase(connection, someSetup, { +setupDatabase(pool, someSetup, { disableWatching: true, }) ``` @@ -272,7 +300,7 @@ setupDatabase(connection, someSetup, { The `setupDatabase` function also returns a cleanup function, which will stop all background work (eventually - it can take a while). ```js -const cleanup = setupDatabase(connection, someSetup) +const cleanup = setupDatabase(pool, someSetup) // Later, at shutdown time: cleanup() @@ -281,7 +309,7 @@ cleanup() The database setup process can generate errors and messages, which go to the console by default, but can be intercepted by passing `log` and `onError` options: ```ts -setupDatabase(connection, someSetup, { +setupDatabase(pool, someSetup, { log(message: string) { console.log(message) }, diff --git a/src/couchdb/couch-pool.ts b/src/couchdb/couch-pool.ts new file mode 100644 index 0000000..8dcd18e --- /dev/null +++ b/src/couchdb/couch-pool.ts @@ -0,0 +1,163 @@ +import { asEither, asObject, asOptional, asString } from 'cleaners' +import nano, { ServerScope } from 'nano' + +/** + * A table of CouchDB connections, + * with one connection highlighted as the default. + */ +export interface CouchPool { + /** The default connection, to use for most queries. */ + default: ServerScope + + /** The cluster name being used for the default connection. */ + defaultName: string + + /** Lists the clusters that are available for connection. */ + clusterNames: string[] + + /** + * Grab a connection to a specific cluster. + * Throws if the name is missing. + */ + connect: (name: string) => ServerScope + + /** Grabs authentication information for a specific cluster. */ + getCredential: (name: string) => CouchCredential | undefined + + /** Grab a connection, returning undefined if the cluster is missing. */ + maybeConnect: (name: string) => ServerScope | undefined +} + +export interface CouchCredential { + url: string + username?: string + password?: string +} + +export interface CouchCredentials { + [name: string]: + | string + | CouchCredential + // The `setupDatabase` function uses this, but it's deprecated: + | ServerScope +} + +export const asCouchCredential = asObject({ + url: asString, + username: asOptional(asString), + password: asOptional(asString) +}) + +export const asCouchCredentials = asObject( + asEither(asCouchCredential, asString) +) + +export function connectCouch(url: string): CouchPool +export function connectCouch( + defaultName: string, + credentials: CouchCredentials +): CouchPool + +/** + * Returns a CouchDB connection pool. + */ +export function connectCouch( + urlOrDefaultName: string, + maybeCreds?: CouchCredentials +): CouchPool { + // Unpack the arguments: + const [defaultName, credentials] = + maybeCreds == null + ? // Just a URL: + ['default', { default: urlOrDefaultName }] + : // Full version: + [urlOrDefaultName, maybeCreds] + + const cache = new Map() + + function maybeConnect(name: string): ServerScope | undefined { + // Use the cache, if we have it: + const cached = cache.get(name) + if (cached != null) return cached + + // Find the credentials: + const row = credentials[name] + if (row == null) return + + // Plain URL: + if (typeof row === 'string') { + const connection = nano(row) + cache.set(name, connection) + return connection + } + + // Already-connected nano instance: + if ('relax' in row) { + cache.set(name, row) + return row + } + + // A credentials object: + const { url, username, password } = row + if (username == null || password == null) { + const connection = nano(url) + cache.set(name, connection) + return connection + } + const connection = nano({ + url, + requestDefaults: { + auth: { username, password } + } + }) + cache.set(name, connection) + return connection + } + + function connect(name: string): ServerScope { + const connection = maybeConnect(name) + if (connection == null) { + throw new Error(`Cannot find cluster '${name}'`) + } + return connection + } + + function extractUrlCredentials(url: string): CouchCredential { + const parsed = new URL(url) + const { username, password } = parsed + + // Remove credentials from the URL: + parsed.username = '' + parsed.password = '' + return { + url: parsed.toString(), + username, + password + } + } + + function getCredential(name: string): CouchCredential | undefined { + const row = credentials[name] + if (row == null) return + if (typeof row === 'string') return extractUrlCredentials(row) + if ('relax' in row) return extractUrlCredentials(row.config.url) + + const cleanUrl = extractUrlCredentials(row.url) + return { + url: cleanUrl.url, + username: row.username ?? cleanUrl.username, + password: row.password ?? cleanUrl.password + } + } + + return { + default: connect(defaultName), + defaultName, + + clusterNames: Object.keys(credentials), + + connect, + getCredential, + maybeConnect + } +} diff --git a/src/couchdb/js-design-document.ts b/src/couchdb/js-design-document.ts index b325b8a..a5eda07 100644 --- a/src/couchdb/js-design-document.ts +++ b/src/couchdb/js-design-document.ts @@ -72,12 +72,14 @@ export function stringifyCode(code: (...args: any[]) => unknown): string { } interface JsDesignOptions { - // Methods available using 'require': + /** Methods available using 'require'. */ lib?: Lib - // Transforms the Javascript source code. - // This could be anything from a simple search & replace - // to running something complicated like Babel: + /** + * Transforms the Javascript source code. + * This could be anything from a simple search & replace + * to running something complicated like Babel. + */ fixJs?: (code: string) => string // Couch options: diff --git a/src/couchdb/replication.ts b/src/couchdb/replication.ts index 75ac541..fbbf982 100644 --- a/src/couchdb/replication.ts +++ b/src/couchdb/replication.ts @@ -18,6 +18,7 @@ const asEdgeServersInternalResponse = asObject({ ) }) +/** @deprecated use `setupDatabase` instead. */ export async function autoReplication( infoServerAddress: string, serverName: string, @@ -64,6 +65,7 @@ export async function autoReplication( } } +/** @deprecated use `setupDatabase` instead. */ export async function dbReplication( sourceUrl: string, targetUrl: string diff --git a/src/couchdb/replicator-setup-document.ts b/src/couchdb/replicator-setup-document.ts index 96d72c3..987fb30 100644 --- a/src/couchdb/replicator-setup-document.ts +++ b/src/couchdb/replicator-setup-document.ts @@ -11,25 +11,49 @@ import { asHealingObject } from '../cleaners/as-healing-object' import { ReplicatorDocument, ReplicatorEndpoint } from './replicator-document' import { DatabaseSetup } from './setup-database' +/** + * Configures which databases a CouchDB cluster contains, + * and who it replicates with. + * + * The various filters wildcard matching with a trailing `*`, + * For example, 'logs-*' matches 'logs-2019' and 'logs-2020'. + */ interface ReplicatorCluster { - // The base URI to connect to: - url: string + /** + * The base URI to connect to. + * @deprecated Use `CouchPool` to hold credentials + */ + url?: string - // The base64 "username:password" pair used to authenticate. - // To get this, run `btoa('username:password')` in a JS console: + /** + * The base64-encoded "username:password" used for authentication. + * To generate this, run `btoa('username:password')` in a browser console. + * @deprecated Use `CouchPool` to hold credentials + */ basicAuth?: string - // Database names that exist on this cluster. - // Use an ending '*' for wildcard matching. - // For example, 'logs-*' matches things like 'logs-2019' or 'logs-2020'. - exclude?: string[] // Do not create or replicate these - include?: string[] // Create and replicate these - localOnly?: string[] // Create these but don't replicate them + /** + * List of databases to exclude from creation or replication. + * Supports wildcards and tags. Defaults to `['#exclude']`. + */ + exclude?: string[] + + /** + * List of databases to create and replicate. + * Supports wildcards and tags. Defaults to `[*]` + */ + include?: string[] - // Cluster names to replicate with. - // Use an ending '*' for wildcard matching. - // For example, 'logs-*' matches things like 'logs-eu' or 'logs-us'. + /** + * List of databases to create but not replicate. + * Supports wildcards and tags. + */ + localOnly?: string[] + + /** Cluster names to replicate with. */ pullFrom: string[] + + /** Cluster names to replicate with. */ pushTo: string[] } @@ -128,23 +152,31 @@ export function makeReplicatorDocuments( if (!replicated) continue if (includesName(cluster.pullFrom, remoteName)) { - documents[`${name}.from.${remoteName}`] = { - continuous: true, - create_target: false, - owner: currentUsername, - source: makeEndpoint(remoteCluster, name), - target: makeEndpoint(cluster, name) + const source = makeEndpoint(remoteCluster, name) + const target = makeEndpoint(cluster, name) + if (source != null && target != null) { + documents[`${name}.from.${remoteName}`] = { + continuous: true, + create_target: false, + owner: currentUsername, + source, + target + } } } if (includesName(cluster.pushTo, remoteName)) { - documents[`${name}.to.${remoteName}`] = { - continuous: true, - create_target: true, - create_target_params: options, - owner: currentUsername, - source: makeEndpoint(cluster, name), - target: makeEndpoint(remoteCluster, name) + const source = makeEndpoint(cluster, name) + const target = makeEndpoint(remoteCluster, name) + if (source != null && target != null) { + documents[`${name}.to.${remoteName}`] = { + continuous: true, + create_target: true, + create_target_params: options, + owner: currentUsername, + source, + target + } } } } @@ -212,11 +244,14 @@ function includesName(list: string[], name: string): boolean { function makeEndpoint( cluster: ReplicatorCluster, db: string -): ReplicatorEndpoint { +): ReplicatorEndpoint | undefined { + if (cluster.url == null) return const url = `${cluster.url.replace(/[/]$/, '')}/${db}` - return cluster.basicAuth == null - ? url - : { url, headers: { Authorization: `Basic ${cluster.basicAuth}` } } + if (cluster.basicAuth == null) return url + return { + url, + headers: { Authorization: `Basic ${cluster.basicAuth}` } + } } const asClusterRowRaw = asObject({ diff --git a/src/couchdb/rolling-database.ts b/src/couchdb/rolling-database.ts index 58b8071..f285543 100644 --- a/src/couchdb/rolling-database.ts +++ b/src/couchdb/rolling-database.ts @@ -18,6 +18,7 @@ import nano, { import { asCouchDoc, CouchDoc, + CouchPool, DatabaseSetup, makePeriodicTask, setupDatabase, @@ -31,16 +32,16 @@ import { clusterHasDatabase } from './replicator-setup-document' * Describes a rolling collection of Couch databases that should exist. */ export interface RollingDatabaseSetup extends DatabaseSetup { - // How far back should we create archive databases: + /** How far back should we create archive databases. */ archiveStart?: Date - // Cleans documents stored in the databases: + /** Cleans documents stored in the databases. */ cleaner: Cleaner - // Extracts the date from a stored document: + /** Extracts the date from a stored document. */ getDate: (doc: CouchDoc) => Date - // How often to create new databases: + /** How often to create new databases. */ period: PeriodicMonth } @@ -49,10 +50,10 @@ export interface RollingDatabaseSetup extends DatabaseSetup { */ export interface RollingMangoQuery extends Pick { - // How far in the past we should look. Defaults to no limit: + /** How far in the past we should look. Defaults to no limit. */ afterDate?: Date - // Which partition should we use: + /** Which partition should we use. */ partition?: string } @@ -60,10 +61,10 @@ export interface RollingMangoQuery * Arguments to the rolling database view query. */ export interface RollingViewParams extends DocumentViewParams { - // How far in the past we should look. Defaults to no limit: + /** How far in the past we should look. Defaults to no limit */ afterDate?: Date - // Which partition should we use: + /** Which partition should we use. */ partition?: string } @@ -71,7 +72,7 @@ export interface RollingViewParams extends DocumentViewParams { * Arguments to the rolling database reduce query. */ export interface RollingReduceParams extends RollingViewParams { - // Cleans the view output: + /** Cleans the view output. */ cleaner: Cleaner } @@ -127,8 +128,12 @@ export interface RollingDatabase { doc: CouchDoc ) => Promise + /** + * Create & maintain the database across one or more clusters. + * Passing a single connection is deprecated - pass a pool instead. + */ setup: ( - connection: ServerScope, + pool: CouchPool | ServerScope, opts?: SetupDatabaseOptions ) => Promise<() => void> } @@ -144,13 +149,13 @@ export interface RollingDatabase { * before the query routines try to access it and fail. */ type RollingDatabaseList = Array<{ - // True to tag this database as '#archived': + /** True to tag this database as '#archived'. */ archived: boolean - // The database name: + /** The database name. */ name: string - // The date we start writing documents to this database: + /** The date we start writing documents to this database. */ startDate: Date }> @@ -442,7 +447,7 @@ export function makeRollingDatabase( } async function setup( - connection: ServerScope, + pool: CouchPool | ServerScope, opts: SetupDatabaseOptions = {} ): Promise<() => void> { let cleanups: Array<() => void> = [] @@ -450,6 +455,7 @@ export function makeRollingDatabase( currentCluster, disableWatching = false, log = console.log, + watchCluster, onError = error => { log(`Error while maintaining "${name}" databases: ${String(error)}`) }, @@ -464,7 +470,13 @@ export function makeRollingDatabase( readDbList().catch(onError) } } - const listDbCleanup = await setupDatabase(connection, listDbSetup, opts) + const listDbCleanup = await setupDatabase(pool, listDbSetup, opts) + const connection = + 'relax' in pool + ? pool + : watchCluster == null + ? pool.default + : pool.connect(watchCluster) const listDb = connection.use(listDbSetup.name) /** @@ -537,7 +549,7 @@ export function makeRollingDatabase( setup ) if (exists) { - cleanups.push(await setupDatabase(connection, setup, opts)) + cleanups.push(await setupDatabase(pool, setup, opts)) existingDatabases.push(row) } } diff --git a/src/couchdb/setup-database.ts b/src/couchdb/setup-database.ts index 7e08fbb..e5ba772 100644 --- a/src/couchdb/setup-database.ts +++ b/src/couchdb/setup-database.ts @@ -5,6 +5,7 @@ import { asMaybeExistsError, asMaybeNotFoundError } from './couch-error-cleaners' +import { connectCouch, CouchPool } from './couch-pool' import { clusterHasDatabase, makeReplicatorDocuments, @@ -18,76 +19,116 @@ import { watchDatabase, WatchDatabaseOptions } from './watch-database' */ export interface DatabaseSetup extends Pick { - // The database name: + /** The database name. */ name: string - // Options to pass to CouchDB when creating this database: + /** Options to pass to CouchDB when creating this database. */ options?: DatabaseCreateParams - // Documents that should exactly match: + /** Documents that should exactly match. */ documents?: { [id: string]: object } - // Documents that we should create, unless they already exist: + /** Documents that we should create, unless they already exist. */ templates?: { [id: string]: object } - // Used for filtering, in addition to the wallet name: + /** Used for replicator filtering, in addition to the wallet name. */ tags?: Array<`#${string}`> - // Deprecated. Adds '#archived' to default tag list: + /** @deprecated Adds '#archived' to default tag list. */ ignoreMissing?: boolean - // Deprecated. Put this in the options instead: + /** @deprecated Put this in the options instead. */ replicatorSetup?: SyncedDocument } export interface SetupDatabaseOptions { - // The couch cluster name the current client is connected to, - // as described in the replicator setup document. - // This controls which databases and replications we create. - // Falls back to "default" if missing: - currentCluster?: string - - // Describes which database and replications should exist on each cluster: + /** + * Describes which database and replications should exist + * on each cluster + */ replicatorSetup?: SyncedDocument - // The setup routine will subscribe to the changes feed if - // the setup includes an `onChange` callback or synced documents. - // This option disables watching, performing a one-time sync instead. + /** + * The setup routine will subscribe to the changes feed if + * the setup includes an `onChange` callback or synced documents. + * This option disables watching, performing a one-time sync instead. + */ disableWatching?: boolean - // Logs status messages whenever we write things to Couch: + /** + * Don't create databases, design documents, or replications, + * but do print messages. + */ + dryRun?: boolean + + /** + * Do not do any setup or replication work. + */ + syncOnly?: boolean + + /** Logs status messages whenever we write things to Couch. */ log?: (message: string) => void - // Logs error messages whenever something goes wrong: + /** Use this cluster for watching for changes. */ + watchCluster?: string + + /** Logs error messages whenever something goes wrong. */ onError?: (error: unknown) => void + + /** + * @deprecated Pass a CouchPool to `setupDatabase` instead. + * The couch cluster name the current client is connected to, + * as described in the replicator setup document. + * This controls which databases and replications we create. + * Falls back to "default" if missing: + */ + currentCluster?: string } /** * Ensures that the requested database exists in CouchDB. + * + * The first parameter should be a `CouchPool` object. + * Passing anything else is deprecated. + * * Returns a cleanup function, which removes any background tasks. */ export async function setupDatabase( - connectionOrUri: nano.ServerScope | string, + poolOrConnection: CouchPool | nano.ServerScope | string, setupInfo: DatabaseSetup, opts: SetupDatabaseOptions = {} ): Promise<() => void> { const { name, onChange, syncedDocuments = [] } = setupInfo const { - replicatorSetup = setupInfo.replicatorSetup, + currentCluster = 'default', disableWatching = false, log = console.log, + replicatorSetup = setupInfo.replicatorSetup, + syncOnly = false, + watchCluster, onError = error => { log(`Error while maintaining database "${name}": ${String(error)})`) } } = opts - const connection = - typeof connectionOrUri === 'string' - ? nano(connectionOrUri) - : connectionOrUri + + const pool = + typeof poolOrConnection === 'string' || 'relax' in poolOrConnection + ? connectCouch(currentCluster, { [currentCluster]: poolOrConnection }) + : poolOrConnection // Run the setup once to ensure the database exists: - const db = await doSetup(connection, setupInfo, opts) - if (db == null) return () => {} + if (!syncOnly) await doSetups(pool, setupInfo, opts) + const connection = + watchCluster == null ? pool.default : pool.connect(watchCluster) + const db = connection.use(name) + + // Should we sync documents? + const { exists } = clusterHasDatabase( + replicatorSetup?.doc, + watchCluster ?? pool.defaultName, + setupInfo + ) + if (!exists) return () => {} // Update or watch synced documents: const cleanups: Array<() => void> = [] @@ -104,7 +145,7 @@ export async function setupDatabase( if (replicatorSetup != null && !disableWatching) { cleanups.push( replicatorSetup.onChange(() => { - doSetup(connection, setupInfo, opts).catch(onError) + doSetups(pool, setupInfo, opts).catch(onError) }) ) } @@ -112,39 +153,68 @@ export async function setupDatabase( return () => cleanups.forEach(cleanup => cleanup()) } +async function doSetups( + pool: CouchPool, + setupInfo: DatabaseSetup, + opts: SetupDatabaseOptions +): Promise { + const { replicatorSetup = setupInfo.replicatorSetup } = opts + + for (const clusterName of pool.clusterNames) { + // Bail out if the current cluster doesn't have this database: + const { exists, replicated } = clusterHasDatabase( + replicatorSetup?.doc, + clusterName, + setupInfo + ) + + if (exists) { + await doSetup(pool, clusterName, setupInfo, opts) + if (replicated) { + await doReplication(pool, clusterName, setupInfo, opts) + } + } + } +} + /** * Performs the actual work of database setup. * This is a one-shot process, and doesn't subscribe to anything. */ async function doSetup( - connection: nano.ServerScope, + pool: CouchPool, + clusterName: string, setupInfo: DatabaseSetup, opts: SetupDatabaseOptions -): Promise | undefined> { +): Promise { const { documents = {}, name, options, templates = {} } = setupInfo - const { - currentCluster, - log = console.log, - replicatorSetup = setupInfo.replicatorSetup - } = opts + const { dryRun = false, log = console.log } = opts + const prefix = dryRun ? 'dry-run: ' : '' - // Bail out if the current cluster doesn't have this database: - const { exists, replicated } = clusterHasDatabase( - replicatorSetup?.doc, - currentCluster, - setupInfo - ) - if (!exists) return + const connection = pool.maybeConnect(clusterName) + if (connection == null) return // Create the database if needed: const existingInfo = await connection.db.get(name).catch(error => { if (asMaybeNotFoundError(error) == null) throw error }) if (existingInfo == null) { + log(`${prefix}Creating database "${name}" on cluster "${clusterName}".`) + + // Bail out (with logs) if this is a dry-run: + if (dryRun) { + const docs = [...Object.keys(documents), ...Object.keys(templates)] + for (const id of docs) { + log( + `${prefix}Writing document "${id}" in database "${name}" on cluster "${clusterName}".` + ) + } + return + } + await connection.db.create(name, options).catch(error => { if (asMaybeExistsError(error) == null) throw error }) - log(`Created database "${name}"`) } const db: DocumentScope = connection.db.use(name) @@ -156,8 +226,11 @@ async function doSetup( }) if (!matchJson(documents[id], rest)) { + log( + `${prefix}Writing document "${id}" in database "${name}" on cluster "${clusterName}".` + ) + if (dryRun) continue await db.insert({ _id, _rev, ...documents[id] }) - log(`Wrote document "${id}" in database "${name}".`) } } @@ -169,31 +242,77 @@ async function doSetup( }) if (_rev == null) { + log( + `${prefix}Writing document "${id}" in database "${name}" on cluster "${clusterName}".` + ) + if (dryRun) continue await db.insert({ _id, ...templates[id] }) - log(`Wrote document "${id}" in database "${name}".`) } } +} - if (replicated) { - // Figure out the current username: - const sessionInfo = await connection.session() - const currentUsername: string = sessionInfo.userCtx.name - - // Set up replication: - await doSetup( - connection, - { - name: '_replicator', - documents: makeReplicatorDocuments( - replicatorSetup?.doc, - currentCluster, - currentUsername, - setupInfo - ) - }, - opts - ) +async function doReplication( + pool: CouchPool, + clusterName: string, + setupInfo: DatabaseSetup, + opts: SetupDatabaseOptions +): Promise { + const replicatorSetup = mergeReplicatorCredentials( + pool, + opts.replicatorSetup?.doc ?? setupInfo.replicatorSetup?.doc + ) + + const connection = pool.maybeConnect(clusterName) + if (connection == null) return + + // Figure out the current username: + const credential = pool.getCredential(clusterName) + const currentUsername = + credential?.username ?? + // We can remove this slow fallback in the next breaking release, + // since this will only happen when putting a `nano.ServerScope` into + // CouchPool, which is deprecated: + (await connection.session()).userCtx.name + + // Set up replication: + const documents = makeReplicatorDocuments( + replicatorSetup, + clusterName, + currentUsername, + setupInfo + ) + await doSetup(pool, clusterName, { name: '_replicator', documents }, opts) +} + +/** + * Adds credentials to a replicator setup document. + * + * The replicator document can contain credentials, but that's deprecated. + * Copy credentials from the CouchPool to the replicator document, + * so we remain backwards-compatible. + */ +function mergeReplicatorCredentials( + pool: CouchPool, + doc: ReplicatorSetupDocument | undefined +): ReplicatorSetupDocument { + const merged: ReplicatorSetupDocument = { clusters: {} } + if (doc == null) return merged + + for (const name of Object.keys(doc.clusters)) { + const row = doc.clusters[name] + merged.clusters[name] = row + + // If we have credentials in the pool, use those: + const credential = pool.getCredential(name) + if (credential != null) { + const { url, username, password } = credential + const basicAuth = + username != null && password != null + ? btoa(`${username}:${password}`) + : undefined + merged.clusters[name] = { ...row, url, basicAuth } + } } - return db + return merged } diff --git a/src/couchdb/watch-database.ts b/src/couchdb/watch-database.ts index 5073ac5..cdddd02 100644 --- a/src/couchdb/watch-database.ts +++ b/src/couchdb/watch-database.ts @@ -10,13 +10,13 @@ export interface CouchChange { } export interface WatchDatabaseOptions { - // Documents to automatically keep up-to-date: + /** Documents to automatically keep up-to-date. */ syncedDocuments?: Array> - // Provides low-level access to the change feed: + /** Provides low-level access to the change feed. */ onChange?: (change: CouchChange) => void - // Called if there is an error in the watching loop: + /** Called if there is an error in the watching loop. */ onError?: (error: unknown) => void } diff --git a/src/index.ts b/src/index.ts index 28dde8e..18a0a3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,18 @@ export * from './couchdb/as-couch-doc' export * from './couchdb/bulk-get' export * from './couchdb/couch-error-cleaners' + +export type { + CouchPool, + CouchCredential, + CouchCredentials +} from './couchdb/couch-pool' +export { + asCouchCredential, + asCouchCredentials, + connectCouch +} from './couchdb/couch-pool' + export * from './couchdb/for-each-document' export * from './couchdb/js-design-document' export * from './couchdb/mango-design-document' diff --git a/test/couchdb/couch-pool.test.ts b/test/couchdb/couch-pool.test.ts new file mode 100644 index 0000000..5a3a7ab --- /dev/null +++ b/test/couchdb/couch-pool.test.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai' +import { describe } from 'mocha' + +import { connectCouch } from '../../src' + +describe('CouchPool', function () { + it('returns credentials', function () { + const pool = connectCouch('logs', { + production: { + url: 'https://production.example.com:6984/', + username: 'prod-u', + password: 'prod-p' + }, + logs: { + url: 'https://logsu:logsp@logs.example.com:6984/' + }, + backup: 'https://backu:backp@backup.example.com:6984/' + }) + + expect(pool.defaultName).equals('logs') + expect(pool.clusterNames).deep.equals(['production', 'logs', 'backup']) + expect(pool.getCredential('missing')).equals(undefined) + + expect(pool.getCredential('production')).deep.equals({ + url: 'https://production.example.com:6984/', + username: 'prod-u', + password: 'prod-p' + }) + + expect(pool.getCredential('logs')).deep.equals({ + url: 'https://logs.example.com:6984/', + username: 'logsu', + password: 'logsp' + }) + + expect(pool.getCredential('backup')).deep.equals({ + url: 'https://backup.example.com:6984/', + username: 'backu', + password: 'backp' + }) + }) +})