@@ -1,13 +1,18 @@ | |||||
import models from './src/models' | import models from './src/models' | ||||
import seeds from './src/seeds' | import seeds from './src/seeds' | ||||
// TODO support NoSQL | |||||
export const up = async queryInterface => { | 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) | await Promise.all(createTablePromises) | ||||
const seedTablePromise = models | const seedTablePromise = models | ||||
.filter(m => Boolean(seeds[m.modelName])) | .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) | return Promise.all(seedTablePromise) | ||||
} | } | ||||
@@ -6,7 +6,8 @@ | |||||
"dev": "next dev", | "dev": "next dev", | ||||
"build": "next build", | "build": "next build", | ||||
"start": "next start", | "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": { | "dependencies": { | ||||
"@fingerprintjs/fingerprintjs": "^3.0.3", | "@fingerprintjs/fingerprintjs": "^3.0.3", | ||||
@@ -18,9 +19,6 @@ | |||||
"react-feather": "^2.0.8", | "react-feather": "^2.0.8", | ||||
"react-mobiledoc-editor": "^0.10.0", | "react-mobiledoc-editor": "^0.10.0", | ||||
"sequelize": "5.22.0", | "sequelize": "5.22.0", | ||||
"sequelize-cli": "^6.2.0", | |||||
"sequelize-typescript": "^1.1.0", | |||||
"sqlite3": "^5.0.0", | |||||
"styled-components": "^5.2.0", | "styled-components": "^5.2.0", | ||||
"uuid": "^8.3.1" | "uuid": "^8.3.1" | ||||
}, | }, | ||||
@@ -29,7 +27,9 @@ | |||||
"@types/react": "^16.9.53", | "@types/react": "^16.9.53", | ||||
"@types/styled-components": "^5.1.4", | "@types/styled-components": "^5.1.4", | ||||
"@types/uuid": "^8.3.0", | "@types/uuid": "^8.3.0", | ||||
"typescript": "^4.0.3" | |||||
"typescript": "^4.0.3", | |||||
"sequelize-cli": "^6.2.0", | |||||
"sqlite3": "^5.0.0" | |||||
}, | }, | ||||
"optionalDependencies": { | "optionalDependencies": { | ||||
"sqlite3": "^5.0.0" | "sqlite3": "^5.0.0" | ||||
@@ -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', | modelName: 'Folder', | ||||
tableName: 'folders', | |||||
options: { | options: { | ||||
timestamps: true, | timestamps: true, | ||||
paranoid: true, | paranoid: true, | ||||
@@ -10,31 +10,33 @@ export default { | |||||
updatedAt: 'updatedAt', | updatedAt: 'updatedAt', | ||||
deletedAt: 'deletedAt', | deletedAt: 'deletedAt', | ||||
}, | }, | ||||
rawAttributes: { | |||||
attributes: { | |||||
id: { | id: { | ||||
allowNull: true, | allowNull: true, | ||||
primaryKey: true, | primaryKey: true, | ||||
type: UUIDV4, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | }, | ||||
name: { | name: { | ||||
allowNull: false, | allowNull: false, | ||||
type: STRING, | |||||
type: ColumnTypes.STRING, | |||||
}, | }, | ||||
parentId: { | parentId: { | ||||
allowNull: true, | allowNull: true, | ||||
type: UUIDV4, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | }, | ||||
createdAt: { | createdAt: { | ||||
allowNull: false, | allowNull: false, | ||||
type: DATE, | |||||
type: ColumnTypes.DATE, | |||||
}, | }, | ||||
updatedAt: { | updatedAt: { | ||||
allowNull: false, | allowNull: false, | ||||
type: DATE, | |||||
type: ColumnTypes.DATE, | |||||
}, | }, | ||||
deletedAt: { | deletedAt: { | ||||
allowNull: true, | allowNull: true, | ||||
type: DATE, | |||||
type: ColumnTypes.DATE, | |||||
}, | }, | ||||
} | } | ||||
} | } | ||||
export default Folder |
@@ -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', | modelName: 'Note', | ||||
tableName: 'notes', | |||||
options: { | options: { | ||||
timestamps: true, | timestamps: true, | ||||
paranoid: true, | paranoid: true, | ||||
@@ -10,35 +10,37 @@ export default { | |||||
updatedAt: 'updatedAt', | updatedAt: 'updatedAt', | ||||
deletedAt: 'deletedAt', | deletedAt: 'deletedAt', | ||||
}, | }, | ||||
rawAttributes: { | |||||
attributes: { | |||||
id: { | id: { | ||||
allowNull: true, | allowNull: true, | ||||
primaryKey: true, | primaryKey: true, | ||||
type: UUIDV4, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | }, | ||||
title: { | title: { | ||||
allowNull: false, | allowNull: false, | ||||
type: STRING, | |||||
type: ColumnTypes.STRING, | |||||
}, | }, | ||||
content: { | content: { | ||||
allowNull: true, | allowNull: true, | ||||
type: TEXT({ length: 'long', }), | |||||
type: ColumnTypes.TEXT({ length: 'long', }), | |||||
}, | }, | ||||
folderId: { | folderId: { | ||||
allowNull: true, | allowNull: true, | ||||
type: UUIDV4, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | }, | ||||
createdAt: { | createdAt: { | ||||
allowNull: false, | allowNull: false, | ||||
type: DATE, | |||||
type: ColumnTypes.DATE, | |||||
}, | }, | ||||
updatedAt: { | updatedAt: { | ||||
allowNull: false, | allowNull: false, | ||||
type: DATE, | |||||
type: ColumnTypes.DATE, | |||||
}, | }, | ||||
deletedAt: { | deletedAt: { | ||||
allowNull: true, | allowNull: true, | ||||
type: DATE, | |||||
type: ColumnTypes.DATE, | |||||
}, | }, | ||||
}, | }, | ||||
} | } | ||||
export default Note |
@@ -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: { | options: { | ||||
timestamps: false, | timestamps: false, | ||||
}, | }, | ||||
modelName: 'Operation', | |||||
tableName: 'operations', | |||||
rawAttributes: { | |||||
attributes: { | |||||
id: { | id: { | ||||
allowNull: true, | allowNull: true, | ||||
primaryKey: true, | primaryKey: true, | ||||
type: INTEGER, | |||||
type: ColumnTypes.INTEGER, | |||||
}, | }, | ||||
name: { | name: { | ||||
allowNull: false, | allowNull: false, | ||||
type: STRING, | |||||
type: ColumnTypes.STRING, | |||||
}, | }, | ||||
} | } | ||||
} | } | ||||
export default Operation |
@@ -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: { | options: { | ||||
timestamps: false, | timestamps: false, | ||||
}, | }, | ||||
modelName: 'Tag', | |||||
tableName: 'tags', | |||||
rawAttributes: { | |||||
attributes: { | |||||
id: { | id: { | ||||
allowNull: true, | allowNull: true, | ||||
primaryKey: true, | primaryKey: true, | ||||
type: UUIDV4, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | }, | ||||
name: { | name: { | ||||
allowNull: false, | allowNull: false, | ||||
type: STRING, | |||||
type: ColumnTypes.STRING, | |||||
}, | }, | ||||
} | } | ||||
} | } | ||||
export default Tag |
@@ -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: { | options: { | ||||
timestamps: false, | timestamps: false, | ||||
}, | }, | ||||
modelName: 'Transaction', | |||||
tableName: 'transactions', | |||||
rawAttributes: { | |||||
attributes: { | |||||
id: { | id: { | ||||
allowNull: true, | allowNull: true, | ||||
primaryKey: true, | primaryKey: true, | ||||
type: UUIDV4, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | }, | ||||
deviceId: { | deviceId: { | ||||
allowNull: false, | allowNull: false, | ||||
type: STRING, | |||||
type: ColumnTypes.STRING, | |||||
}, | }, | ||||
operation: { | operation: { | ||||
allowNull: false, | allowNull: false, | ||||
type: INTEGER, | |||||
type: ColumnTypes.INTEGER, | |||||
}, | }, | ||||
objectId: { | objectId: { | ||||
allowNull: false, | allowNull: false, | ||||
type: STRING, | |||||
type: ColumnTypes.STRING, | |||||
}, | }, | ||||
performedAt: { | performedAt: { | ||||
allowNull: false, | allowNull: false, | ||||
type: DATE, | |||||
type: ColumnTypes.DATE, | |||||
}, | }, | ||||
}, | }, | ||||
} | } | ||||
export default Transaction |
@@ -1,5 +1,36 @@ | |||||
import ORM, { DatabaseKind } from '../../utilities/ORM' | |||||
import * as Service from '../../services/entities/Folder' | |||||
import Model from '../../models/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() | |||||
} | |||||
} |
@@ -1,5 +1,43 @@ | |||||
import ORM, { DatabaseKind } from '../../../utilities/ORM' | |||||
import * as Service from '../../../services/entities/Folder' | |||||
import Model from '../../../models/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() | |||||
} | |||||
} |
@@ -1,5 +1,36 @@ | |||||
import ORM, { DatabaseKind } from '../../utilities/ORM' | |||||
import * as Service from '../../services/entities/Note' | |||||
import Model from '../../models/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() | |||||
} | |||||
} |
@@ -1,5 +1,43 @@ | |||||
import ORM, { DatabaseKind } from '../../../utilities/ORM' | |||||
import * as Service from '../../../services/entities/Note' | |||||
import Model from '../../../models/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() | |||||
} | |||||
} |
@@ -0,0 +1,23 @@ | |||||
export default class LocalStorage<T> { | |||||
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) | |||||
} | |||||
} |
@@ -0,0 +1,55 @@ | |||||
import Storage, { StorageMeta, OutOfSyncError } from '../../services/storages/Storage' | |||||
import Engine from './Engine' | |||||
export default class LocalStorage<T> implements Storage<T> { | |||||
private readonly engine: Engine<StorageMeta<T>> | |||||
constructor( | |||||
private readonly ownerId: string, | |||||
private readonly storageId: string, | |||||
private readonly getItemId = item => item['id'], | |||||
) { | |||||
this.engine = new Engine<StorageMeta<T>>(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<T> = { | |||||
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<T> = { | |||||
items: newItems, | |||||
lastModifiedBy: this.ownerId, | |||||
lastModifiedAt: new Date(), | |||||
} | |||||
this.engine.replaceCollection(this.storageId, newMeta) | |||||
return oldMeta.items.length !== newItems.length | |||||
} | |||||
} |
@@ -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') | |||||
} |
@@ -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<U, T> { | |||||
constructor( | |||||
private readonly baseUrl: string, | |||||
private readonly getItemId = item => item['id'], | |||||
private readonly serializers: Map<string, Serializer> = new Map([ | |||||
['*/*', JSON.stringify], | |||||
['application/json', JSON.stringify], | |||||
['text/json', JSON.stringify], | |||||
]), | |||||
private readonly deserializers: Map<string, Deserializer> = 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? | |||||
} |
@@ -0,0 +1,27 @@ | |||||
import Storage, { StorageMeta } from '../../services/storages/Storage' | |||||
import Engine from './Engine' | |||||
export default class RemoteStorage<T> implements Storage<T> { | |||||
private readonly engine: Engine<T, StorageMeta<T>> | |||||
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<T>; | |||||
return response.items; | |||||
} | |||||
async saveItem(newItem: T) { | |||||
return this.engine.setItem(this.collectionId, newItem) | |||||
} | |||||
} |
@@ -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') | |||||
} |
@@ -1,10 +1,13 @@ | |||||
import Operation from './services/Operation' | import Operation from './services/Operation' | ||||
export default { | 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, | |||||
})) | |||||
), | |||||
} | } |
@@ -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() | |||||
} | |||||
} |
@@ -1,7 +1,7 @@ | |||||
import { addTime, TimeDivision } from '../utilities/Date' | import { addTime, TimeDivision } from '../utilities/Date' | ||||
import * as Serialization from '../utilities/Serialization' | import * as Serialization from '../utilities/Serialization' | ||||
import NoteModel from '../models/Note' | import NoteModel from '../models/Note' | ||||
import InferModel from '../utilities/Instance' | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
import LocalStorage from './LocalStorage' | import LocalStorage from './LocalStorage' | ||||
type StorageParams = { | type StorageParams = { | ||||
@@ -9,7 +9,7 @@ type StorageParams = { | |||||
url: string, | url: string, | ||||
} | } | ||||
type Note = InferModel<typeof NoteModel> | |||||
type Note = ColumnTypes.InferModel<typeof NoteModel> | |||||
type LoadItems = <T extends Record<string, unknown>>(params: StorageParams) => () => Promise<T[]> | type LoadItems = <T extends Record<string, unknown>>(params: StorageParams) => () => Promise<T[]> | ||||
@@ -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<typeof FolderModel> | |||||
type Folder = ColumnTypes.InferModel<typeof FolderModel> | |||||
export const getSingle = repository => async (id: string) => { | export const getSingle = repository => async (id: string) => { | ||||
const instance = await repository.findByPk(id) | const instance = await repository.findByPk(id) | ||||
@@ -10,14 +10,22 @@ export const getSingle = repository => async (id: string) => { | |||||
throw new Response.NotFound({ message: 'Not found.' }) | throw new Response.NotFound({ message: 'Not found.' }) | ||||
} | } | ||||
return new Response.Retrieved({ | return new Response.Retrieved({ | ||||
data: instance, | |||||
data: instance.toJSON() as Folder, | |||||
}) | }) | ||||
} | } | ||||
export const getMultiple = repository => async (query: Record<string, unknown>) => { | |||||
const instances = await repository.findAll() | |||||
return new Response.Retrieved({ | |||||
data: instances, | |||||
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()), | |||||
}) | }) | ||||
} | } | ||||
@@ -32,7 +40,7 @@ export const save = repository => (body: Partial<Folder>) => async (id: string, | |||||
if (created) { | if (created) { | ||||
return new Response.Created({ | return new Response.Created({ | ||||
data: newInstance.toJSON() | |||||
data: newInstance.toJSON() as Folder | |||||
}) | }) | ||||
} | } | ||||
@@ -42,7 +50,7 @@ export const save = repository => (body: Partial<Folder>) => async (id: string, | |||||
newInstance[idColumnName] = id | newInstance[idColumnName] = id | ||||
const updatedInstance = await newInstance.save() | const updatedInstance = await newInstance.save() | ||||
return new Response.Saved({ | return new Response.Saved({ | ||||
data: updatedInstance.toJSON() | |||||
data: updatedInstance.toJSON() as Folder | |||||
}) | }) | ||||
} | } | ||||
@@ -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<typeof Model> | |||||
type Note = ColumnTypes.InferModel<typeof Model> | |||||
export const getSingle = repository => async (id: string) => { | export const getSingle = repository => async (id: string) => { | ||||
const instanceDAO = await repository.findByPk(id) | const instanceDAO = await repository.findByPk(id) | ||||
@@ -31,7 +31,7 @@ export const getMultiple = repository => async (query: Record<string, unknown>) | |||||
}) | }) | ||||
} | } | ||||
export const save = repository => (body: Partial<ModelInstance>) => async (id: string, idColumnName = 'id') => { | |||||
export const save = repository => (body: Partial<Note>) => async (id: string, idColumnName = 'id') => { | |||||
const { content: contentRaw, ...etcBody } = body | const { content: contentRaw, ...etcBody } = body | ||||
const content = contentRaw! ? JSON.stringify(contentRaw) : null | const content = contentRaw! ? JSON.stringify(contentRaw) : null | ||||
const [dao, created] = await repository.findOrCreate({ | const [dao, created] = await repository.findOrCreate({ |
@@ -1,17 +1,16 @@ | |||||
type Collection<T> = { | |||||
items: T[], | |||||
total: number, | |||||
skip: number, | |||||
take: number, | |||||
export default interface Storage<T> { | |||||
queryItems(): Promise<T[]> | |||||
saveItem(item: T): Promise<void> | |||||
deleteItem(item: T): Promise<boolean> | |||||
} | } | ||||
type Query = { | |||||
q: string, | |||||
export interface StorageMeta<T> { | |||||
items: T[], | |||||
lastModifiedBy: string, | |||||
lastModifiedAt: Date, | |||||
} | } | ||||
export default interface Storage<T> { | |||||
loadSingle(id: string): T, | |||||
loadMultiple(query: Query): Collection<T>, | |||||
saveSingle(data: T): (id: string) => boolean, | |||||
deleteSingle(id: string): boolean, | |||||
} | |||||
export type Serializer<T = unknown> = (t: T) => string | |||||
export type Deserializer<T = unknown> = (s: string) => T | |||||
export class OutOfSyncError {} |
@@ -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<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, | |||||
} |
@@ -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<string, ModelAttribute>, | |||||
} | |||||
type InferType<V extends Sequelize.DataType> = ( | |||||
V extends typeof Sequelize.STRING ? string : | |||||
V extends typeof Sequelize.TEXT ? string : | |||||
V extends ReturnType<typeof Sequelize.TEXT> ? string : | |||||
V extends typeof Sequelize.DATE ? Date : | |||||
V extends typeof Sequelize.DATEONLY ? Date : | |||||
V extends typeof Sequelize.UUIDV4 ? string : | |||||
unknown | |||||
) | |||||
type InferProps<M extends Model> = { | |||||
[K in keyof M['rawAttributes']]-?: InferType<M['rawAttributes'][K]['type']> | |||||
} | |||||
export default InferProps |
@@ -1,4 +1,5 @@ | |||||
import { Sequelize, Dialect, ModelAttributes, ModelOptions } from 'sequelize' | |||||
import { Sequelize, Dialect, } from 'sequelize' | |||||
import * as ColumnTypes from './ColumnTypes' | |||||
export enum DatabaseKind { | export enum DatabaseKind { | ||||
MSSQL = 'mssql', | MSSQL = 'mssql', | ||||
@@ -34,13 +35,17 @@ export default class ORM { | |||||
host: url, | host: url, | ||||
}) | }) | ||||
return | return | ||||
// TODO add nosql dbs | |||||
default: | default: | ||||
break | break | ||||
} | } | ||||
throw new Error(`Database kind "${kind as string}" not yet supported.`) | 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.') | |||||
} | } | ||||
} | } |
@@ -1,4 +1,4 @@ | |||||
interface Response<T = Record<string, unknown>> { | |||||
interface Response<T extends {} = {}> { | |||||
status: number, | status: number, | ||||
data?: T, | data?: T, | ||||
} | } | ||||
@@ -7,7 +7,7 @@ interface ErrorResponse extends Response { | |||||
message: string, | message: string, | ||||
} | } | ||||
type ResponseParams<T extends Record<string, unknown> = Record<string, unknown>> = { | |||||
type ResponseParams<T extends {} = {}> = { | |||||
message?: string, | message?: string, | ||||
data?: T, | data?: T, | ||||
} | } | ||||
@@ -20,7 +20,7 @@ export class NotFound implements ErrorResponse { | |||||
} | } | ||||
} | } | ||||
export class Created<T extends Record<string, unknown>> implements Response { | |||||
export class Created<T extends {}> implements Response { | |||||
public readonly status = 201 | public readonly status = 201 | ||||
public readonly data: T | public readonly data: T | ||||
constructor(params: ResponseParams<T>) { | constructor(params: ResponseParams<T>) { | ||||
@@ -28,7 +28,7 @@ export class Created<T extends Record<string, unknown>> implements Response { | |||||
} | } | ||||
} | } | ||||
export class Saved<T extends Record<string, unknown>> implements Response { | |||||
export class Saved<T extends {}> implements Response { | |||||
public readonly status = 200 | public readonly status = 200 | ||||
public readonly data: T | public readonly data: T | ||||
constructor(params: ResponseParams<T>) { | constructor(params: ResponseParams<T>) { | ||||
@@ -36,7 +36,7 @@ export class Saved<T extends Record<string, unknown>> implements Response { | |||||
} | } | ||||
} | } | ||||
export class Retrieved<T extends Record<string, unknown>> implements Response { | |||||
export class Retrieved<T extends {}> implements Response { | |||||
public readonly status = 200 | public readonly status = 200 | ||||
public readonly data: T | public readonly data: T | ||||
constructor(params: ResponseParams<T>) { | constructor(params: ResponseParams<T>) { | ||||
@@ -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" | resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" | ||||
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== | 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: | glob@^7.0.3, glob@^7.1.3, glob@^7.1.4: | ||||
version "7.1.6" | version "7.1.6" | ||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" | 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" | resolved "https://registry.yarnpkg.com/sequelize-pool/-/sequelize-pool-2.3.0.tgz#64f1fe8744228172c474f530604b6133be64993d" | ||||
integrity sha512-Ibz08vnXvkZ8LJTiUOxRcj1Ckdn7qafNZ2t59jYHMX1VIebTAOYefWdRYFt6z6+hy52WGthAHAoLc9hvk3onqA== | 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: | sequelize@5.22.0: | ||||
version "5.22.0" | version "5.22.0" | ||||
resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-5.22.0.tgz#72344a3aecd6767a8ceb02b8cba739e3ebeadeaf" | resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-5.22.0.tgz#72344a3aecd6767a8ceb02b8cba739e3ebeadeaf" | ||||
@@ -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', }), | |||||
] | |||||
} |