Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create Database Command #435

Merged
merged 6 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
});