Skip to content

Commit

Permalink
feat: entity api
Browse files Browse the repository at this point in the history
* start global query

* fix: switch to one module syntax

so api can be required in node as is

* fix: add app specific api

* global query cont

* no entity api

* store contructor cleanup

* entity api uses adapter again

* create page setup

* fix: export pouch

* ditch store for now

* fix: searching

* fix: lol standard
  • Loading branch information
kdoran authored Apr 25, 2020
1 parent 621f90e commit ba48c87
Show file tree
Hide file tree
Showing 52 changed files with 1,490 additions and 581 deletions.
43 changes: 43 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
EntityApi
- implements common methods
- has a single data access adapter
- has a schema that rejects on create/update
- extended for entity-specific domain logic (custom functions)
- can be used on default without modification

AdapterApi
- this is an interface extended that has the same methods as EntityApi
- it defaults to the entity's schema but can have different schema (location)

Why only one adapter in EntityApi? What about "smart" adapters that need two+ different data sources?

(strange that js-data had a "defaultAdapter" but no other way to change adapters externally...)

I think for these (pretty common) use cases, instead of trying to have the default
EntityApi + pouch Adapter Api, you'd create a custom adapter that can do stuff.

e.g. orders needs a single 'fetch' endpoint, so

```js
OrdersDataAdapter extends PouchDBAdapter {
constructor () {
super()
this.fetchAdapter = new FetchAdapter()
}
list () {
return this.fetchAdapter()
}
}
```

(e.g. fetch + pouch, or localPouch + remotePouch)

to point out:
- adapter expected to return `id`
- adapter can use json schema if it wants
- pouch adapter definitely uses json schema


# todos:
- entity api has schema but not sure how to use it yet
- common interface for Entity & Adapter
62 changes: 62 additions & 0 deletions api/entity-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const {SchemaInterface} = require('./schema-interface')

class EntityApi extends SchemaInterface {
constructor ({
schema, adapter, user, relations = {}, description = ''
}) {
if (typeof user !== 'object' || !user.name) {
throw new Error('EntityApi usage: user object with name is requred')
}
if (!adapter) {
throw new Error('EntityApi usage: adapter is required')
}

super(schema)

this.adapter = adapter
this.name = schema.name
this.relations = relations
this.description = description
// done in schema interface
// this.schema = schema
this.user = user
}

async list (...params) {
return this.adapter.list(...params)
}

async get (...params) {
return this.adapter.get(...params)
}

async create (...params) {
return this.adapter.create(...params)
}

async update (...params) {
return this.adapter.update(...params)
}

async createMany (...params) {
return this.adapter.createMany(...params)
}

async updateMany (...params) {
return this.adapter.updateMany(...params)
}

async remove (...params) {
return this.adapter.remove(...params)
}

async find (...params) {
return this.adapter.find(...params)
}

async findOne (...params) {
return this.adapter.findOne(...params)
}
}

module.exports = {EntityApi}
11 changes: 10 additions & 1 deletion api/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
const {RemoteCouchApi} = require('./remote-couch-api')
const {PouchAdapter, IndexedDBPouchAdapter} = require('./pouch-adapter')
const {EntityApi} = require('./entity-api')
const {PouchDB} = require('./pouchdb')

module.exports = {RemoteCouchApi}
module.exports = {
EntityApi,
PouchDB,
RemoteCouchApi,
PouchAdapter,
IndexedDBPouchAdapter
}
189 changes: 189 additions & 0 deletions api/pouch-adapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
const keyBy = require('lodash/keyBy')
const cloneDeep = require('lodash/cloneDeep')
const get = require('lodash/get')
const {PouchDB} = require('./pouchdb')
const {SchemaInterface} = require('./schema-interface')
const {addSchemaDefaults} = require('./pouch-schema-utils')

