diff --git a/src/commands/schema/push.ts b/src/commands/schema/push.ts index 54beb661..1eaf3062 100644 --- a/src/commands/schema/push.ts +++ b/src/commands/schema/push.ts @@ -9,6 +9,11 @@ export default class PushSchemaCommand extends SchemaCommand { description: "Push the change without a diff or schema version check", default: false, }), + stage: Flags.boolean({ + description: + "Stages the schema change, instead of applying it immediately", + default: false, + }), }; static description = "Push the current project's .fsl files to Fauna."; @@ -16,6 +21,7 @@ export default class PushSchemaCommand extends SchemaCommand { static examples = [ "$ fauna schema push", "$ fauna schema push --dir schemas/myschema", + "$ fauna schema push --stage", ]; async run() { @@ -24,8 +30,14 @@ export default class PushSchemaCommand extends SchemaCommand { try { const { url, secret } = await this.fetchsetup(); if (this.flags?.force) { - // Just push. - const res = await fetch(new URL("/schema/1/update?force=true", url), { + const params = new URLSearchParams({ + force: "true", // Just push. + staged: this.flags?.stage ? "true" : "false", + }); + + // This is how MDN says to do it for some reason. + const path = new URL(`/schema/1/update?${params}`, url); + const res = await fetch(path, { method: "POST", headers: { AUTHORIZATION: `Bearer ${secret}` }, body: this.body(files), @@ -34,21 +46,30 @@ export default class PushSchemaCommand extends SchemaCommand { // @ts-expect-error-next-line duplex: "half", }); + const json = await res.json(); if (json.error) { - this.error(json.error.message); + this.error(json.error?.message ?? json.error); } } else { - // Confirm diff, then push it. - const res = await fetch(new URL("/schema/1/validate?force=true", url), { + // Confirm diff, then push it. `force` is set on `validate` so we don't + // need to pass the last known schema version through. + const params = new URLSearchParams({ + force: "true", + }); + const path = new URL(`/schema/1/validate?${params}`, url); + const res = await fetch(path, { method: "POST", headers: { AUTHORIZATION: `Bearer ${secret}` }, body: this.body(files), + // @ts-expect-error-next-line + duplex: "half", }); const json = await res.json(); if (json.error) { - this.error(json.error.message); + this.error(json.error?.message ?? json.error); } + let message = "Accept and push changes?"; if (json.diff) { this.log(`Proposed diff:\n`); @@ -61,17 +82,22 @@ export default class PushSchemaCommand extends SchemaCommand { message, default: false, }); + if (confirmed) { - const res = await fetch( - new URL(`/schema/1/update?version=${json.version}`, url), - { - method: "POST", - headers: { AUTHORIZATION: `Bearer ${secret}` }, - body: this.body(files), - // @ts-expect-error-next-line - duplex: "half", - } - ); + const params = new URLSearchParams({ + version: json.version, + staged: this.flags?.stage ? "true" : "false", + }); + + const path = new URL(`/schema/1/update?${params}`, url); + const res = await fetch(path, { + method: "POST", + headers: { AUTHORIZATION: `Bearer ${secret}` }, + body: this.body(files), + // @ts-expect-error-next-line + duplex: "half", + }); + const json0 = await res.json(); if (json0.error) { this.error(json0.error.message); diff --git a/test/integ/base.ts b/test/integ/base.ts new file mode 100644 index 00000000..36781aa5 --- /dev/null +++ b/test/integ/base.ts @@ -0,0 +1,130 @@ +import { runCommand } from "@oclif/test"; +import { fail } from "assert"; +import { env } from "process"; + +export type ShellResult = { stdout: string; stderr: string; ok: boolean }; + +const TEST_PREFIX = "fauna_shell_integ_test_"; + +export const newDB = async (secret?: string): Promise => { + const name = TEST_PREFIX + Math.floor(Math.random() * 1000000000); + + return evalOk( + stripMargin( + `|if (Database.byName('${name}').exists()) { + | Database.byName('${name}').delete() + |} + |Database.create({ name: '${name}', typechecked: true }) + |Key.create({ role: 'admin', database: '${name}' }).secret + |` + ), + secret + ); +}; + +export const cleanupDBs = async (): Promise => { + const { url, secret } = endpoint(); + + const query = stripMargin( + `|Database.all().forEach((db) => { + | if (db.name.startsWith('${TEST_PREFIX}')) { + | db.delete() + | } + |}) + |` + ); + + const res = await fetch(new URL("/query/1", url), { + method: "POST", + headers: { AUTHORIZATION: `Bearer ${secret}` }, + body: JSON.stringify({ query }), + // @ts-expect-error-next-line + duplex: "half", + }); + + if (res.status !== 200) { + fail(`Cleanup failed: ${await res.text()}`); + } +}; + +export const evalOk = async (code: string, secret?: string): Promise => { + const res = JSON.parse( + await shellOk(`fauna eval "${code}" --format json`, secret) + ); + // FIXME: This should really fail `shellOk`, but error handling is hard. + if (res?.error) { + fail(`Eval failed: ${res.summary}`); + } + + return res; +}; + +export const shellOk = async ( + cmd: string, + secret?: string +): Promise => { + const res = await shell(cmd, secret); + if (!res.ok) { + fail(`Command unexpectedly failed:\n${res.stderr}`); + } + + return res.stdout; +}; + +export const shellErr = async (cmd: string): Promise => { + const res = await shell(cmd); + if (res.ok) { + fail(`Command should not have exitted succesfully:\n${res.stdout}`); + } + + return res.stderr; +}; + +export const stripMargin = (str: string): string => { + return str + .split("\n") + .map((line) => { + const trimmed = line.trimStart(); + if (trimmed.startsWith("|")) { + return trimmed.slice(1); + } else { + return trimmed; + } + }) + .join("\n"); +}; + +export const shell = async ( + cmd: string, + secret?: string +): Promise => { + const parts = cmd.split(" "); + if (parts[0] !== "fauna") { + fail("Command must start with fauna"); + } + + const { url, secret: s } = endpoint(); + + const opts = [ + parts.slice(1).join(" "), + `--url ${url}`, + `--secret ${secret ?? s}`, + ]; + + const out = await runCommand(opts); + + return { + stdout: out.stdout, + stderr: out.stderr + out.error?.message, + ok: out.error === undefined, + }; +}; + +const endpoint = () => { + return { + url: `${env.FAUNA_SCHEMA ?? "http"}://${env.FAUNA_DOMAIN ?? "127.0.0.1"}:${ + env.FAUNA_PORT ?? 8443 + }`, + secret: env.FAUNA_SECRET ?? "secret", + }; +}; diff --git a/test/integ/schema.test.ts b/test/integ/schema.test.ts new file mode 100644 index 00000000..e35821d9 --- /dev/null +++ b/test/integ/schema.test.ts @@ -0,0 +1,52 @@ +import { expect } from "chai"; +import { cleanupDBs, evalOk, newDB, shellOk, stripMargin } from "./base"; + +describe("fauna schema staged commands", () => { + // Cleanup after ourselves. + after(async function () { + await cleanupDBs(); + }); + + it("fauna schema push --stage --force works", async () => { + const secret = await newDB(); + + await shellOk( + "fauna schema push --dir test/integ/schema/start --force", + secret + ); + + expect( + await evalOk("Collection.all().map(.name).toArray()", secret) + ).to.deep.equal(["User"]); + + await shellOk( + "fauna schema push --dir test/integ/schema/staged_index --force --stage", + secret + ); + + // Index should be in the FQL definition. + expect( + await evalOk("Collection.byName('User')!.indexes.byName", secret) + ).to.deep.equal({ + terms: [ + { + field: ".name", + mva: false, + }, + ], + queryable: true, + status: "complete", + }); + + // But, the index should not be visible on the companion object. + expect( + await evalOk( + stripMargin( + `|let user: Any = User + |user.byName` + ), + secret + ) + ).to.deep.equal(null); + }); +}); diff --git a/test/integ/schema/staged_index/main.fsl b/test/integ/schema/staged_index/main.fsl new file mode 100644 index 00000000..d84ef5a8 --- /dev/null +++ b/test/integ/schema/staged_index/main.fsl @@ -0,0 +1,8 @@ +collection User { + name: String + email: String + + index byName { + terms [.name] + } +} diff --git a/test/integ/schema/start/main.fsl b/test/integ/schema/start/main.fsl new file mode 100644 index 00000000..5d700109 --- /dev/null +++ b/test/integ/schema/start/main.fsl @@ -0,0 +1,4 @@ +collection User { + name: String + email: String +}