diff --git a/src/graphql/mirror.js b/src/graphql/mirror.js index f39e663..b4b51f7 100644 --- a/src/graphql/mirror.js +++ b/src/graphql/mirror.js @@ -1,6 +1,70 @@ // @flow import type Database from "better-sqlite3"; +import stringify from "json-stable-stringify"; + +import * as Schema from "./schema"; + +/** + * A local mirror of a subset of a GraphQL database. + */ +export class Mirror { + +_db: Database; + +_schema: Schema.Schema; + + /** + * Create a GraphQL mirror using the given database connection and + * GraphQL schema. + * + * The connection must be to a database that either (a) is empty and + * unused, or (b) has been previously used for a GraphQL mirror with + * an identical GraphQL schema. The database attached to the + * connection must not be modified by any other clients. In other + * words, passing a connection to this constructor entails transferring + * ownership of the attached database to this module. + * + * If the database attached to the connection has been used with an + * incompatible GraphQL schema or an outdated version of this module, + * an error will be thrown and the database will remain unmodified. + */ + constructor(db: Database, schema: Schema.Schema): void { + if (db == null) throw new Error("db: " + String(db)); + if (schema == null) throw new Error("schema: " + String(schema)); + this._db = db; + this._schema = schema; + this._initialize(); + } + + _initialize() { + // The following version number must be updated if there is any + // change to the way in which a GraphQL schema is mapped to a SQL + // schema or the way in which the resulting SQL schema is + // interpreted. If you've made a change and you're not sure whether + // it requires bumping the version, bump it: requiring some extra + // one-time cache resets is okay; doing the wrong thing is not. + const blob = stringify({version: "MIRROR_v1", schema: this._schema}); + // We store the metadata in a singleton table `meta`, whose unique row + // has primary key `0`. Only the first ever insert will succeed; we + // are locked into the first schema. + this._db + .prepare( + "CREATE TABLE IF NOT EXISTS meta\n" + + "(zero INTEGER PRIMARY KEY, schema TEXT NOT NULL)" + ) + .run(); + this._db + .prepare("INSERT OR IGNORE INTO meta (zero, schema) VALUES (0, ?)") + .run(blob); + const result = this._db + .prepare("SELECT COUNT(1) AS n FROM meta WHERE schema = ?") + .get(blob); + if (result.n !== 1) { + throw new Error( + "Database already populated with incompatible schema or version" + ); + } + } +} /** * Execute a function inside a database transaction. diff --git a/src/graphql/mirror.test.js b/src/graphql/mirror.test.js index 0446784..5989800 100644 --- a/src/graphql/mirror.test.js +++ b/src/graphql/mirror.test.js @@ -1,11 +1,120 @@ // @flow import Database from "better-sqlite3"; +import fs from "fs"; import tmp from "tmp"; -import {_inTransaction} from "./mirror"; +import * as Schema from "./schema"; +import {_inTransaction, Mirror} from "./mirror"; describe("graphql/mirror", () => { + function buildGithubSchema(): Schema.Schema { + const s = Schema; + return s.schema({ + Repository: s.object({ + id: s.id(), + url: s.primitive(), + issues: s.connection("Issue"), + }), + Issue: s.object({ + id: s.id(), + url: s.primitive(), + author: s.node("Actor"), + parent: s.node("Repository"), + title: s.primitive(), + comments: s.connection("IssueComment"), + }), + IssueComment: s.object({ + id: s.id(), + body: s.primitive(), + author: s.node("Actor"), + }), + Actor: s.union(["User", "Bot", "Organization"]), // actually an interface + User: s.object({ + id: s.id(), + url: s.primitive(), + login: s.primitive(), + }), + Bot: s.object({ + id: s.id(), + url: s.primitive(), + login: s.primitive(), + }), + Organization: s.object({ + id: s.id(), + url: s.primitive(), + login: s.primitive(), + }), + }); + } + + describe("Mirror", () => { + describe("constructor", () => { + it("initializes a new database successfully", () => { + const db = new Database(":memory:"); + const schema = buildGithubSchema(); + expect(() => new Mirror(db, schema)).not.toThrow(); + }); + + it("fails if the database connection is `null`", () => { + // $ExpectFlowError + expect(() => new Mirror(null, buildGithubSchema())).toThrow("db: null"); + }); + + it("fails if the schema is `null`", () => { + // $ExpectFlowError + expect(() => new Mirror(new Database(":memory:"), null)).toThrow( + "schema: null" + ); + }); + + it("is idempotent", () => { + // We use an on-disk database file here so that we can dump the + // contents to ensure that the database is physically unchanged. + const filename = tmp.fileSync().name; + const schema = buildGithubSchema(); + + const db0 = new Database(filename); + new Mirror(db0, schema); + db0.close(); + const data0 = fs.readFileSync(filename).toJSON(); + + const db1 = new Database(filename); + new Mirror(db1, schema); + db1.close(); + const data1 = fs.readFileSync(filename).toJSON(); + + expect(data0).toEqual(data1); + }); + + it("rejects a different schema without changing the database", () => { + const s = Schema; + const schema0 = s.schema({A: s.object({id: s.id()})}); + const schema1 = s.schema({B: s.object({id: s.id()})}); + + // We use an on-disk database file here so that we can dump the + // contents to ensure that the database is physically unchanged. + const filename = tmp.fileSync().name; + const db = new Database(filename); + expect(() => new Mirror(db, schema0)).not.toThrow(); + const data = fs.readFileSync(filename).toJSON(); + + expect(() => new Mirror(db, schema1)).toThrow( + "incompatible schema or version" + ); + expect(fs.readFileSync(filename).toJSON()).toEqual(data); + + expect(() => new Mirror(db, schema1)).toThrow( + "incompatible schema or version" + ); + expect(fs.readFileSync(filename).toJSON()).toEqual(data); + + expect(() => new Mirror(db, schema0)).not.toThrow(); + expect(fs.readFileSync(filename).toJSON()).toEqual(data); + }); + }); + }); + describe("_inTransaction", () => { it("runs its callback inside a transaction", () => { // We use an on-disk database file here because we need to open