Initial commit

This commit is contained in:
2019-09-25 04:39:03 +02:00
commit 74721bf744
22 changed files with 3480 additions and 0 deletions

29
src/db/index.ts Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
import { MigrationRepository } from "./migrations";
export interface Extensions {
migrations: MigrationRepository;
}
export { MigrationRepository };

View 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
View 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();
}

View File

@@ -0,0 +1 @@
SELECT id, name, applied_at FROM migrations ORDER BY applied_at

View File

@@ -0,0 +1 @@
INSERT INTO migrations (name) VALUES ($1);

View 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
)

View File

@@ -0,0 +1 @@
DROP FUNCTION set_updated_timestamp;

View File

@@ -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';

View File

@@ -0,0 +1 @@
DROP TABLE users;

View File

@@ -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
View 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
View 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
View 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;