class PouchAdapter extends SchemaInterface {
constructor ({
schema, pouchDB, pouchDBName, pouchOptions = {}, relations = {}
}) {
if (typeof schema !== 'object' || !schema.properties || !schema.name) {
throw new Error('SchemaInterface usage: schema object with {properties: {}, name} is required')
}

if (!pouchDB && !pouchDBName) {
throw new Error('PouchAdapter usage: pouchDB instance or pouchDB name are required')
}

const withDefaults = addSchemaDefaults(schema)
super(withDefaults)

this.type = schema.name
this.pouchDB = pouchDB || new PouchDB(pouchDBName, pouchOptions)
}

toEntity (doc) {
const entity = cloneDeep(doc)
if (doc._id) {
entity.id = doc._id
delete entity._id
}
delete entity._rev
return entity
}

beforeCreate (entity) {
const doc = Object.assign(
this.getSchemaDefaults(),
cloneDeep(entity)
)
if (doc.hasOwnProperty('_id') && !doc._id) {
delete doc._id
}

if (entity.id) {
doc._id = entity.id
delete doc.id
}

this.throwIfInvalid(doc)
return doc
}

beforeUpdate (entity, _rev) {
if (!_rev) {
throw new Error('usage: beforeUpdate requires _rev')
}
const doc = cloneDeep(entity)
doc._rev = _rev
doc._id = entity.id
delete doc.id

this.throwIfInvalid(doc)
return doc
}

async list () {
const {docs} = await this.pouchDB.find({
selector: {type: {'$eq': this.type}},
limit: Number.MAX_SAFE_INTEGER
})

return docs.map(this.toEntity)
}

async get (id) {
const doc = await this.pouchDB.get(id)
return this.toEntity(doc)
}

async create (inputRow) {
const doc = this.beforeCreate(inputRow)

// confusing warning: pouch/couch return `id` on creates (and sometimes other functions)
// but `_id` other times.
// toEntity is going to expect an `_id` because normally docs have it.
const response = await this.pouchDB.post(doc)
const _id = doc._id || response.id
return this.toEntity(Object.assign({_id}, doc))
}

async update (id, updatedRow) {
if (typeof id === 'object') {
updatedRow = id
id = updatedRow.id
} else if (updatedRow.id && updatedRow.id !== id) {
throw new Error(`update() received id that does not match id on update`)
} else {
updatedRow.id = id
}

const {_rev} = await this.pouchDB.get(id)
const doc = this.beforeUpdate(updatedRow, _rev)

await this.pouchDB.put(doc)
return updatedRow
}

// this can also returned a mixed bag of [{createdEntity, {error!}}]
// because there are no "all or nothing" bulk insertions in couch,
// some succeed, some might not.
async createMany (inputRows) {
const docs = inputRows.map(row => this.beforeCreate(row))

const response = await this.pouchDB.bulkDocs(docs)
// response is not the full doc,
// but we need to get the {id} out of it in case pouch/couch
// was expected to auto-create it.
return inputRows.map((row, index) => {
if (response[index].error || !response[index].ok) {
return response[index]
}

const _id = response[index].id

return this.toEntity(Object.assign({_id}, docs[index]))
})
}

// TODO: this could be done in batches of like 500ish
// for 10k+ updates to couch that will network fail
async updateMany (rows) {
const revsResponse = await this.pouchDB.find({
selector: {_id: {'$in': rows.map(row => row.id)}},
limit: Number.MAX_SAFE_INTEGER,
fields: ['_id', '_rev']
})
const revsById = keyBy(revsResponse.docs, '_id')
const docs = rows
.map(row => this.beforeUpdate(row, get(revsById[row.id], '_rev')))

// return the original row sent if there was no error
const response = await this.pouchDB.bulkDocs(docs)
return rows.map((row, index) => {
if (response[index].error || !response[index].ok) {
return response[index]
}
return this.toEntity(rows[index])
})
}

async remove (id) {
if (typeof id !== 'string') throw new Error('remove expects id')
const doc = await this.pouchDB.get(id)
// doing this with pouch.remove(id) threw a 404
return this.pouchDB.put({...doc, _deleted: true})
}

async find (selector, limit = Number.MAX_SAFE_INTEGER) {
const {docs} = await this.pouchDB.find({
selector,
limit
})

return docs.map(doc => this.toEntity(doc))
}

// returns undefined if doesn't exist
async findOne (selector) {
const [doc] = await this.find(selector)
return doc
? this.toEntity(doc)
: undefined
}

destroyDatabase () {
return this.pouchDB.destroy()
}
}

class IndexedDBPouchAdapter extends PouchAdapter {
constructor (options) {
const pouchDBOptions = {pouchDBOptions: {adapter: 'idb'}}
super(Object.assign({}, options, pouchDBOptions))
}
}

module.exports = {PouchAdapter, IndexedDBPouchAdapter}
36 changes: 36 additions & 0 deletions api/pouch-schema-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const baseProperties = {
type: {type: 'string'},
// this is here but not required for the
// option of having pouch/couch create the _id.
_id: {type: 'string'},
createdAt: {
type: 'string',
format: 'date-time',
default: () => new Date().toJSON()
},
createdBy: {
type: 'string'
},
updatedAt: {
type: 'string',
format: 'date-time',
default: () => new Date().toJSON()
},
updatedBy: {
type: 'string'
}
}

const baseRequired = ['type', 'createdAt', 'createdBy']

function addSchemaDefaults (schema) {
const properties = Object.assign({}, baseProperties, schema.properties)
properties.type.default = schema.name

const required = baseRequired.concat(schema.required || [])
return Object.assign({}, schema, {properties, required})
}

module.exports = {
addSchemaDefaults
}
Loading

0 comments on commit ba48c87

Please sign in to comment.