Initial commit
This commit is contained in:
29
src/db/index.ts
Normal file
29
src/db/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pgPromise, { IDatabase, IInitOptions } from "pg-promise";
|
||||
import { Extensions, MigrationRepository } from "./repos";
|
||||
|
||||
type ExtendedProtocol = IDatabase<Extensions> & Extensions;
|
||||
|
||||
const initOptions: IInitOptions<Extensions> = {
|
||||
extend(obj: ExtendedProtocol, dc: any) {
|
||||
obj.migrations = new MigrationRepository(obj, pgp);
|
||||
}
|
||||
};
|
||||
|
||||
const pgp: pgPromise.IMain = pgPromise(initOptions);
|
||||
const db: ExtendedProtocol = pgp(process.env.PG_CONNECTION_STRING);
|
||||
export { db, pgp };
|
||||
7
src/db/models.ts
Normal file
7
src/db/models.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export interface Migration {
|
||||
id: number;
|
||||
name: string;
|
||||
applied_at: DateTime;
|
||||
}
|
||||
7
src/db/repos/index.ts
Normal file
7
src/db/repos/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { MigrationRepository } from "./migrations";
|
||||
|
||||
export interface Extensions {
|
||||
migrations: MigrationRepository;
|
||||
}
|
||||
|
||||
export { MigrationRepository };
|
||||
44
src/db/repos/migrations.ts
Normal file
44
src/db/repos/migrations.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { IDatabase, IMain } from "pg-promise";
|
||||
import logger from "../../logger";
|
||||
import { Migration } from "../models";
|
||||
import { migrations as sql } from "../sql";
|
||||
|
||||
export class MigrationRepository {
|
||||
private db: IDatabase<any>;
|
||||
|
||||
constructor(db: IDatabase<any>, pgp: IMain) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public async create() {
|
||||
await this.db.none(sql.create);
|
||||
}
|
||||
|
||||
public async apply() {
|
||||
const applied = (await this.applied()).map(m => m.name);
|
||||
const toApply = sql.patches.filter(
|
||||
p => p.up.isSome() && !applied.find(o => o === p.name)
|
||||
);
|
||||
|
||||
for (const patch of toApply) {
|
||||
logger.info("Applying migration", { name: patch.name });
|
||||
await patch.up
|
||||
.map(async qf => {
|
||||
await this.db.none(qf);
|
||||
await this.db.none(sql.apply, [patch.name]);
|
||||
})
|
||||
.orLazy(() => Promise.resolve());
|
||||
}
|
||||
}
|
||||
|
||||
public async applied(): Promise<Migration[]> {
|
||||
return this.db.map<Migration>(sql.applied, [], row => {
|
||||
return {
|
||||
applied_at: DateTime.fromSQL(row.applied_at),
|
||||
id: +row.id,
|
||||
name: row.name
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
45
src/db/sql/index.ts
Normal file
45
src/db/sql/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
import { Maybe, None, Some } from "monet";
|
||||
import path from "path";
|
||||
import { QueryFile } from "pg-promise";
|
||||
|
||||
const migrations = {
|
||||
applied: sql("migrations/applied.sql"),
|
||||
apply: sql("migrations/apply.sql"),
|
||||
create: sql("migrations/create.sql"),
|
||||
patches: subdirs(path.join("migrations", "patches")).map(patchName => ({
|
||||
down: ifExists(
|
||||
path.join("migrations", "patches", patchName, "down.sql")
|
||||
).map(sql),
|
||||
name: patchName,
|
||||
up: ifExists(path.join("migrations", "patches", patchName, "up.sql")).map(
|
||||
sql
|
||||
)
|
||||
}))
|
||||
};
|
||||
|
||||
export { migrations };
|
||||
|
||||
/** Helper for linking to external query files */
|
||||
function sql(file: string): QueryFile {
|
||||
const fullPath = path.join(__dirname, file);
|
||||
|
||||
return new QueryFile(fullPath, { minify: true });
|
||||
}
|
||||
|
||||
function ifExists(file: string): Maybe<string> {
|
||||
const fullPath = path.join(__dirname, file);
|
||||
if (existsSync(fullPath)) {
|
||||
return Some(file);
|
||||
} else {
|
||||
return None();
|
||||
}
|
||||
}
|
||||
|
||||
function subdirs(dir: string): string[] {
|
||||
const fullPath = path.join(__dirname, dir);
|
||||
return readdirSync(fullPath, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory)
|
||||
.map(dirent => dirent.name)
|
||||
.sort();
|
||||
}
|
||||
1
src/db/sql/migrations/applied.sql
Normal file
1
src/db/sql/migrations/applied.sql
Normal file
@@ -0,0 +1 @@
|
||||
SELECT id, name, applied_at FROM migrations ORDER BY applied_at
|
||||
1
src/db/sql/migrations/apply.sql
Normal file
1
src/db/sql/migrations/apply.sql
Normal file
@@ -0,0 +1 @@
|
||||
INSERT INTO migrations (name) VALUES ($1);
|
||||
5
src/db/sql/migrations/create.sql
Normal file
5
src/db/sql/migrations/create.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text UNIQUE NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
DROP FUNCTION set_updated_timestamp;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE OR REPLACE FUNCTION set_updated_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF row(NEW.*) IS DISTINCT FROM row(OLD.*) THEN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
ELSE
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE users;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE users (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
encrypted_password text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TRIGGER set_users_updated BEFORE UPDATE ON users FOR EACH ROW EXECUTE PROCEDURE set_updated_timestamp();
|
||||
59
src/graphql.ts
Normal file
59
src/graphql.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ApolloServer, gql } from "apollo-server-express";
|
||||
import { Kind } from "graphql/language";
|
||||
import { GraphQLScalarType, GraphQLScalarTypeConfig } from "graphql/type";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { db } from "./db";
|
||||
|
||||
const typeDefs = gql`
|
||||
type Query {
|
||||
"A simple type for getting started"
|
||||
hello: String
|
||||
migrations: [Migration]!
|
||||
}
|
||||
|
||||
type Migration {
|
||||
id: ID
|
||||
name: String!
|
||||
applied_at: DateTime!
|
||||
}
|
||||
|
||||
scalar DateTime
|
||||
`;
|
||||
|
||||
const dateTimeConfig: GraphQLScalarTypeConfig<DateTime, string> = {
|
||||
description: "Date custom scalar type",
|
||||
name: "DateTime",
|
||||
parseValue(value) {
|
||||
return DateTime.fromISO(value as string);
|
||||
},
|
||||
serialize(value) {
|
||||
return (value as DateTime).toISO();
|
||||
},
|
||||
parseLiteral(ast) {
|
||||
if (ast.kind === Kind.STRING) {
|
||||
return DateTime.fromISO(ast.value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvers = {
|
||||
DateTime: new GraphQLScalarType(dateTimeConfig),
|
||||
Query: {
|
||||
hello: async () => {
|
||||
return `Hello, world!`;
|
||||
},
|
||||
migrations: async () => {
|
||||
return db.migrations.applied();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function server() {
|
||||
return new ApolloServer({
|
||||
resolvers,
|
||||
typeDefs
|
||||
});
|
||||
}
|
||||
40
src/index.ts
Normal file
40
src/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import express from "express";
|
||||
import pinoExpress from "express-pino-logger";
|
||||
import { db } from "./db";
|
||||
import { server as graphqlServer } from "./graphql";
|
||||
import logger from "./logger";
|
||||
|
||||
async function main() {
|
||||
await db.migrations.create();
|
||||
await db.migrations.apply();
|
||||
|
||||
const server = graphqlServer();
|
||||
const app = express();
|
||||
const expressPino = pinoExpress({ logger });
|
||||
app.use(expressPino);
|
||||
server.applyMiddleware({ app, path: "/graphql" });
|
||||
const port = 3000;
|
||||
|
||||
app.listen(port, () =>
|
||||
logger.info("Example app listening", {
|
||||
uri: `http://localhost:${port}${server.graphqlPath}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
24
src/logger.ts
Normal file
24
src/logger.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pino from "pino";
|
||||
|
||||
const loggerOptions: pino.LoggerOptions = {};
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
loggerOptions.prettyPrint = true;
|
||||
}
|
||||
const logger = pino(loggerOptions);
|
||||
|
||||
export default logger;
|
||||
Reference in New Issue
Block a user