Skip to content

Commit

Permalink
Create Database Command (#435)
Browse files Browse the repository at this point in the history
* setup create database

* add tests and types

* setup error handling for common errors

* cleanup

* appease linter

* address commnents
  • Loading branch information
henryfauna authored Nov 25, 2024
1 parent 40d10bd commit 10dabb3
Show file tree
Hide file tree
Showing 4 changed files with 303 additions and 2 deletions.
43 changes: 41 additions & 2 deletions src/commands/database/create.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
//@ts-check

import { FaunaError, fql } from "fauna";

import { container } from "../../cli.mjs";
import { commonQueryOptions } from "../../lib/command-helpers.mjs";
import { throwForV10Error } from "../../lib/fauna.mjs";

async function createDatabase() {
async function createDatabase(argv) {
const logger = container.resolve("logger");
logger.stdout(`TBD`);
const runV10Query = container.resolve("runV10Query");

try {
await runV10Query({
url: argv.url,
secret: argv.secret,
query: fql`Database.create({
name: ${argv.name},
protected: ${argv.protected ?? null},
typechecked: ${argv.typechecked ?? null},
priority: ${argv.priority ?? null},
})`,
});
logger.stdout(`Database ${argv.name} created`);
} catch (e) {
if (e instanceof FaunaError) {
throwForV10Error(e, {
onConstraintFailure: () =>
`Constraint failure: The database '${argv.name}' may already exists or one of the provided options may be invalid.`,
});
}
throw e;
}
}

function buildCreateCommand(yargs) {
Expand All @@ -14,6 +40,19 @@ function buildCreateCommand(yargs) {
type: "string",
description: "the name of the database to create",
},
typechecked: {
type: "string",
description: "enable typechecking for the database",
},
protected: {
type: "boolean",
description: "allow destructive schema changes",
},
priority: {
type: "number",
description: "user-defined priority assigned to the child database",
},
...commonQueryOptions,
})
.demandOption("name")
.version(false)
Expand Down
5 changes: 5 additions & 0 deletions src/config/setup-container.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { makeAccountRequest } from "../lib/account.mjs";
import OAuthClient from "../lib/auth/oauth-client.mjs";
import { getSimpleClient } from "../lib/command-helpers.mjs";
import { makeFaunaRequest } from "../lib/db.mjs";
import { getV10Client,runV10Query } from "../lib/fauna.mjs";
import { FaunaAccountClient } from "../lib/fauna-account-client.mjs";
import fetchWrapper from "../lib/fetch-wrapper.mjs";
import { AccountKey, SecretKey } from "../lib/file-util.mjs";
Expand Down Expand Up @@ -76,6 +77,10 @@ export const injectables = {
secretCreds: awilix.asClass(SecretKey, { lifetime: Lifetime.SCOPED }),
errorHandler: awilix.asValue((error, exitCode) => exit(exitCode)),

// utilities for interacting with Fauna
runV10Query: awilix.asValue(runV10Query),
getV10Client: awilix.asValue(getV10Client),

// feature-specific lib (homemade utilities)
gatherFSL: awilix.asValue(gatherFSL),
gatherRelativeFSLFilePaths: awilix.asValue(gatherRelativeFSLFilePaths),
Expand Down
150 changes: 150 additions & 0 deletions src/lib/fauna.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//@ts-check

/**
* @fileoverview Fauna V10 client utilities for query execution and error handling.
*/

import {
Client,
ClientClosedError,
ClientError,
NetworkError,
ProtocolError,
ServiceError,
} from "fauna";

/**
* Default options for V10 Fauna queries.
*
* @type {import("fauna").QueryOptions}
*/
export const defaultV10QueryOptions = {
format: "simple",
typecheck: false,
};

/**
* Creates a V10 Client instance.
*
* @param {object} opts
* @param {string} opts.url
* @param {string} opts.secret
* @returns {Client}
*/
export const getV10Client = ({ url, secret }) => {
// Check for required arguments.
if (!url || !secret) {
throw new Error("A url and secret are required.");
}
// Create the client.
return new Client({ secret, endpoint: new URL(url) });
};

/**
* Runs a V10 Fauna query. A client may be provided, or a url
* and secret may be used to create one.
*
* @param {object} opts
* @param {import("fauna").Query<any>} opts.query
* @param {string} [opts.url]
* @param {string} [opts.secret]
* @param {Client} [opts.client]
* @param {import("fauna").QueryOptions} [opts.options]
* @returns {Promise<import("fauna").QuerySuccess<any>>}
*/
export const runV10Query = async ({
query,
url,
secret,
client,
options = {},
}) => {
// Check for required arguments.
if (!query) {
throw new Error("A query is required.");
} else if (!client && (!url || !secret)) {
throw new Error("A client or url and secret are required.");
}

// Create the client if one wasn't provided.
let _client =
client ??
getV10Client({
url: /** @type {string} */ (url), // We know this is a string because we check for !url above.
secret: /** @type {string} */ (secret), // We know this is a string because we check for !secret above.
});

// Run the query.
return _client
.query(query, { ...defaultV10QueryOptions, ...options })
.finally(() => {
// Clean up the client if one was created internally.
if (!client && _client) _client.close();
});
};

/**
* Error handler for errors thrown by the V10 driver. Custom handlers
* can be provided for different types of errors, and a default error
* message is thrown if no handler is provided.
*
* @param {import("fauna").FaunaError} e - The Fauna error to handle
* @param {object} [handlers] - Optional error handlers
* @param {(e: ServiceError) => string} [handlers.onInvalidQuery] - Handler for invalid query errors
* @param {(e: ServiceError) => string} [handlers.onInvalidRequest] - Handler for invalid request errors
* @param {(e: ServiceError) => string} [handlers.onAbort] - Handler for aborted operation errors
* @param {(e: ServiceError) => string} [handlers.onConstraintFailure] - Handler for constraint violation errors
* @param {(e: ServiceError) => string} [handlers.onUnauthorized] - Handler for unauthorized access errors
* @param {(e: ServiceError) => string} [handlers.onForbidden] - Handler for forbidden access errors
* @param {(e: ServiceError) => string} [handlers.onContendedTransaction] - Handler for transaction contention errors
* @param {(e: ServiceError) => string} [handlers.onLimitExceeded] - Handler for rate/resource limit errors
* @param {(e: ServiceError) => string} [handlers.onTimeOut] - Handler for timeout errors
* @param {(e: ServiceError) => string} [handlers.onInternalError] - Handler for internal server errors
* @param {(e: ClientError) => string} [handlers.onClientError] - Handler for general client errors
* @param {(e: ClientClosedError) => string} [handlers.onClientClosedError] - Handler for closed client errors
* @param {(e: NetworkError) => string} [handlers.onNetworkError] - Handler for network-related errors
* @param {(e: ProtocolError) => string} [handlers.onProtocolError] - Handler for protocol-related errors
* @throws {Error} Always throws an error with a message based on the error code or handler response
* @returns {never} This function always throws an error
*/
export const throwForV10Error = (e, handlers = {}) => {
if (e instanceof ServiceError) {
switch (e.code) {
case "invalid_query":
throw new Error(handlers.onInvalidQuery?.(e) ?? e.message);
case "invalid_request ":
throw new Error(handlers.onInvalidRequest?.(e) ?? e.message);
case "abort":
throw new Error(handlers.onAbort?.(e) ?? e.message);
case "constraint_failure":
throw new Error(handlers.onConstraintFailure?.(e) ?? e.message);
case "unauthorized":
throw new Error(
handlers.onUnauthorized?.(e) ??
"Authentication failed: Please either log in using 'fauna login' or provide a valid database secret with '--secret'",
);
case "forbidden":
throw new Error(handlers.onForbidden?.(e) ?? e.message);
case "contended_transaction":
throw new Error(handlers.onContendedTransaction?.(e) ?? e.message);
case "limit_exceeded":
throw new Error(handlers.onLimitExceeded?.(e) ?? e.message);
case "time_out":
throw new Error(handlers.onTimeOut?.(e) ?? e.message);
case "internal_error":
throw new Error(handlers.onInternalError?.(e) ?? e.message);
default:
throw e;
}
} else if (e instanceof ClientError) {
throw new Error(handlers.onClientError?.(e) ?? e.message);
} else if (e instanceof ClientClosedError) {
throw new Error(handlers.onClientClosedError?.(e) ?? e.message);
} else if (e instanceof NetworkError) {
throw new Error(handlers.onNetworkError?.(e) ?? e.message);
} else if (e instanceof ProtocolError) {
throw new Error(handlers.onProtocolError?.(e) ?? e.message);
} else {
throw e;
}
};
107 changes: 107 additions & 0 deletions test/database/create.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//@ts-check

import * as awilix from "awilix";
import { expect } from "chai";
import chalk from "chalk";
import { fql, ServiceError } from "fauna";
import sinon from "sinon";

import { builtYargs, run } from "../../src/cli.mjs";
import { setupTestContainer as setupContainer } from "../../src/config/setup-test-container.mjs";

describe("database create", () => {
let container, logger, runV10Query;

beforeEach(() => {
// reset the container before each test
container = setupContainer();
logger = container.resolve("logger");
runV10Query = container.resolve("runV10Query");
});

[
{ missing: "name", command: "database create --secret 'secret'" },
{ missing: "secret", command: "database create --name 'name'" },
].forEach(({ missing, command }) => {
it(`requires a ${missing}`, async () => {
try {
await run(command, container);
} catch (e) {}

const message = `${chalk.reset(await builtYargs.getHelp())}\n\n${chalk.red(
`Missing required argument: ${missing}`,
)}`;
expect(logger.stderr).to.have.been.calledWith(message);
expect(container.resolve("parseYargs")).to.have.been.calledOnce;
});
});

[
{
args: "--name 'testdb' --secret 'secret'",
expected: { name: "testdb", secret: "secret" },
},
{
args: "--name 'testdb' --secret 'secret' --typechecked",
expected: { name: "testdb", secret: "secret", typechecked: true },
},
{
args: "--name 'testdb' --secret 'secret' --protected",
expected: { name: "testdb", secret: "secret", protected: true },
},
{
args: "--name 'testdb' --secret 'secret' --priority 10",
expected: { name: "testdb", secret: "secret", priority: 10 },
},
].forEach(({ args, expected }) => {
describe("calls fauna with the user specified arguments", () => {
it(`${args}`, async () => {
await run(`database create ${args}`, container);
expect(runV10Query).to.have.been.calledOnceWith({
url: sinon.match.string,
secret: expected.secret,
query: fql`Database.create({
name: ${expected.name},
protected: ${expected.protected ?? null},
typechecked: ${expected.typechecked ?? null},
priority: ${expected.priority ?? null},
})`,
});
});
});
});

[
{
error: new ServiceError({
error: { code: "constraint_failure", message: "whatever" },
}),
expectedMessage:
"Constraint failure: The database 'testdb' may already exists or one of the provided options may be invalid.",
},
{
error: new ServiceError({
error: { code: "unauthorized", message: "whatever" },
}),
expectedMessage:
"Authentication failed: Please either log in using 'fauna login' or provide a valid database secret with '--secret'",
},
].forEach(({ error, expectedMessage }) => {
it(`handles ${error.code} errors when calling fauna`, async () => {
const runV10QueryStub = sinon.stub().rejects(error);
container.register({
runV10Query: awilix.asValue(runV10QueryStub),
});

try {
await run(
`database create --name 'testdb' --secret 'secret'`,
container,
);
} catch (e) {}

const message = `${chalk.reset(await builtYargs.getHelp())}\n\n${chalk.red(expectedMessage)}`;
expect(logger.stderr).to.have.been.calledWith(message);
});
});
});

0 comments on commit 10dabb3

Please sign in to comment.