mirror: initialize a GraphQL database mirror (#847)
Summary: This commit introduces the `Mirror` class that will be the centerpiece of the persistent-loading API as described in #622. An instance of this class represents a mirror of a remote GraphQL database, defined by a particular schema. In this commit, we add the construction logic, which includes a safety measure to ensure that the database is used within one version of the code and schema. Test Plan: Unit tests included, with full coverage; run `yarn unit`. wchargin-branch: mirror-class
This commit is contained in:
parent
62d3c180ee
commit
a93ad80ebc
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue