@@ -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 | |||||
} |