From b244429f084a900d12690efa26ed848ff33991ba Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sun, 29 Nov 2020 20:13:55 +0800 Subject: [PATCH] Attempt to implement pluggable interface Add support for plugins --- migrate.ts | 9 +++- package.json | 10 ++-- src/models/Folder.ts | 22 ++++---- src/models/Note.ts | 24 +++++---- src/models/Operation.ts | 16 +++--- src/models/Tag.ts | 16 +++--- src/models/Transaction.ts | 22 ++++---- src/pages/api/folders.ts | 37 ++++++++++++-- src/pages/api/folders/[id].ts | 44 ++++++++++++++-- src/pages/api/notes.ts | 37 ++++++++++++-- src/pages/api/notes/[id].ts | 44 ++++++++++++++-- src/plugins/local-storage/Engine.ts | 23 +++++++++ src/plugins/local-storage/Storage.ts | 55 ++++++++++++++++++++ src/plugins/local-storage/index.ts | 18 +++++++ src/plugins/remote-storage/Engine.ts | 70 +++++++++++++++++++++++++ src/plugins/remote-storage/Storage.ts | 27 ++++++++++ src/plugins/remote-storage/index.ts | 18 +++++++ src/seeds.ts | 15 +++--- src/services/Controller.ts | 74 --------------------------- src/services/Storage.ts | 4 +- src/services/{ => entities}/Folder.ts | 30 +++++++---- src/services/{ => entities}/Note.ts | 10 ++-- src/services/storages/Storage.ts | 25 +++++---- src/utilities/ColumnTypes.ts | 60 ++++++++++++++++++++++ src/utilities/Instance.ts | 36 ------------- src/utilities/ORM.ts | 11 ++-- src/utilities/Response.ts | 10 ++-- yarn.lock | 19 ------- zeichen.config.ts | 9 ++++ 29 files changed, 557 insertions(+), 238 deletions(-) create mode 100644 src/plugins/local-storage/Engine.ts create mode 100644 src/plugins/local-storage/Storage.ts create mode 100644 src/plugins/local-storage/index.ts create mode 100644 src/plugins/remote-storage/Engine.ts create mode 100644 src/plugins/remote-storage/Storage.ts create mode 100644 src/plugins/remote-storage/index.ts delete mode 100644 src/services/Controller.ts rename src/services/{ => entities}/Folder.ts (59%) rename src/services/{ => entities}/Note.ts (86%) create mode 100644 src/utilities/ColumnTypes.ts delete mode 100644 src/utilities/Instance.ts create mode 100644 zeichen.config.ts diff --git a/migrate.ts b/migrate.ts index 7d233c1..a7aa208 100644 --- a/migrate.ts +++ b/migrate.ts @@ -1,13 +1,18 @@ 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.rawAttributes)) + 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 => queryInterface.bulkInsert(m.tableName, seeds[m.modelName])) + .map(m => { + console.log(JSON.stringify(seeds[m.modelName])) + return queryInterface.bulkInsert(m.tableName, seeds[m.tableName]) + }) return Promise.all(seedTablePromise) } diff --git a/package.json b/package.json index cd1e8d4..d85479a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "migrate": "tsc migrate.ts --module commonjs --esModuleInterop --outDir database/migrations && sequelize-cli db:migrate" + "migrate": "tsc migrate.ts --module commonjs --esModuleInterop --outDir database/migrations && sequelize-cli db:migrate", + "migrate:run": "sequelize-cli db:migrate" }, "dependencies": { "@fingerprintjs/fingerprintjs": "^3.0.3", @@ -18,9 +19,6 @@ "react-feather": "^2.0.8", "react-mobiledoc-editor": "^0.10.0", "sequelize": "5.22.0", - "sequelize-cli": "^6.2.0", - "sequelize-typescript": "^1.1.0", - "sqlite3": "^5.0.0", "styled-components": "^5.2.0", "uuid": "^8.3.1" }, @@ -29,7 +27,9 @@ "@types/react": "^16.9.53", "@types/styled-components": "^5.1.4", "@types/uuid": "^8.3.0", - "typescript": "^4.0.3" + "typescript": "^4.0.3", + "sequelize-cli": "^6.2.0", + "sqlite3": "^5.0.0" }, "optionalDependencies": { "sqlite3": "^5.0.0" diff --git a/src/models/Folder.ts b/src/models/Folder.ts index 4996811..def65d0 100644 --- a/src/models/Folder.ts +++ b/src/models/Folder.ts @@ -1,8 +1,8 @@ -import { UUIDV4, STRING, DATE, } from 'sequelize' +import * as ColumnTypes from '../utilities/ColumnTypes' -export default { - tableName: 'folders', +const Folder: ColumnTypes.Model = { modelName: 'Folder', + tableName: 'folders', options: { timestamps: true, paranoid: true, @@ -10,31 +10,33 @@ export default { updatedAt: 'updatedAt', deletedAt: 'deletedAt', }, - rawAttributes: { + attributes: { id: { allowNull: true, primaryKey: true, - type: UUIDV4, + type: ColumnTypes.UUIDV4, }, name: { allowNull: false, - type: STRING, + type: ColumnTypes.STRING, }, parentId: { allowNull: true, - type: UUIDV4, + type: ColumnTypes.UUIDV4, }, createdAt: { allowNull: false, - type: DATE, + type: ColumnTypes.DATE, }, updatedAt: { allowNull: false, - type: DATE, + type: ColumnTypes.DATE, }, deletedAt: { allowNull: true, - type: DATE, + type: ColumnTypes.DATE, }, } } + +export default Folder diff --git a/src/models/Note.ts b/src/models/Note.ts index 8ed6443..159c787 100644 --- a/src/models/Note.ts +++ b/src/models/Note.ts @@ -1,8 +1,8 @@ -import { UUIDV4, STRING, TEXT, DATE, } from 'sequelize' +import * as ColumnTypes from '../utilities/ColumnTypes' -export default { - tableName: 'notes', +const Note: ColumnTypes.Model = { modelName: 'Note', + tableName: 'notes', options: { timestamps: true, paranoid: true, @@ -10,35 +10,37 @@ export default { updatedAt: 'updatedAt', deletedAt: 'deletedAt', }, - rawAttributes: { + attributes: { id: { allowNull: true, primaryKey: true, - type: UUIDV4, + type: ColumnTypes.UUIDV4, }, title: { allowNull: false, - type: STRING, + type: ColumnTypes.STRING, }, content: { allowNull: true, - type: TEXT({ length: 'long', }), + type: ColumnTypes.TEXT({ length: 'long', }), }, folderId: { allowNull: true, - type: UUIDV4, + type: ColumnTypes.UUIDV4, }, createdAt: { allowNull: false, - type: DATE, + type: ColumnTypes.DATE, }, updatedAt: { allowNull: false, - type: DATE, + type: ColumnTypes.DATE, }, deletedAt: { allowNull: true, - type: DATE, + type: ColumnTypes.DATE, }, }, } + +export default Note diff --git a/src/models/Operation.ts b/src/models/Operation.ts index e58e86a..ebac62f 100644 --- a/src/models/Operation.ts +++ b/src/models/Operation.ts @@ -1,20 +1,22 @@ -import { INTEGER, STRING, } from 'sequelize' +import * as ColumnTypes from '../utilities/ColumnTypes' -export default { +const Operation: ColumnTypes.Model = { + modelName: 'Operation', + tableName: 'operations', options: { timestamps: false, }, - modelName: 'Operation', - tableName: 'operations', - rawAttributes: { + attributes: { id: { allowNull: true, primaryKey: true, - type: INTEGER, + type: ColumnTypes.INTEGER, }, name: { allowNull: false, - type: STRING, + type: ColumnTypes.STRING, }, } } + +export default Operation diff --git a/src/models/Tag.ts b/src/models/Tag.ts index de74e4d..605c849 100644 --- a/src/models/Tag.ts +++ b/src/models/Tag.ts @@ -1,20 +1,22 @@ -import { UUIDV4, STRING, } from 'sequelize' +import * as ColumnTypes from '../utilities/ColumnTypes' -export default { +const Tag: ColumnTypes.Model = { + modelName: 'Tag', + tableName: 'tags', options: { timestamps: false, }, - modelName: 'Tag', - tableName: 'tags', - rawAttributes: { + attributes: { id: { allowNull: true, primaryKey: true, - type: UUIDV4, + type: ColumnTypes.UUIDV4, }, name: { allowNull: false, - type: STRING, + type: ColumnTypes.STRING, }, } } + +export default Tag diff --git a/src/models/Transaction.ts b/src/models/Transaction.ts index a2b77d5..f2138e0 100644 --- a/src/models/Transaction.ts +++ b/src/models/Transaction.ts @@ -1,32 +1,34 @@ -import { UUIDV4, STRING, DATE, INTEGER, } from 'sequelize' +import * as ColumnTypes from '../utilities/ColumnTypes' -export default { +const Transaction: ColumnTypes.Model = { + modelName: 'Transaction', + tableName: 'transactions', options: { timestamps: false, }, - modelName: 'Transaction', - tableName: 'transactions', - rawAttributes: { + attributes: { id: { allowNull: true, primaryKey: true, - type: UUIDV4, + type: ColumnTypes.UUIDV4, }, deviceId: { allowNull: false, - type: STRING, + type: ColumnTypes.STRING, }, operation: { allowNull: false, - type: INTEGER, + type: ColumnTypes.INTEGER, }, objectId: { allowNull: false, - type: STRING, + type: ColumnTypes.STRING, }, performedAt: { allowNull: false, - type: DATE, + type: ColumnTypes.DATE, }, }, } + +export default Transaction diff --git a/src/pages/api/folders.ts b/src/pages/api/folders.ts index 6c86f71..dbc95e6 100644 --- a/src/pages/api/folders.ts +++ b/src/pages/api/folders.ts @@ -1,5 +1,36 @@ +import ORM, { DatabaseKind } from '../../utilities/ORM' +import * as Service from '../../services/entities/Folder' import Model from '../../models/Folder' -import * as Service from '../../services/Folder' -import { collection } from '../../services/Controller' -export default collection(Model, Service) +export default async (req, res) => { + const orm = new ORM({ + kind: process.env.DATABASE_DIALECT as DatabaseKind, + url: process.env.DATABASE_URL, + }) + const repository = orm.getRepository(Model) + const methodHandlers = { + 'GET': Service.getMultiple(repository), + } + + const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers + if (handler === null) { + res.statusCode = 415 + res.json({ message: 'Method not allowed.' }) + return + } + + try { + const { status, data, } = await handler(req.query) + res.statusCode = status + res.json(data) + } catch (err) { + console.error(err) + const { status, data, } = err + res.statusCode = status + if (data && status !== 204) { + res.json(data) + return + } + res.end() + } +} diff --git a/src/pages/api/folders/[id].ts b/src/pages/api/folders/[id].ts index 9b36c85..d753afa 100644 --- a/src/pages/api/folders/[id].ts +++ b/src/pages/api/folders/[id].ts @@ -1,5 +1,43 @@ +import ORM, { DatabaseKind } from '../../../utilities/ORM' +import * as Service from '../../../services/entities/Folder' import Model from '../../../models/Folder' -import * as Service from '../../../services/Folder' -import { item } from '../../../services/Controller' -export default item(Model, Service) +export default async (req, res) => { + const orm = new ORM({ + kind: process.env.DATABASE_DIALECT as DatabaseKind, + url: process.env.DATABASE_URL, + }) + const repository = orm.getRepository(Model) + const methodHandlers = { + 'GET': Service.getSingle(repository), + 'PUT': Service.save(repository)(req.body), + 'DELETE': Service.remove(repository) + } + + const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers + if (handler === null) { + res.statusCode = 415 + res.json({ message: 'Method not allowed.' }) + return + } + + const { id } = req.query + try { + const { status, ...etcReturn } = await handler(id) + res.statusCode = status + if (etcReturn['data']) { + res.json(etcReturn['data']) + return + } + res.end() + } catch (err) { + console.error(err) + const { status, data, } = err + res.statusCode = status || 500 + if (data && status !== 204) { + res.json(data) + return + } + res.end() + } +} diff --git a/src/pages/api/notes.ts b/src/pages/api/notes.ts index 5206ebe..f34a80c 100644 --- a/src/pages/api/notes.ts +++ b/src/pages/api/notes.ts @@ -1,5 +1,36 @@ +import ORM, { DatabaseKind } from '../../utilities/ORM' +import * as Service from '../../services/entities/Note' import Model from '../../models/Note' -import * as Service from '../../services/Note' -import { collection } from '../../services/Controller' -export default collection(Model, Service) +export default async (req, res) => { + const orm = new ORM({ + kind: process.env.DATABASE_DIALECT as DatabaseKind, + url: process.env.DATABASE_URL, + }) + const repository = orm.getRepository(Model) + const methodHandlers = { + 'GET': Service.getMultiple(repository), + } + + const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers + if (handler === null) { + res.statusCode = 415 + res.json({ message: 'Method not allowed.' }) + return + } + + try { + const { status, data, } = await handler(req.query) + res.statusCode = status + res.json(data) + } catch (err) { + console.error(err) + const { status, data, } = err + res.statusCode = status + if (data && status !== 204) { + res.json(data) + return + } + res.end() + } +} diff --git a/src/pages/api/notes/[id].ts b/src/pages/api/notes/[id].ts index ddf82ea..6775734 100644 --- a/src/pages/api/notes/[id].ts +++ b/src/pages/api/notes/[id].ts @@ -1,5 +1,43 @@ +import ORM, { DatabaseKind } from '../../../utilities/ORM' +import * as Service from '../../../services/entities/Note' import Model from '../../../models/Note' -import * as Service from '../../../services/Note' -import { item } from '../../../services/Controller' -export default item(Model, Service) +export default async (req, res) => { + const orm = new ORM({ + kind: process.env.DATABASE_DIALECT as DatabaseKind, + url: process.env.DATABASE_URL, + }) + const repository = orm.getRepository(Model) + const methodHandlers = { + 'GET': Service.getSingle(repository), + 'PUT': Service.save(repository)(req.body), + 'DELETE': Service.remove(repository) + } + + const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers + if (handler === null) { + res.statusCode = 415 + res.json({ message: 'Method not allowed.' }) + return + } + + const { id } = req.query + try { + const { status, ...etcReturn } = await handler(id) + res.statusCode = status + if (etcReturn['data']) { + res.json(etcReturn['data']) + return + } + res.end() + } catch (err) { + console.error(err) + const { status, data, } = err + res.statusCode = status || 500 + if (data && status !== 204) { + res.json(data) + return + } + res.end() + } +} diff --git a/src/plugins/local-storage/Engine.ts b/src/plugins/local-storage/Engine.ts new file mode 100644 index 0000000..002f81d --- /dev/null +++ b/src/plugins/local-storage/Engine.ts @@ -0,0 +1,23 @@ +export default class LocalStorage { + constructor( + private readonly source: Storage, + private readonly serializer: (t: T) => string = JSON.stringify, + private readonly deserializer: (s: string) => T = JSON.parse, + ) {} + + getCollection(collectionId: string) { + const raw = this.source.getItem(collectionId) + if (raw !== null) { + return this.deserializer(raw) + } + return null + } + + replaceCollection(collectionId: string, collectionData: T) { + this.source.setItem(collectionId, this.serializer(collectionData)) + } + + removeCollection(collectionId: string) { + this.source.removeItem(collectionId) + } +} diff --git a/src/plugins/local-storage/Storage.ts b/src/plugins/local-storage/Storage.ts new file mode 100644 index 0000000..1cae26e --- /dev/null +++ b/src/plugins/local-storage/Storage.ts @@ -0,0 +1,55 @@ +import Storage, { StorageMeta, OutOfSyncError } from '../../services/storages/Storage' +import Engine from './Engine' + +export default class LocalStorage implements Storage { + private readonly engine: Engine> + + constructor( + private readonly ownerId: string, + private readonly storageId: string, + private readonly getItemId = item => item['id'], + ) { + this.engine = new Engine>(window.localStorage) + } + + private getMeta() { + const oldMeta = this.engine.getCollection(this.storageId) + if (oldMeta === null) { + throw new OutOfSyncError() + } + return oldMeta + } + + private existenceCheck(newItem: T) { + return oldItem => this.getItemId(oldItem) === this.getItemId(newItem) + } + + async queryItems() { + return this.getMeta().items + } + + async saveItem(newItem: T) { + const oldMeta = this.getMeta() + const isExistingItem = oldMeta.items.some(this.existenceCheck(newItem)) + const newMeta: StorageMeta = { + items: isExistingItem + ? oldMeta.items.map(oldItem => this.existenceCheck(newItem)(oldItem) ? newItem : oldItem) + : [...oldMeta.items, newItem], + lastModifiedBy: this.ownerId, + lastModifiedAt: new Date(), + } + this.engine.replaceCollection(this.storageId, newMeta) + } + + async deleteItem(newItem: T) { + const oldMeta = this.getMeta() + const newItems = oldMeta.items.filter(oldItem => !this.existenceCheck(newItem)(oldItem)) + const newMeta: StorageMeta = { + items: newItems, + lastModifiedBy: this.ownerId, + lastModifiedAt: new Date(), + } + this.engine.replaceCollection(this.storageId, newMeta) + return oldMeta.items.length !== newItems.length + } +} diff --git a/src/plugins/local-storage/index.ts b/src/plugins/local-storage/index.ts new file mode 100644 index 0000000..5ff07a0 --- /dev/null +++ b/src/plugins/local-storage/index.ts @@ -0,0 +1,18 @@ +import Storage from './Storage' + +type PluginConfig = { + +} + +type State = { + currentUserId: string, +} + +type Plugin = (config: PluginConfig) => (state: State) => void + +const LocalStoragePlugin: Plugin = config => ({ + currentUserId, +}) => { + new Storage(currentUserId, 'notes') + new Storage(currentUserId, 'folders') +} diff --git a/src/plugins/remote-storage/Engine.ts b/src/plugins/remote-storage/Engine.ts new file mode 100644 index 0000000..9b3800e --- /dev/null +++ b/src/plugins/remote-storage/Engine.ts @@ -0,0 +1,70 @@ +import { Deserializer, Serializer } from '../../services/storages/Storage' + +const CREATED = 201 +const NO_CONTENT = 204 +const NOT_FOUND = 404 +const GONE = 410 + +export default class RemoteStorage { + constructor( + private readonly baseUrl: string, + private readonly getItemId = item => item['id'], + private readonly serializers: Map = new Map([ + ['*/*', JSON.stringify], + ['application/json', JSON.stringify], + ['text/json', JSON.stringify], + ]), + private readonly deserializers: Map = new Map([ + ['*/*', JSON.parse], + ['application/json', JSON.parse], + ['text/json', JSON.parse], + ]), + ) {} + + async getCollection(collectionId: string) { + const response = await window.fetch([this.baseUrl, collectionId].join('/')) + const contentType = response.headers.get('content-type') + const { [contentType]: deserializer = this.deserializers.get('*/*'), } = Object.fromEntries(this.deserializers.entries()) + const payload = await response.text() + return deserializer(payload) + } + + async setItem(collectionId: string, item: U, contentType = 'application/json') { + const { [contentType]: serializer = this.serializers.get('application/json'), } = Object.fromEntries(this.serializers.entries()) + const response = await window.fetch([this.baseUrl, collectionId, this.getItemId(item)].join('/'), { + method: 'put', + body: serializer(item), + }) + // resource is created + if (response.status === CREATED) { + return + } + if (100 <= response.status && response.status <= 399) { + console.warn(`Expected response is ${CREATED}, got ${response.status}.`) + return + } + throw new Error(response.statusText) + } + + async removeItem(collectionId: string, item: U) { + const response = await window.fetch([this.baseUrl, collectionId, this.getItemId(item)].join('/'), { + method: 'delete', + }) + // resource is deleted + if (response.status === NO_CONTENT) { + return true + } + // resource is already deleted + if (response.status === NOT_FOUND || response.status === GONE) { + return false + } + if (100 <= response.status && response.status <= 399) { + console.warn(`Expected response is ${NO_CONTENT}, got ${response.status}.`) + // assume there's a change in the collection + return true + } + throw new Error(response.statusText) + } + + // TODO do removeCollection for account closing? +} diff --git a/src/plugins/remote-storage/Storage.ts b/src/plugins/remote-storage/Storage.ts new file mode 100644 index 0000000..d10b144 --- /dev/null +++ b/src/plugins/remote-storage/Storage.ts @@ -0,0 +1,27 @@ +import Storage, { StorageMeta } from '../../services/storages/Storage' +import Engine from './Engine' + +export default class RemoteStorage implements Storage { + private readonly engine: Engine> + + constructor( + private ownerId: string, + private baseUrl: string, + private collectionId: string, + ) { + this.engine = new Engine(baseUrl) + } + + async deleteItem(item: T) { + return this.engine.removeItem(this.collectionId, item) + } + + async queryItems() { + const response = await this.engine.getCollection(this.collectionId) as StorageMeta; + return response.items; + } + + async saveItem(newItem: T) { + return this.engine.setItem(this.collectionId, newItem) + } +} diff --git a/src/plugins/remote-storage/index.ts b/src/plugins/remote-storage/index.ts new file mode 100644 index 0000000..17726ef --- /dev/null +++ b/src/plugins/remote-storage/index.ts @@ -0,0 +1,18 @@ +import Storage from './Storage' + +type PluginConfig = { + baseUrl: string, +} + +type State = { + currentUserId: string, +} + +type Plugin = (config: PluginConfig) => (state: State) => void + +const RemoteStoragePlugin: Plugin = config => ({ + currentUserId, +}) => { + new Storage(currentUserId, config.baseUrl, 'notes') + new Storage(currentUserId, config.baseUrl, 'folders') +} diff --git a/src/seeds.ts b/src/seeds.ts index d029db4..edd2bb3 100644 --- a/src/seeds.ts +++ b/src/seeds.ts @@ -1,10 +1,13 @@ import Operation from './services/Operation' export default { - 'Operation': Object - .entries(Operation) - .map(([name, id]) => ({ - id: Number(id), - name, - })), + 'operations': ( + Object + .entries(Operation) + .filter(([, value]) => !isNaN(Number(value))) + .map(([name, id]) => ({ + id: Number(id), + name, + })) + ), } diff --git a/src/services/Controller.ts b/src/services/Controller.ts deleted file mode 100644 index cca99af..0000000 --- a/src/services/Controller.ts +++ /dev/null @@ -1,74 +0,0 @@ -import ORM, { DatabaseKind } from '../utilities/ORM' - -export const collection = (Model, Service) => async (req, res) => { - const orm = new ORM({ - kind: process.env.DATABASE_DIALECT as DatabaseKind, - url: process.env.DATABASE_URL, - }) - const repository = orm.getRepository(Model) - const methodHandlers = { - 'GET': Service.getMultiple(repository), - } - - const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers - if (handler === null) { - res.statusCode = 415 - res.json({ message: 'Method not allowed.' }) - return - } - - try { - const { status, data, } = await handler(req.query) - res.statusCode = status - res.json(data) - } catch (err) { - console.error(err) - const { status, data, } = err - res.statusCode = status - if (data && status !== 204) { - res.json(data) - return - } - res.end() - } -} - -export const item = (Model, Service) => async (req, res) => { - const orm = new ORM({ - kind: process.env.DATABASE_DIALECT as DatabaseKind, - url: process.env.DATABASE_URL, - }) - const repository = orm.getRepository(Model) - const methodHandlers = { - 'GET': Service.getSingle(repository), - 'PUT': Service.save(repository)(req.body), - 'DELETE': Service.remove(repository) - } - - const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers - if (handler === null) { - res.statusCode = 415 - res.json({ message: 'Method not allowed.' }) - return - } - - const { id } = req.query - try { - const { status, data, } = await handler(id) - res.statusCode = status - if (data) { - res.json(data) - return - } - res.end() - } catch (err) { - console.error(err) - const { status, data, } = err - res.statusCode = status || 500 - if (data && status !== 204) { - res.json(data) - return - } - res.end() - } -} diff --git a/src/services/Storage.ts b/src/services/Storage.ts index 846f79a..7640710 100644 --- a/src/services/Storage.ts +++ b/src/services/Storage.ts @@ -1,7 +1,7 @@ import { addTime, TimeDivision } from '../utilities/Date' import * as Serialization from '../utilities/Serialization' import NoteModel from '../models/Note' -import InferModel from '../utilities/Instance' +import * as ColumnTypes from '../utilities/ColumnTypes' import LocalStorage from './LocalStorage' type StorageParams = { @@ -9,7 +9,7 @@ type StorageParams = { url: string, } -type Note = InferModel +type Note = ColumnTypes.InferModel type LoadItems = >(params: StorageParams) => () => Promise diff --git a/src/services/Folder.ts b/src/services/entities/Folder.ts similarity index 59% rename from src/services/Folder.ts rename to src/services/entities/Folder.ts index 6dc6149..c52c34d 100644 --- a/src/services/Folder.ts +++ b/src/services/entities/Folder.ts @@ -1,8 +1,8 @@ -import FolderModel from '../models/Folder' -import Instance from '../utilities/Instance' -import * as Response from '../utilities/Response' +import FolderModel from '../../models/Folder' +import * as ColumnTypes from '../../utilities/ColumnTypes' +import * as Response from '../../utilities/Response' -type Folder = Instance +type Folder = ColumnTypes.InferModel export const getSingle = repository => async (id: string) => { const instance = await repository.findByPk(id) @@ -10,14 +10,22 @@ export const getSingle = repository => async (id: string) => { throw new Response.NotFound({ message: 'Not found.' }) } return new Response.Retrieved({ - data: instance, + data: instance.toJSON() as Folder, }) } -export const getMultiple = repository => async (query: Record) => { - const instances = await repository.findAll() - return new Response.Retrieved({ - data: instances, +export const getMultiple = repository => async (query?: Record) => { + const fetchMethod = async query => { + if (query) { + return repository.findWhere({ + attributes: query, + }) + } + return repository.findAll() + } + const instances = await fetchMethod(query) + return new Response.Retrieved({ + data: instances.map(i => i.toJSON()), }) } @@ -32,7 +40,7 @@ export const save = repository => (body: Partial) => async (id: string, if (created) { return new Response.Created({ - data: newInstance.toJSON() + data: newInstance.toJSON() as Folder }) } @@ -42,7 +50,7 @@ export const save = repository => (body: Partial) => async (id: string, newInstance[idColumnName] = id const updatedInstance = await newInstance.save() return new Response.Saved({ - data: updatedInstance.toJSON() + data: updatedInstance.toJSON() as Folder }) } diff --git a/src/services/Note.ts b/src/services/entities/Note.ts similarity index 86% rename from src/services/Note.ts rename to src/services/entities/Note.ts index 5ad4a3d..14af660 100644 --- a/src/services/Note.ts +++ b/src/services/entities/Note.ts @@ -1,8 +1,8 @@ -import Model from '../models/Note' -import InferType from '../utilities/Instance' -import * as Response from '../utilities/Response' +import Model from '../../models/Note' +import * as ColumnTypes from '../../utilities/ColumnTypes' +import * as Response from '../../utilities/Response' -type ModelInstance = InferType +type Note = ColumnTypes.InferModel export const getSingle = repository => async (id: string) => { const instanceDAO = await repository.findByPk(id) @@ -31,7 +31,7 @@ export const getMultiple = repository => async (query: Record) }) } -export const save = repository => (body: Partial) => async (id: string, idColumnName = 'id') => { +export const save = repository => (body: Partial) => async (id: string, idColumnName = 'id') => { const { content: contentRaw, ...etcBody } = body const content = contentRaw! ? JSON.stringify(contentRaw) : null const [dao, created] = await repository.findOrCreate({ diff --git a/src/services/storages/Storage.ts b/src/services/storages/Storage.ts index f3a5881..94f2bd9 100644 --- a/src/services/storages/Storage.ts +++ b/src/services/storages/Storage.ts @@ -1,17 +1,16 @@ -type Collection = { - items: T[], - total: number, - skip: number, - take: number, +export default interface Storage { + queryItems(): Promise + saveItem(item: T): Promise + deleteItem(item: T): Promise } -type Query = { - q: string, +export interface StorageMeta { + items: T[], + lastModifiedBy: string, + lastModifiedAt: Date, } -export default interface Storage { - loadSingle(id: string): T, - loadMultiple(query: Query): Collection, - saveSingle(data: T): (id: string) => boolean, - deleteSingle(id: string): boolean, -} +export type Serializer = (t: T) => string +export type Deserializer = (s: string) => T + +export class OutOfSyncError {} diff --git a/src/utilities/ColumnTypes.ts b/src/utilities/ColumnTypes.ts new file mode 100644 index 0000000..5fc4ad5 --- /dev/null +++ b/src/utilities/ColumnTypes.ts @@ -0,0 +1,60 @@ +import { + DataType, + INTEGER, + STRING, + TEXT, + DATE, + DATEONLY, + UUIDV4, +} from 'sequelize' + +type ModelAttribute = { + allowNull?: boolean, + primaryKey?: boolean, + type: DataType, +} + +export type Model = { + tableName?: string, + modelName?: string, + options?: { + timestamps?: boolean, + paranoid?: boolean, + createdAt?: string | boolean, + updatedAt?: string | boolean, + deletedAt?: string | boolean, + }, + attributes: Record, +} + +type IntegerType = typeof INTEGER | ReturnType +type NumberType = IntegerType + +type VarcharType = typeof STRING +type TextType = typeof TEXT | ReturnType +type StringType = VarcharType | TextType + +type DateTimeType = typeof DATE +type DateOnlyType = typeof DATEONLY +type DateType = DateTimeType | DateOnlyType + +type InferType = ( + V extends NumberType ? number : + V extends StringType ? string : + V extends DateType ? Date : + V extends typeof UUIDV4 ? string : + unknown +) + +export type InferModel = { + [K in keyof M['attributes']]-?: InferType +} + +export { + INTEGER, + STRING, + TEXT, + DATEONLY, + DATE, + UUIDV4, +} diff --git a/src/utilities/Instance.ts b/src/utilities/Instance.ts deleted file mode 100644 index f65767b..0000000 --- a/src/utilities/Instance.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as Sequelize from 'sequelize' - -type ModelAttribute = { - allowNull?: boolean, - primaryKey?: boolean, - type: Sequelize.DataType, -} - -type Model = { - tableName?: string, - modelName?: string, - options?: { - timestamps?: boolean, - paranoid?: boolean, - createdAt?: string | boolean, - updatedAt?: string | boolean, - deletedAt?: string | boolean, - }, - rawAttributes: Record, -} - -type InferType = ( - V extends typeof Sequelize.STRING ? string : - V extends typeof Sequelize.TEXT ? string : - V extends ReturnType ? string : - V extends typeof Sequelize.DATE ? Date : - V extends typeof Sequelize.DATEONLY ? Date : - V extends typeof Sequelize.UUIDV4 ? string : - unknown -) - -type InferProps = { - [K in keyof M['rawAttributes']]-?: InferType -} - -export default InferProps diff --git a/src/utilities/ORM.ts b/src/utilities/ORM.ts index 5e7eef5..01003bb 100644 --- a/src/utilities/ORM.ts +++ b/src/utilities/ORM.ts @@ -1,4 +1,5 @@ -import { Sequelize, Dialect, ModelAttributes, ModelOptions } from 'sequelize' +import { Sequelize, Dialect, } from 'sequelize' +import * as ColumnTypes from './ColumnTypes' export enum DatabaseKind { MSSQL = 'mssql', @@ -34,13 +35,17 @@ export default class ORM { host: url, }) return + // TODO add nosql dbs default: break } throw new Error(`Database kind "${kind as string}" not yet supported.`) } - getRepository(model: { modelName: string, rawAttributes: ModelAttributes, options: ModelOptions }) { - return this.instance.define(model.modelName, model.rawAttributes, model.options) + 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.') } } diff --git a/src/utilities/Response.ts b/src/utilities/Response.ts index fd48394..da1f96a 100644 --- a/src/utilities/Response.ts +++ b/src/utilities/Response.ts @@ -1,4 +1,4 @@ -interface Response> { +interface Response { status: number, data?: T, } @@ -7,7 +7,7 @@ interface ErrorResponse extends Response { message: string, } -type ResponseParams = Record> = { +type ResponseParams = { message?: string, data?: T, } @@ -20,7 +20,7 @@ export class NotFound implements ErrorResponse { } } -export class Created> implements Response { +export class Created implements Response { public readonly status = 201 public readonly data: T constructor(params: ResponseParams) { @@ -28,7 +28,7 @@ export class Created> implements Response { } } -export class Saved> implements Response { +export class Saved implements Response { public readonly status = 200 public readonly data: T constructor(params: ResponseParams) { @@ -36,7 +36,7 @@ export class Saved> implements Response { } } -export class Retrieved> implements Response { +export class Retrieved implements Response { public readonly status = 200 public readonly data: T constructor(params: ResponseParams) { diff --git a/yarn.lock b/yarn.lock index 3b9c04d..1554520 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3088,18 +3088,6 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" - integrity sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@^7.0.3, glob@^7.1.3, glob@^7.1.4: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" @@ -5168,13 +5156,6 @@ sequelize-pool@^2.3.0: resolved "https://registry.yarnpkg.com/sequelize-pool/-/sequelize-pool-2.3.0.tgz#64f1fe8744228172c474f530604b6133be64993d" integrity sha512-Ibz08vnXvkZ8LJTiUOxRcj1Ckdn7qafNZ2t59jYHMX1VIebTAOYefWdRYFt6z6+hy52WGthAHAoLc9hvk3onqA== -sequelize-typescript@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/sequelize-typescript/-/sequelize-typescript-1.1.0.tgz#d5c2945e7fbfe55a934917b27d84589858d79123" - integrity sha512-FAPEQPeAhIaFQNLAcf9Q2IWcqWhNcvn5OZZ7BzGB0CJMtImIsGg4E/EAb7huMmPaPwDArxJUWGqk1KurphTNRA== - dependencies: - glob "7.1.2" - sequelize@5.22.0: version "5.22.0" resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-5.22.0.tgz#72344a3aecd6767a8ceb02b8cba739e3ebeadeaf" diff --git a/zeichen.config.ts b/zeichen.config.ts new file mode 100644 index 0000000..d3b94a2 --- /dev/null +++ b/zeichen.config.ts @@ -0,0 +1,9 @@ +import LocalStorage from './src/plugins/local-storage' +import RemoteStorage from './src/plugins/remote-storage' + +export default { + plugins: [ + LocalStorage, + RemoteStorage({ baseUrl: 'https://localhost:3000/api', }), + ] +}