@@ -0,0 +1,3 @@ | |||
node_modules/ | |||
dist/ | |||
database/ |
@@ -0,0 +1,7 @@ | |||
const path = require('path'); | |||
module.exports = { | |||
'config-path': path.resolve('database', 'config', 'config.js'), | |||
'seeders-path': path.resolve('database', 'seeders'), | |||
'migrations-path': path.resolve('database', 'migrations') | |||
}; |
@@ -0,0 +1,3 @@ | |||
# Zeichen - Backend | |||
Zeichen's backing service for remote storage. |
@@ -0,0 +1,18 @@ | |||
const dotenv = require('dotenv') | |||
dotenv.config() | |||
module.exports = { | |||
"development": { | |||
"host": process.env.DATABASE_URL, | |||
"dialect": process.env.DATABASE_DIALECT | |||
}, | |||
"test": { | |||
"host": process.env.DATABASE_URL, | |||
"dialect": process.env.DATABASE_DIALECT | |||
}, | |||
"production": { | |||
"host": process.env.DATABASE_URL, | |||
"dialect": process.env.DATABASE_DIALECT | |||
} | |||
} |
@@ -0,0 +1,25 @@ | |||
import models from './src/models' | |||
import seeds from './src/seeds' | |||
// TODO support NoSQL | |||
export const up = async queryInterface => { | |||
const createTablePromises = models.map(m => queryInterface.createTable(m.tableName, m.attributes)) | |||
await Promise.all(createTablePromises) | |||
const seedTablePromise = models | |||
.filter(m => Boolean(seeds[m.modelName])) | |||
.map(m => { | |||
console.log(JSON.stringify(seeds[m.modelName])) | |||
return queryInterface.bulkInsert(m.tableName, seeds[m.tableName]) | |||
}) | |||
return Promise.all(seedTablePromise) | |||
} | |||
export const down = async queryInterface => { | |||
const dropTablePromises = models | |||
.reduce((reverse, m) => [m, ...reverse], []) | |||
.map(m => queryInterface.dropTable(m.tableName)) | |||
return Promise.all(dropTablePromises) | |||
} |
@@ -0,0 +1,22 @@ | |||
{ | |||
"name": "zeichen-backend", | |||
"description": "Zeichen's backing service for remote storage.", | |||
"version": "0.1.0", | |||
"dependencies": { | |||
"sequelize": "5.22.0", | |||
"dotenv": "^8.2.0" | |||
}, | |||
"devDependencies": { | |||
"sequelize-cli": "^6.2.0", | |||
"sqlite3": "^5.0.0", | |||
"typescript": "^4.0.3" | |||
}, | |||
"optionalDependencies": { | |||
"sqlite3": "^5.0.0" | |||
}, | |||
"scripts": { | |||
"migrate": "tsc ./migrate.ts --module commonjs --esModuleInterop --outDir database/migrations && sequelize-cli db:migrate", | |||
"migrate:run": "sequelize-cli db:migrate" | |||
}, | |||
"license": "MIT" | |||
} |
@@ -0,0 +1,13 @@ | |||
import Folder from './models/Folder' | |||
import Note from './models/Note' | |||
import Tag from './models/Tag' | |||
import Operation from './models/Operation' | |||
import Transaction from './models/Transaction' | |||
export default [ | |||
Folder, | |||
Note, | |||
Tag, | |||
Operation, | |||
Transaction, | |||
] |
@@ -0,0 +1,42 @@ | |||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||
const Folder: ColumnTypes.Model = { | |||
modelName: 'Folder', | |||
tableName: 'folders', | |||
options: { | |||
timestamps: true, | |||
paranoid: true, | |||
createdAt: 'createdAt', | |||
updatedAt: 'updatedAt', | |||
deletedAt: 'deletedAt', | |||
}, | |||
attributes: { | |||
id: { | |||
allowNull: true, | |||
primaryKey: true, | |||
type: ColumnTypes.UUIDV4, | |||
}, | |||
name: { | |||
allowNull: false, | |||
type: ColumnTypes.STRING, | |||
}, | |||
parentId: { | |||
allowNull: true, | |||
type: ColumnTypes.UUIDV4, | |||
}, | |||
createdAt: { | |||
allowNull: false, | |||
type: ColumnTypes.DATE, | |||
}, | |||
updatedAt: { | |||
allowNull: false, | |||
type: ColumnTypes.DATE, | |||
}, | |||
deletedAt: { | |||
allowNull: true, | |||
type: ColumnTypes.DATE, | |||
}, | |||
} | |||
} | |||
export default Folder |
@@ -0,0 +1,46 @@ | |||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||
const Note: ColumnTypes.Model = { | |||
modelName: 'Note', | |||
tableName: 'notes', | |||
options: { | |||
timestamps: true, | |||
paranoid: true, | |||
createdAt: 'createdAt', | |||
updatedAt: 'updatedAt', | |||
deletedAt: 'deletedAt', | |||
}, | |||
attributes: { | |||
id: { | |||
allowNull: true, | |||
primaryKey: true, | |||
type: ColumnTypes.UUIDV4, | |||
}, | |||
title: { | |||
allowNull: false, | |||
type: ColumnTypes.STRING, | |||
}, | |||
content: { | |||
allowNull: true, | |||
type: ColumnTypes.TEXT({ length: 'long', }), | |||
}, | |||
folderId: { | |||
allowNull: true, | |||
type: ColumnTypes.UUIDV4, | |||
}, | |||
createdAt: { | |||
allowNull: false, | |||
type: ColumnTypes.DATE, | |||
}, | |||
updatedAt: { | |||
allowNull: false, | |||
type: ColumnTypes.DATE, | |||
}, | |||
deletedAt: { | |||
allowNull: true, | |||
type: ColumnTypes.DATE, | |||
}, | |||
}, | |||
} | |||
export default Note |
@@ -0,0 +1,22 @@ | |||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||
const Operation: ColumnTypes.Model = { | |||
modelName: 'Operation', | |||
tableName: 'operations', | |||
options: { | |||
timestamps: false, | |||
}, | |||
attributes: { | |||
id: { | |||
allowNull: true, | |||
primaryKey: true, | |||
type: ColumnTypes.INTEGER, | |||
}, | |||
name: { | |||
allowNull: false, | |||
type: ColumnTypes.STRING, | |||
}, | |||
} | |||
} | |||
export default Operation |
@@ -0,0 +1,22 @@ | |||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||
const Tag: ColumnTypes.Model = { | |||
modelName: 'Tag', | |||
tableName: 'tags', | |||
options: { | |||
timestamps: false, | |||
}, | |||
attributes: { | |||
id: { | |||
allowNull: true, | |||
primaryKey: true, | |||
type: ColumnTypes.UUIDV4, | |||
}, | |||
name: { | |||
allowNull: false, | |||
type: ColumnTypes.STRING, | |||
}, | |||
} | |||
} | |||
export default Tag |
@@ -0,0 +1,34 @@ | |||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||
const Transaction: ColumnTypes.Model = { | |||
modelName: 'Transaction', | |||
tableName: 'transactions', | |||
options: { | |||
timestamps: false, | |||
}, | |||
attributes: { | |||
id: { | |||
allowNull: true, | |||
primaryKey: true, | |||
type: ColumnTypes.UUIDV4, | |||
}, | |||
deviceId: { | |||
allowNull: false, | |||
type: ColumnTypes.STRING, | |||
}, | |||
operation: { | |||
allowNull: false, | |||
type: ColumnTypes.INTEGER, | |||
}, | |||
objectId: { | |||
allowNull: false, | |||
type: ColumnTypes.STRING, | |||
}, | |||
performedAt: { | |||
allowNull: false, | |||
type: ColumnTypes.DATE, | |||
}, | |||
}, | |||
} | |||
export default Transaction |
@@ -0,0 +1,13 @@ | |||
import { Access } from '../../core/src/data/access' | |||
export default { | |||
'operations': ( | |||
Object | |||
.entries(Access) | |||
.filter(([, value]) => !isNaN(Number(value))) | |||
.map(([name, id]) => ({ | |||
id: Number(id), | |||
name, | |||
})) | |||
), | |||
} |
@@ -0,0 +1,64 @@ | |||
import FolderModel from '../models/Folder' | |||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||
import * as Response from '../utilities/Response' | |||
type Folder = ColumnTypes.InferModel<typeof FolderModel> | |||
export const getSingle = repository => async (id: string) => { | |||
const instance = await repository.findByPk(id) | |||
if (instance === null) { | |||
throw new Response.NotFound({ message: 'Not found.' }) | |||
} | |||
return new Response.Retrieved({ | |||
data: instance.toJSON() as Folder, | |||
}) | |||
} | |||
export const getMultiple = repository => async (query?: Record<string, unknown>) => { | |||
const fetchMethod = async query => { | |||
if (query) { | |||
return repository.findWhere({ | |||
attributes: query, | |||
}) | |||
} | |||
return repository.findAll() | |||
} | |||
const instances = await fetchMethod(query) | |||
return new Response.Retrieved<Folder[]>({ | |||
data: instances.map(i => i.toJSON()), | |||
}) | |||
} | |||
export const save = repository => (body: Partial<Folder>) => async (id: string, idColumnName = 'id') => { | |||
const [newInstance, created] = await repository.findOrCreate({ | |||
where: { [idColumnName]: id }, | |||
defaults: { | |||
...body, | |||
[idColumnName]: id, | |||
}, | |||
}) | |||
if (created) { | |||
return new Response.Created({ | |||
data: newInstance.toJSON() as Folder | |||
}) | |||
} | |||
Object.entries(body).forEach(([key, value]) => { | |||
newInstance[key] = value | |||
}) | |||
newInstance[idColumnName] = id | |||
const updatedInstance = await newInstance.save() | |||
return new Response.Saved({ | |||
data: updatedInstance.toJSON() as Folder | |||
}) | |||
} | |||
export const remove = repository => async (id: string) => { | |||
const instanceDAO = repository.findByPk(id) | |||
if (instanceDAO === null) { | |||
throw new Response.NotFound({ message: 'Not found.' }) | |||
} | |||
await instanceDAO.destroy() | |||
return new Response.Destroyed() | |||
} |
@@ -0,0 +1,78 @@ | |||
import Model from '../models/Note' | |||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||
import * as Response from '../utilities/Response' | |||
type Note = ColumnTypes.InferModel<typeof Model> | |||
export const getSingle = repository => async (id: string) => { | |||
const instanceDAO = await repository.findByPk(id) | |||
if (instanceDAO === null) { | |||
throw new Response.NotFound({ message: 'Not found.' }) | |||
} | |||
const instance = instanceDAO.toJSON() | |||
return new Response.Retrieved({ | |||
data: { | |||
...instance, | |||
content: instance.content ? JSON.parse(instance.content) : null, | |||
}, | |||
}) | |||
} | |||
export const getMultiple = repository => async (query: Record<string, unknown>) => { | |||
const instances = await repository.findAll() | |||
return new Response.Retrieved({ | |||
data: instances.map(instanceDAO => { | |||
const instance = instanceDAO.toJSON() | |||
return { | |||
...instance, | |||
content: instance.content ? JSON.parse(instance.content) : null, | |||
} | |||
}), | |||
}) | |||
} | |||
export const save = repository => (body: Partial<Note>) => async (id: string, idColumnName = 'id') => { | |||
const { content: contentRaw, ...etcBody } = body | |||
const content = contentRaw! ? JSON.stringify(contentRaw) : null | |||
const [dao, created] = await repository.findOrCreate({ | |||
where: { [idColumnName]: id }, | |||
defaults: { | |||
...etcBody, | |||
[idColumnName]: id, | |||
content, | |||
}, | |||
}) | |||
if (created) { | |||
const newInstance = dao.toJSON() | |||
return new Response.Created({ | |||
data: { | |||
...newInstance, | |||
content: newInstance.content ? JSON.parse(newInstance.content) : null, | |||
}, | |||
}) | |||
} | |||
Object.entries(body).forEach(([key, value]) => { | |||
dao[key] = value | |||
}) | |||
dao['content'] = content | |||
dao[idColumnName] = id | |||
const updatedDAO = await dao.save() | |||
const updatedInstance = updatedDAO.toJSON() | |||
return new Response.Saved({ | |||
data: { | |||
...updatedInstance, | |||
content: updatedInstance.content ? JSON.parse(updatedInstance.content) : null, | |||
} | |||
}) | |||
} | |||
export const remove = repository => async (id: string) => { | |||
const instanceDAO = await repository.findByPk(id) | |||
if (instanceDAO === null) { | |||
throw new Response.NotFound({ message: 'Not found.' }) | |||
} | |||
await instanceDAO.destroy() | |||
return new Response.Destroyed() | |||
} |
@@ -0,0 +1,60 @@ | |||
import { | |||
DataType, | |||
INTEGER, | |||
STRING, | |||
TEXT, | |||
DATE, | |||
DATEONLY, | |||
UUIDV4, | |||
} from 'sequelize' | |||
type ModelAttribute = { | |||
allowNull?: boolean, | |||
primaryKey?: boolean, | |||
type: DataType, | |||
} | |||
export interface Model { | |||
tableName?: string, | |||
modelName?: string, | |||
options?: { | |||
timestamps?: boolean, | |||
paranoid?: boolean, | |||
createdAt?: string | boolean, | |||
updatedAt?: string | boolean, | |||
deletedAt?: string | boolean, | |||
}, | |||
attributes: Record<string, ModelAttribute>, | |||
} | |||
type IntegerType = typeof INTEGER | ReturnType<typeof INTEGER> | |||
type NumberType = IntegerType | |||
type VarcharType = typeof STRING | |||
type TextType = typeof TEXT | ReturnType<typeof TEXT> | |||
type StringType = VarcharType | TextType | |||
type DateTimeType = typeof DATE | |||
type DateOnlyType = typeof DATEONLY | |||
type DateType = DateTimeType | DateOnlyType | |||
type InferType<V extends DataType> = ( | |||
V extends NumberType ? number : | |||
V extends StringType ? string : | |||
V extends DateType ? Date : | |||
V extends typeof UUIDV4 ? string : | |||
unknown | |||
) | |||
export type InferModel<M extends Model> = { | |||
[K in keyof M['attributes']]-?: InferType<M['attributes'][K]['type']> | |||
} | |||
export { | |||
INTEGER, | |||
STRING, | |||
TEXT, | |||
DATEONLY, | |||
DATE, | |||
UUIDV4, | |||
} |
@@ -0,0 +1,51 @@ | |||
import { Sequelize, Dialect, } from 'sequelize' | |||
import * as ColumnTypes from './ColumnTypes' | |||
export enum DatabaseKind { | |||
MSSQL = 'mssql', | |||
SQLITE = 'sqlite', | |||
MYSQL = 'mysql', | |||
MARIADB = 'mariadb', | |||
POSTGRESQL = 'postgres', | |||
} | |||
type RepositoryParams = { | |||
url: string, | |||
kind: DatabaseKind, | |||
} | |||
export default class ORM { | |||
private readonly instance: Sequelize | |||
constructor(params: RepositoryParams) { | |||
const { kind, url, } = params | |||
switch (kind as DatabaseKind) { | |||
case DatabaseKind.SQLITE: | |||
this.instance = new Sequelize({ | |||
dialect: kind as string as Dialect, | |||
storage: url, | |||
}) | |||
return | |||
case DatabaseKind.POSTGRESQL: | |||
case DatabaseKind.MARIADB: | |||
case DatabaseKind.MSSQL: | |||
case DatabaseKind.MYSQL: | |||
this.instance = new Sequelize({ | |||
dialect: kind as string as Dialect, | |||
host: url, | |||
}) | |||
return | |||
// TODO add nosql dbs | |||
default: | |||
break | |||
} | |||
throw new Error(`Database kind "${kind as string}" not yet supported.`) | |||
} | |||
getRepository(model: ColumnTypes.Model) { | |||
if (this.instance instanceof Sequelize) { | |||
return this.instance.define(model.modelName, model.attributes, model.options) | |||
} | |||
throw new Error('Interface not yet initialized.') | |||
} | |||
} |
@@ -0,0 +1,49 @@ | |||
interface Response<T extends {} = {}> { | |||
status: number, | |||
data?: T, | |||
} | |||
interface ErrorResponse extends Response { | |||
message: string, | |||
} | |||
type ResponseParams<T extends {} = {}> = { | |||
message?: string, | |||
data?: T, | |||
} | |||
export class NotFound implements ErrorResponse { | |||
public readonly status = 404 | |||
public readonly message: string | |||
constructor(params: ResponseParams) { | |||
this.message = params.message | |||
} | |||
} | |||
export class Created<T extends {}> implements Response { | |||
public readonly status = 201 | |||
public readonly data: T | |||
constructor(params: ResponseParams<T>) { | |||
this.data = params.data | |||
} | |||
} | |||
export class Saved<T extends {}> implements Response { | |||
public readonly status = 200 | |||
public readonly data: T | |||
constructor(params: ResponseParams<T>) { | |||
this.data = params.data | |||
} | |||
} | |||
export class Retrieved<T extends {}> implements Response { | |||
public readonly status = 200 | |||
public readonly data: T | |||
constructor(params: ResponseParams<T>) { | |||
this.data = params.data | |||
} | |||
} | |||
export class Destroyed implements Response { | |||
public readonly status = 204 | |||
} |