mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Adding psf-memo-db
This commit is contained in:
@@ -3,5 +3,10 @@ build/
|
||||
docs/
|
||||
tmp/
|
||||
target/
|
||||
leveldb/
|
||||
logs/
|
||||
.env
|
||||
coverage/
|
||||
|
||||
|
||||
.gitsigners
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
PORT=5021
|
||||
SVC_ENV=development
|
||||
BACKUP_QTY=3
|
||||
EXIT_ON_MISSING_BACKUP=false
|
||||
@@ -0,0 +1,68 @@
|
||||
# psf-memo-db
|
||||
|
||||
LevelDB REST API for the Memo protocol indexer. Architecture mirrors [psf-slp-db](https://github.com/Permissionless-Software-Foundation/psf-slp-db).
|
||||
|
||||
Database-focused architecture notes live in the indexer repo: [psf-memo-indexer/dev-docs/psf-memo-db.md](../psf-memo-indexer/dev-docs/psf-memo-db.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- node ^20
|
||||
- npm ^10
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd psf-memo-db
|
||||
npm install
|
||||
cp .env-example .env # optional
|
||||
npm start
|
||||
```
|
||||
|
||||
Default port: **5021**
|
||||
|
||||
API documentation is served at the root URL (`http://localhost:5021/`). Regenerate with `npm run docs`.
|
||||
|
||||
## API
|
||||
|
||||
All indexer data is exposed under `/level/*` with CRUD routes per entity (`post`, `like`, `name`, `profile`, `status`, etc.) plus:
|
||||
|
||||
- `GET /profile/recent` — paginated list of profiles, sorted by block height (newest first)
|
||||
- `GET /posts/recent` — paginated list of posts, sorted by block height (newest first)
|
||||
- `POST /level/backup` — zip database snapshot
|
||||
- `POST /level/restore` — restore from snapshot (exits process)
|
||||
- `GET /health` — health check
|
||||
|
||||
### List recent profiles
|
||||
|
||||
```bash
|
||||
# First page (default limit 100)
|
||||
curl -sS "http://localhost:5021/profile/recent"
|
||||
|
||||
# Page size and offset
|
||||
curl -sS "http://localhost:5021/profile/recent?limit=50&offset=50"
|
||||
```
|
||||
|
||||
Query parameters: `limit` (default `100`, max `100`), `offset` (default `0`). Block height is read from the stored profile document (`blockHeight` field, set at indexing time).
|
||||
|
||||
### List recent posts
|
||||
|
||||
```bash
|
||||
curl -sS "http://localhost:5021/posts/recent"
|
||||
curl -sS "http://localhost:5021/posts/recent?limit=50&offset=50"
|
||||
```
|
||||
|
||||
Same query parameters as `/profile/recent`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## Production (Docker)
|
||||
|
||||
The production Docker setup is in [psf-memo-indexer/production/docker](../psf-memo-indexer/production/docker/memo-db/) (compose builds `memo-db` from this repo).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Koa REST API for Memo indexer LevelDB storage.
|
||||
Architecture mirrors psf-slp-db bin/server.js.
|
||||
*/
|
||||
|
||||
import Koa from 'koa'
|
||||
import bodyParser from 'koa-bodyparser'
|
||||
import convert from 'koa-convert'
|
||||
import logger from 'koa-logger'
|
||||
import mount from 'koa-mount'
|
||||
import serve from 'koa-static'
|
||||
import cors from 'kcors'
|
||||
import 'dotenv/config'
|
||||
|
||||
import config from '../config/index.js'
|
||||
import errorMiddleware from '../src/controllers/rest-api/middleware/error.js'
|
||||
import wlogger from '../src/adapters/wlogger.js'
|
||||
import Controllers from '../src/controllers/index.js'
|
||||
|
||||
class Server {
|
||||
constructor () {
|
||||
this.controllers = new Controllers()
|
||||
this.config = config
|
||||
this.process = process
|
||||
}
|
||||
|
||||
async startServer () {
|
||||
try {
|
||||
const app = new Koa()
|
||||
app.keys = [this.config.session]
|
||||
|
||||
console.log(`Starting environment: ${this.config.env}`)
|
||||
|
||||
app.use(convert(logger()))
|
||||
app.use(bodyParser({
|
||||
jsonLimit: '100mb',
|
||||
formLimit: '100mb',
|
||||
textLimit: '100mb'
|
||||
}))
|
||||
app.use(errorMiddleware())
|
||||
app.use(cors({ origin: '*' }))
|
||||
|
||||
app.use(mount('/', serve(`${process.cwd()}/docs`)))
|
||||
|
||||
await this.controllers.initAdapters()
|
||||
await this.controllers.initUseCases()
|
||||
await this.controllers.attachRESTControllers(app)
|
||||
|
||||
app.controllers = this.controllers
|
||||
|
||||
console.log(`Running server in environment: ${this.config.env}`)
|
||||
wlogger.info(`Running server in environment: ${this.config.env}`)
|
||||
|
||||
this.server = await app.listen(this.config.port)
|
||||
console.log(`Server started on ${this.config.port}`)
|
||||
|
||||
if (this.config.env !== 'test') {
|
||||
await this.controllers.attachControllers(app)
|
||||
}
|
||||
|
||||
return app
|
||||
} catch (err) {
|
||||
console.error('Could not start server. Error: ', err)
|
||||
console.log('Exiting after 5 seconds.')
|
||||
await this.sleep(5000)
|
||||
this.process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
sleep (ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
export default Server
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
import 'dotenv/config'
|
||||
|
||||
import * as url from 'url'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
const pkgInfo = JSON.parse(readFileSync(`${__dirname}/../../package.json`))
|
||||
|
||||
export default {
|
||||
port: process.env.PORT ? parseInt(process.env.PORT) : 5021,
|
||||
noMongo: true,
|
||||
useIpfs: false,
|
||||
version: pkgInfo.version,
|
||||
backupQty: process.env.BACKUP_QTY ? parseInt(process.env.BACKUP_QTY) : 3,
|
||||
exitOnMissingBackup: process.env.EXIT_ON_MISSING_BACKUP === 'true'
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
session: 'secret-memo-db-dev',
|
||||
env: 'development'
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
session: process.env.SESSION_SECRET || 'secret-memo-db-prod',
|
||||
env: 'prod'
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
session: 'secret-memo-db-test',
|
||||
env: 'test'
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import common from './env/common.js'
|
||||
import development from './env/development.js'
|
||||
import production from './env/production.js'
|
||||
import test from './env/test.js'
|
||||
|
||||
const env = process.env.SVC_ENV || 'development'
|
||||
console.log(`Loading config for this environment: ${env}`)
|
||||
|
||||
let config = development
|
||||
if (env === 'test') {
|
||||
config = test
|
||||
} else if (env === 'prod') {
|
||||
config = production
|
||||
}
|
||||
|
||||
export default Object.assign({}, common, config)
|
||||
@@ -0,0 +1,9 @@
|
||||
import Server from './bin/server.js'
|
||||
|
||||
const server = new Server()
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.log('Unhandled rejection:', reason)
|
||||
})
|
||||
|
||||
server.startServer()
|
||||
Generated
+9463
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "psf-memo-db",
|
||||
"version": "1.0.0",
|
||||
"description": "LevelDB REST API for Memo protocol indexing.",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prestart": "npm run docs",
|
||||
"start": "node index.js",
|
||||
"test": "export SVC_ENV=test && c8 --reporter=text mocha --exit --timeout 15000 --recursive test/unit/",
|
||||
"lint": "standard --env mocha --fix",
|
||||
"docs": "./node_modules/.bin/apidoc -i src/ -o docs"
|
||||
},
|
||||
"author": "Chris Troutner",
|
||||
"license": "MIT",
|
||||
"apidoc": {
|
||||
"title": "psf-memo-db",
|
||||
"url": "localhost:5021"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "17.2.3",
|
||||
"kcors": "2.2.2",
|
||||
"koa": "2.13.1",
|
||||
"koa-bodyparser": "4.3.0",
|
||||
"koa-convert": "2.0.0",
|
||||
"koa-logger": "3.2.1",
|
||||
"koa-mount": "4.0.0",
|
||||
"koa-router": "10.0.0",
|
||||
"koa-static": "5.0.0",
|
||||
"level": "7.0.1",
|
||||
"shelljs": "0.10.0",
|
||||
"winston": "3.3.3",
|
||||
"winston-daily-rotate-file": "4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"apidoc": "0.51.1",
|
||||
"c8": "10.1.3",
|
||||
"chai": "4.3.0",
|
||||
"mocha": "10.0.0",
|
||||
"sinon": "9.2.4",
|
||||
"standard": "17.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Backup and restore for Memo indexer LevelDB.
|
||||
*/
|
||||
|
||||
import shell from 'shelljs'
|
||||
import fs from 'fs'
|
||||
import config from '../../config/index.js'
|
||||
import { DB_NAMES, dbDir } from './level-db.js'
|
||||
|
||||
class DbBackup {
|
||||
constructor (levelDbs = {}) {
|
||||
for (const name of DB_NAMES) {
|
||||
this[`${name}Db`] = levelDbs[`${name}Db`]
|
||||
}
|
||||
this.shell = shell
|
||||
this.config = config
|
||||
this.zipDb = this.zipDb.bind(this)
|
||||
this.unzipDb = this.unzipDb.bind(this)
|
||||
}
|
||||
|
||||
async closeAll () {
|
||||
for (const name of DB_NAMES) {
|
||||
await this[`${name}Db`].close()
|
||||
}
|
||||
}
|
||||
|
||||
async openAll () {
|
||||
for (const name of DB_NAMES) {
|
||||
await this[`${name}Db`].open()
|
||||
}
|
||||
}
|
||||
|
||||
async zipDb (height, epoch) {
|
||||
try {
|
||||
await this.closeAll()
|
||||
|
||||
this.shell.cd(dbDir)
|
||||
this.shell.exec(`zip -r zips/memo-indexer-${height}.zip current`)
|
||||
|
||||
const backupQty = this.config.backupQty
|
||||
if (backupQty && epoch) {
|
||||
const oldHeight = height - (epoch * backupQty)
|
||||
const rmStr = `zips/memo-indexer-${oldHeight}.zip`
|
||||
if (this.shell.test('-f', rmStr)) {
|
||||
this.shell.rm(rmStr)
|
||||
}
|
||||
}
|
||||
|
||||
await this.openAll()
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Error in zipDb')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async unzipDb (height) {
|
||||
try {
|
||||
const zipFile = `memo-indexer-${height}.zip`
|
||||
const zipFilePath = `${dbDir}/zips/${zipFile}`
|
||||
|
||||
if (!fs.existsSync(zipFilePath)) {
|
||||
console.error(`Backup file not found: ${zipFile}`)
|
||||
if (this.config.exitOnMissingBackup) {
|
||||
process.exit(1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
await this.closeAll()
|
||||
|
||||
this.shell.rm('-rf', `${dbDir}/current/*`)
|
||||
this.shell.cd(`${dbDir}/zips`)
|
||||
this.shell.exec(`unzip -o ${zipFile}`)
|
||||
this.shell.cp('-r', `${dbDir}/zips/current/*`, `${dbDir}/current/`)
|
||||
|
||||
await this.openAll()
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Error in unzipDb: ', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DbBackup
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Top-level adapters for psf-memo-db.
|
||||
*/
|
||||
|
||||
import LevelDb from './level-db.js'
|
||||
import DbBackup from './db-backup.js'
|
||||
import ProfileQuery from './profile-query.js'
|
||||
import PostQuery from './post-query.js'
|
||||
|
||||
class Adapters {
|
||||
constructor () {
|
||||
this.levelDb = new LevelDb()
|
||||
this.openDatabases = this.openDatabases.bind(this)
|
||||
this.start = this.start.bind(this)
|
||||
}
|
||||
|
||||
openDatabases () {
|
||||
this.levelDb.ensureDirectories()
|
||||
const level = this.levelDb.openDbs()
|
||||
this.level = level
|
||||
this.dbBackup = new DbBackup(level)
|
||||
this.profileQuery = new ProfileQuery({
|
||||
profilesDb: level.profilesDb
|
||||
})
|
||||
this.postQuery = new PostQuery({
|
||||
postsDb: level.postsDb,
|
||||
postParentsDb: level.postParentsDb,
|
||||
postChildrenDb: level.postChildrenDb
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async start () {
|
||||
this.openDatabases()
|
||||
console.log('Adapter libraries initialized.')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default Adapters
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Adapter library for Memo indexer LevelDB instances.
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import shell from 'shelljs'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
const dbDir = `${__dirname}/../../leveldb`
|
||||
|
||||
const DB_NAMES = [
|
||||
'status',
|
||||
'posts',
|
||||
'postParents',
|
||||
'postChildren',
|
||||
'likes',
|
||||
'names',
|
||||
'profiles',
|
||||
'profilePics',
|
||||
'follows',
|
||||
'rooms',
|
||||
'processErrors',
|
||||
'ptxs'
|
||||
]
|
||||
|
||||
class LevelDb {
|
||||
constructor () {
|
||||
this.level = level
|
||||
this.shell = shell
|
||||
this.openDbs = this.openDbs.bind(this)
|
||||
this.closeDbs = this.closeDbs.bind(this)
|
||||
this.ensureDirectories = this.ensureDirectories.bind(this)
|
||||
this.getDbList = this.getDbList.bind(this)
|
||||
}
|
||||
|
||||
openDbs () {
|
||||
console.log('Opening LevelDB databases...')
|
||||
const dbs = {}
|
||||
|
||||
for (const name of DB_NAMES) {
|
||||
const prop = `${name}Db`
|
||||
dbs[prop] = this.level(`${__dirname}/../../leveldb/current/${name}`, {
|
||||
valueEncoding: 'json',
|
||||
cacheSize: name === 'posts' ? 512 * 1024 * 1024 : 64 * 1024 * 1024
|
||||
})
|
||||
this[prop] = dbs[prop]
|
||||
}
|
||||
|
||||
return dbs
|
||||
}
|
||||
|
||||
getDbList () {
|
||||
return DB_NAMES.map((name) => this[`${name}Db`])
|
||||
}
|
||||
|
||||
async closeDbs () {
|
||||
for (const name of DB_NAMES) {
|
||||
await this[`${name}Db`].close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async ensureDirectories () {
|
||||
this.shell.mkdir('-p', `${dbDir}/current`)
|
||||
this.shell.mkdir('-p', `${dbDir}/zips`)
|
||||
this.shell.mkdir('-p', `${dbDir}/backup`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export { DB_NAMES, dbDir }
|
||||
export default LevelDb
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Adapter for scanning posts with stored block height.
|
||||
Excludes reply posts (txids present in postParentsDb).
|
||||
*/
|
||||
|
||||
class PostQuery {
|
||||
constructor (localConfig = {}) {
|
||||
const { postsDb, postParentsDb, postChildrenDb } = localConfig
|
||||
if (!postsDb) {
|
||||
throw new Error('postsDb required when instantiating PostQuery adapter.')
|
||||
}
|
||||
if (!postParentsDb) {
|
||||
throw new Error('postParentsDb required when instantiating PostQuery adapter.')
|
||||
}
|
||||
if (!postChildrenDb) {
|
||||
throw new Error('postChildrenDb required when instantiating PostQuery adapter.')
|
||||
}
|
||||
this.postsDb = postsDb
|
||||
this.postParentsDb = postParentsDb
|
||||
this.postChildrenDb = postChildrenDb
|
||||
this.scanPostsWithBlockHeight = this.scanPostsWithBlockHeight.bind(this)
|
||||
this.scanPostsByAddr = this.scanPostsByAddr.bind(this)
|
||||
this.loadReplyTxids = this.loadReplyTxids.bind(this)
|
||||
this.buildReplyCountMap = this.buildReplyCountMap.bind(this)
|
||||
}
|
||||
|
||||
async loadReplyTxids () {
|
||||
const replyTxids = new Set()
|
||||
|
||||
for await (const [childTxid] of this.postParentsDb.iterator()) {
|
||||
replyTxids.add(childTxid)
|
||||
}
|
||||
|
||||
return replyTxids
|
||||
}
|
||||
|
||||
async buildReplyCountMap () {
|
||||
const counts = new Map()
|
||||
|
||||
for await (const [, child] of this.postChildrenDb.iterator()) {
|
||||
const parentTxid = child.parentTxid
|
||||
if (!parentTxid) continue
|
||||
counts.set(parentTxid, (counts.get(parentTxid) || 0) + 1)
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
async scanPostsWithBlockHeight () {
|
||||
const [replyTxids, replyCounts] = await Promise.all([
|
||||
this.loadReplyTxids(),
|
||||
this.buildReplyCountMap()
|
||||
])
|
||||
const posts = []
|
||||
|
||||
for await (const [txid, post] of this.postsDb.iterator()) {
|
||||
if (replyTxids.has(txid)) continue
|
||||
posts.push({
|
||||
txid,
|
||||
addr: post.addr,
|
||||
text: post.text,
|
||||
seen: post.seen,
|
||||
blockHeight: post.blockHeight ?? 0,
|
||||
replyCount: replyCounts.get(txid) ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
async scanPostsByAddr (addr) {
|
||||
const [replyTxids, replyCounts] = await Promise.all([
|
||||
this.loadReplyTxids(),
|
||||
this.buildReplyCountMap()
|
||||
])
|
||||
const posts = []
|
||||
|
||||
for await (const [txid, post] of this.postsDb.iterator()) {
|
||||
if (post.addr !== addr) continue
|
||||
if (replyTxids.has(txid)) continue
|
||||
posts.push({
|
||||
txid,
|
||||
addr: post.addr,
|
||||
text: post.text,
|
||||
seen: post.seen,
|
||||
blockHeight: post.blockHeight ?? 0,
|
||||
replyCount: replyCounts.get(txid) ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
async buildReplyCountMap () {
|
||||
const counts = new Map()
|
||||
let total = 0
|
||||
|
||||
for await (const [childTxid, child] of this.postChildrenDb.iterator()) {
|
||||
total++
|
||||
|
||||
console.log('Indexed reply:', {
|
||||
childTxid,
|
||||
child,
|
||||
parentTxid: child?.parentTxid
|
||||
})
|
||||
|
||||
const parentTxid = child?.parentTxid
|
||||
if (!parentTxid) continue
|
||||
|
||||
counts.set(parentTxid, (counts.get(parentTxid) || 0) + 1)
|
||||
}
|
||||
|
||||
console.log(`Total postChildrenDb records: ${total}`)
|
||||
|
||||
return counts
|
||||
}
|
||||
}
|
||||
|
||||
export default PostQuery
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Adapter for scanning profiles with stored block height.
|
||||
*/
|
||||
|
||||
class ProfileQuery {
|
||||
constructor (localConfig = {}) {
|
||||
const { profilesDb } = localConfig
|
||||
if (!profilesDb) {
|
||||
throw new Error('profilesDb required when instantiating ProfileQuery adapter.')
|
||||
}
|
||||
this.profilesDb = profilesDb
|
||||
this.scanProfilesWithBlockHeight = this.scanProfilesWithBlockHeight.bind(this)
|
||||
}
|
||||
|
||||
async scanProfilesWithBlockHeight () {
|
||||
const profiles = []
|
||||
|
||||
for await (const [addr, profile] of this.profilesDb.iterator()) {
|
||||
profiles.push({
|
||||
addr,
|
||||
text: profile.text,
|
||||
txid: profile.txid,
|
||||
seen: profile.seen,
|
||||
blockHeight: profile.blockHeight ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfileQuery
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict'
|
||||
|
||||
import winston from 'winston'
|
||||
import config from '../../config/index.js'
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.simple()
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
logger.info(`Wlogger initialized for env: ${config.env}`)
|
||||
|
||||
export default logger
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Top-level controllers for psf-memo-db.
|
||||
*/
|
||||
|
||||
import Adapters from '../adapters/index.js'
|
||||
import UseCases from '../use-cases/index.js'
|
||||
import RESTControllers from './rest-api/index.js'
|
||||
|
||||
class Controllers {
|
||||
constructor () {
|
||||
this.adapters = new Adapters()
|
||||
this.useCases = new UseCases({ adapters: this.adapters })
|
||||
this.initAdapters = this.initAdapters.bind(this)
|
||||
this.initUseCases = this.initUseCases.bind(this)
|
||||
this.attachRESTControllers = this.attachRESTControllers.bind(this)
|
||||
this.attachControllers = this.attachControllers.bind(this)
|
||||
}
|
||||
|
||||
async initAdapters () {
|
||||
await this.adapters.start()
|
||||
}
|
||||
|
||||
async initUseCases () {
|
||||
await this.useCases.start()
|
||||
}
|
||||
|
||||
attachRESTControllers (app) {
|
||||
const restControllers = new RESTControllers({
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
})
|
||||
restControllers.attachRESTControllers(app)
|
||||
}
|
||||
|
||||
async attachControllers () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default Controllers
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
REST API controller for /health routes.
|
||||
*/
|
||||
|
||||
class HealthRESTControllerLib {
|
||||
constructor () {
|
||||
this.getHealth = this.getHealth.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /health Health check
|
||||
* @apiPermission public
|
||||
* @apiName GetHealth
|
||||
* @apiGroup REST Health
|
||||
*
|
||||
* @apiDescription Returns a simple status payload for load balancers and compose health checks.
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/health
|
||||
*
|
||||
* @apiSuccess {String} status Always "ok" when the service is running
|
||||
*/
|
||||
async getHealth (ctx) {
|
||||
ctx.body = { status: 'ok' }
|
||||
}
|
||||
}
|
||||
|
||||
export default HealthRESTControllerLib
|
||||
@@ -0,0 +1,17 @@
|
||||
import Router from 'koa-router'
|
||||
import HealthRESTControllerLib from './controller.js'
|
||||
|
||||
class HealthRouter {
|
||||
constructor () {
|
||||
this.healthRESTController = new HealthRESTControllerLib()
|
||||
this.router = new Router({ prefix: '/health' })
|
||||
}
|
||||
|
||||
attach (app) {
|
||||
this.router.get('/', this.healthRESTController.getHealth)
|
||||
app.use(this.router.routes())
|
||||
app.use(this.router.allowedMethods())
|
||||
}
|
||||
}
|
||||
|
||||
export default HealthRouter
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
REST API controllers index.
|
||||
*/
|
||||
|
||||
import LevelRESTController from './level/index.js'
|
||||
import HealthRouter from './health/index.js'
|
||||
import ProfileRouter from './profile/index.js'
|
||||
import PostsRouter from './posts/index.js'
|
||||
|
||||
class RESTControllers {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
this.useCases = localConfig.useCases
|
||||
this.attachRESTControllers = this.attachRESTControllers.bind(this)
|
||||
}
|
||||
|
||||
attachRESTControllers (app) {
|
||||
const dependencies = {
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
}
|
||||
|
||||
const levelRESTController = new LevelRESTController(dependencies)
|
||||
levelRESTController.attach(app)
|
||||
|
||||
const healthRouter = new HealthRouter()
|
||||
healthRouter.attach(app)
|
||||
|
||||
const profileRouter = new ProfileRouter(dependencies)
|
||||
profileRouter.attach(app)
|
||||
|
||||
const postsRouter = new PostsRouter(dependencies)
|
||||
postsRouter.attach(app)
|
||||
}
|
||||
}
|
||||
|
||||
export default RESTControllers
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
REST API Controller for /level routes.
|
||||
*/
|
||||
|
||||
import wlogger from '../../../adapters/wlogger.js'
|
||||
import { makeCrudHandlers, ENTITY_CONFIG } from './crud-handlers.js'
|
||||
|
||||
class LevelRESTControllerLib {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required for Level REST Controller.')
|
||||
}
|
||||
|
||||
this.handleError = this.handleError.bind(this)
|
||||
this.getStatus = this.getStatus.bind(this)
|
||||
this.createStatus = this.createStatus.bind(this)
|
||||
this.updateStatus = this.updateStatus.bind(this)
|
||||
this.deleteStatus = this.deleteStatus.bind(this)
|
||||
this.backup = this.backup.bind(this)
|
||||
this.restore = this.restore.bind(this)
|
||||
|
||||
this.entityHandlers = {}
|
||||
for (const cfg of ENTITY_CONFIG) {
|
||||
const handlers = makeCrudHandlers(cfg)
|
||||
this.entityHandlers[cfg.route] = {
|
||||
get: async (ctx) => this._wrap(handlers.get, ctx),
|
||||
create: async (ctx) => this._wrap(handlers.create, ctx),
|
||||
update: async (ctx) => this._wrap(handlers.update, ctx),
|
||||
delete: async (ctx) => this._wrap(handlers.delete, ctx),
|
||||
keyParam: cfg.keyParam
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleError (ctx, err) {
|
||||
if (err.status) {
|
||||
ctx.throw(err.status, err.message || err)
|
||||
} else {
|
||||
ctx.throw(422, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async _wrap (fn, ctx) {
|
||||
try {
|
||||
await fn(ctx, this.adapters)
|
||||
} catch (err) {
|
||||
wlogger.error('Error in level controller: ', err)
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /level/status/:statusKey Get indexer status
|
||||
* @apiPermission public
|
||||
* @apiName GetLevelStatus
|
||||
* @apiGroup Level Status
|
||||
*
|
||||
* @apiDescription Read a status document from the status LevelDB store.
|
||||
*
|
||||
* @apiParam {String} statusKey Status key (typically "status")
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/status/status
|
||||
*
|
||||
* @apiSuccess {Number} startBlockHeight First block indexed
|
||||
* @apiSuccess {Number} syncedBlockHeight Last fully indexed block
|
||||
* @apiSuccess {Number} chainBlockHeight Current chain tip at last sync
|
||||
*/
|
||||
async getStatus (ctx) {
|
||||
try {
|
||||
const { statusKey } = ctx.params
|
||||
ctx.body = await this.adapters.level.statusDb.get(statusKey)
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /level/status Create indexer status record
|
||||
* @apiPermission public
|
||||
* @apiName CreateLevelStatus
|
||||
* @apiGroup Level Status
|
||||
*
|
||||
* @apiDescription Create or overwrite a status document in the status LevelDB store.
|
||||
*
|
||||
* @apiBody {String} statusKey Status key (typically "status")
|
||||
* @apiBody {Object} statusData Sync state document
|
||||
* @apiBody {Number} statusData.startBlockHeight First block indexed
|
||||
* @apiBody {Number} statusData.syncedBlockHeight Last fully indexed block
|
||||
* @apiBody {Number} statusData.chainBlockHeight Current chain tip at last sync
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/status \
|
||||
* -d '{"statusKey":"status","statusData":{"startBlockHeight":524999,"syncedBlockHeight":800000,"chainBlockHeight":800001}}'
|
||||
*
|
||||
* @apiSuccess {String} statusKey Status key written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
async createStatus (ctx) {
|
||||
try {
|
||||
const { statusKey, statusData } = ctx.request.body
|
||||
await this.adapters.level.statusDb.put(statusKey, statusData)
|
||||
ctx.body = { statusKey, success: true }
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {put} /level/status Update indexer status
|
||||
* @apiPermission public
|
||||
* @apiName UpdateLevelStatus
|
||||
* @apiGroup Level Status
|
||||
*
|
||||
* @apiDescription Update the status document keyed as "status".
|
||||
*
|
||||
* @apiBody {Object} statusData Sync state document
|
||||
* @apiBody {Number} statusData.startBlockHeight First block indexed
|
||||
* @apiBody {Number} statusData.syncedBlockHeight Last fully indexed block
|
||||
* @apiBody {Number} statusData.chainBlockHeight Current chain tip at last sync
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/status \
|
||||
* -d '{"statusData":{"startBlockHeight":524999,"syncedBlockHeight":800001,"chainBlockHeight":800002}}'
|
||||
*
|
||||
* @apiSuccess {String} statusKey Always "status"
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
async updateStatus (ctx) {
|
||||
try {
|
||||
const statusKey = 'status'
|
||||
const { statusData } = ctx.request.body
|
||||
await this.adapters.level.statusDb.put(statusKey, statusData)
|
||||
ctx.body = { statusKey, success: true }
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {delete} /level/status/:statusKey Delete indexer status
|
||||
* @apiPermission public
|
||||
* @apiName DeleteLevelStatus
|
||||
* @apiGroup Level Status
|
||||
*
|
||||
* @apiDescription Delete a status document from the status LevelDB store.
|
||||
*
|
||||
* @apiParam {String} statusKey Status key to delete
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/status/status
|
||||
*
|
||||
* @apiSuccess {String} statusKey Deleted status key
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
async deleteStatus (ctx) {
|
||||
try {
|
||||
const { statusKey } = ctx.params
|
||||
await this.adapters.level.statusDb.del(statusKey)
|
||||
ctx.body = { statusKey, success: true }
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /level/backup Backup LevelDB to zip archive
|
||||
* @apiPermission public
|
||||
* @apiName BackupLevelDb
|
||||
* @apiGroup Level Admin
|
||||
*
|
||||
* @apiDescription Zip the current LevelDB directory to leveldb/zips/memo-indexer-{height}.zip.
|
||||
*
|
||||
* @apiBody {Number} height Block height label for the backup filename
|
||||
* @apiBody {Number} epoch Epoch identifier included in backup metadata
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/backup \
|
||||
* -d '{"height":800000,"epoch":1}'
|
||||
*
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
async backup (ctx) {
|
||||
try {
|
||||
const { height, epoch } = ctx.request.body
|
||||
await this.adapters.dbBackup.zipDb(height, epoch)
|
||||
ctx.body = { success: true }
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /level/restore Restore LevelDB from zip archive
|
||||
* @apiPermission public
|
||||
* @apiName RestoreLevelDb
|
||||
* @apiGroup Level Admin
|
||||
*
|
||||
* @apiDescription Unzip a backup archive matching the given height and exit the process for restart by a process manager.
|
||||
*
|
||||
* @apiBody {Number} height Block height label of the backup to restore
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/restore \
|
||||
* -d '{"height":800000}'
|
||||
*
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
async restore (ctx) {
|
||||
try {
|
||||
const { height } = ctx.request.body
|
||||
await this.adapters.dbBackup.unzipDb(height)
|
||||
console.log('Restore complete. Exiting for process manager restart.')
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LevelRESTControllerLib
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Generic LevelDB CRUD handlers for /level routes.
|
||||
*/
|
||||
|
||||
export function makeCrudHandlers ({ dbProp, keyParam, bodyIdField, bodyDataField, label }) {
|
||||
return {
|
||||
async get (ctx, adapters) {
|
||||
const key = ctx.params[keyParam]
|
||||
const result = await adapters.level[dbProp].get(key)
|
||||
ctx.body = result
|
||||
},
|
||||
|
||||
async create (ctx, adapters) {
|
||||
const key = ctx.request.body[bodyIdField]
|
||||
const data = ctx.request.body[bodyDataField]
|
||||
await adapters.level[dbProp].put(key, data)
|
||||
ctx.body = { [bodyIdField]: key, success: true }
|
||||
},
|
||||
|
||||
async update (ctx, adapters) {
|
||||
const key = ctx.params[keyParam]
|
||||
const data = ctx.request.body[bodyDataField]
|
||||
await adapters.level[dbProp].put(key, data)
|
||||
ctx.body = { [keyParam]: key, success: true }
|
||||
},
|
||||
|
||||
async delete (ctx, adapters) {
|
||||
const key = ctx.params[keyParam]
|
||||
await adapters.level[dbProp].del(key)
|
||||
ctx.body = { [keyParam]: key, success: true }
|
||||
},
|
||||
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
export const ENTITY_CONFIG = [
|
||||
{ route: 'post', dbProp: 'postsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'postData' },
|
||||
{ route: 'postparent', dbProp: 'postParentsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'parentData' },
|
||||
{ route: 'postchild', dbProp: 'postChildrenDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'childData' },
|
||||
{ route: 'like', dbProp: 'likesDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'likeData' },
|
||||
{ route: 'name', dbProp: 'namesDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'nameData' },
|
||||
{ route: 'profile', dbProp: 'profilesDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profileData' },
|
||||
{ route: 'profilepic', dbProp: 'profilePicsDb', keyParam: 'addr', bodyIdField: 'addr', bodyDataField: 'profilePicData' },
|
||||
{ route: 'follow', dbProp: 'followsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'followData' },
|
||||
{ route: 'room', dbProp: 'roomsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'roomData' },
|
||||
{ route: 'processerror', dbProp: 'processErrorsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'errorData' },
|
||||
{ route: 'ptx', dbProp: 'ptxsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'ptxData' }
|
||||
]
|
||||
@@ -0,0 +1,844 @@
|
||||
/*
|
||||
apidoc blocks for generic /level entity CRUD routes.
|
||||
Handlers are generated from ENTITY_CONFIG in crud-handlers.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine LevelPublic
|
||||
* @apiPermission public
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine PostDataFields
|
||||
* @apiBody {String} postData.addr Author cash address
|
||||
* @apiBody {String} postData.text Post or reply message text
|
||||
* @apiBody {Number} postData.seen Unix epoch milliseconds
|
||||
* @apiBody {Number} postData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/post Create post record
|
||||
* @apiName CreateLevelPost
|
||||
* @apiGroup Level Post
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiDescription Create or overwrite a post document keyed by transaction id.
|
||||
*
|
||||
* @apiBody {String} txid Post transaction id (key)
|
||||
* @apiUse PostDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/post \
|
||||
* -d '{"txid":"abc...","postData":{"addr":"bitcoincash:q...","text":"hello","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/post/:txid Get post record
|
||||
* @apiName GetLevelPost
|
||||
* @apiGroup Level Post
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Post transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/post/abc...
|
||||
*
|
||||
* @apiSuccess {String} addr Author cash address
|
||||
* @apiSuccess {String} text Post text
|
||||
* @apiSuccess {Number} seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/post/:txid Update post record
|
||||
* @apiName UpdateLevelPost
|
||||
* @apiGroup Level Post
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Post transaction id
|
||||
* @apiUse PostDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/post/abc... \
|
||||
* -d '{"postData":{"addr":"bitcoincash:q...","text":"updated","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/post/:txid Delete post record
|
||||
* @apiName DeleteLevelPost
|
||||
* @apiGroup Level Post
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Post transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/post/abc...
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine ReplyLinkFields
|
||||
* @apiBody {String} parentData.parentTxid Parent post transaction id
|
||||
* @apiBody {String} parentData.childTxid Reply transaction id
|
||||
* @apiBody {Number} parentData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/postparent Create reply parent link
|
||||
* @apiName CreateLevelPostParent
|
||||
* @apiGroup Level Post Parent
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiDescription Store the forward link from a reply transaction to its parent post. Keyed by child (reply) txid.
|
||||
*
|
||||
* @apiBody {String} txid Reply transaction id (key)
|
||||
* @apiUse ReplyLinkFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/postparent \
|
||||
* -d '{"txid":"child...","parentData":{"parentTxid":"parent...","childTxid":"child...","blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Reply transaction id written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/postparent/:txid Get reply parent link
|
||||
* @apiName GetLevelPostParent
|
||||
* @apiGroup Level Post Parent
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Reply transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/postparent/child...
|
||||
*
|
||||
* @apiSuccess {String} parentTxid Parent post transaction id
|
||||
* @apiSuccess {String} childTxid Reply transaction id
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/postparent/:txid Update reply parent link
|
||||
* @apiName UpdateLevelPostParent
|
||||
* @apiGroup Level Post Parent
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Reply transaction id
|
||||
* @apiUse ReplyLinkFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/postparent/child... \
|
||||
* -d '{"parentData":{"parentTxid":"parent...","childTxid":"child...","blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Reply transaction id updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/postparent/:txid Delete reply parent link
|
||||
* @apiName DeleteLevelPostParent
|
||||
* @apiGroup Level Post Parent
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Reply transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/postparent/child...
|
||||
*
|
||||
* @apiSuccess {String} txid Reply transaction id deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine ChildLinkFields
|
||||
* @apiBody {String} childData.parentTxid Parent post transaction id
|
||||
* @apiBody {String} childData.childTxid Reply transaction id
|
||||
* @apiBody {Number} childData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/postchild Create reply child link
|
||||
* @apiName CreateLevelPostChild
|
||||
* @apiGroup Level Post Child
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiDescription Store the reverse link from a parent post to a reply. Keyed by parentTxid:childTxid composite key.
|
||||
*
|
||||
* @apiBody {String} key Composite key parentTxid:childTxid
|
||||
* @apiUse ChildLinkFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/postchild \
|
||||
* -d '{"key":"parent...:child...","childData":{"parentTxid":"parent...","childTxid":"child...","blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} key Composite key written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/postchild/:key Get reply child link
|
||||
* @apiName GetLevelPostChild
|
||||
* @apiGroup Level Post Child
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key parentTxid:childTxid
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/postchild/parent...:child...
|
||||
*
|
||||
* @apiSuccess {String} parentTxid Parent post transaction id
|
||||
* @apiSuccess {String} childTxid Reply transaction id
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/postchild/:key Update reply child link
|
||||
* @apiName UpdateLevelPostChild
|
||||
* @apiGroup Level Post Child
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key parentTxid:childTxid
|
||||
* @apiUse ChildLinkFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/postchild/parent...:child... \
|
||||
* -d '{"childData":{"parentTxid":"parent...","childTxid":"child...","blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} key Composite key updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/postchild/:key Delete reply child link
|
||||
* @apiName DeleteLevelPostChild
|
||||
* @apiGroup Level Post Child
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key parentTxid:childTxid
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/postchild/parent...:child...
|
||||
*
|
||||
* @apiSuccess {String} key Composite key deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine LikeDataFields
|
||||
* @apiBody {String} likeData.addr Liker cash address
|
||||
* @apiBody {String} likeData.postTxid Liked post transaction id
|
||||
* @apiBody {Number} likeData.seen Unix epoch milliseconds
|
||||
* @apiBody {Number} [likeData.tip] Tip amount in satoshis
|
||||
* @apiBody {Number} likeData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/like Create like record
|
||||
* @apiName CreateLevelLike
|
||||
* @apiGroup Level Like
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiBody {String} txid Like transaction id (key)
|
||||
* @apiUse LikeDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/like \
|
||||
* -d '{"txid":"abc...","likeData":{"addr":"bitcoincash:q...","postTxid":"post...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Like transaction id written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/like/:txid Get like record
|
||||
* @apiName GetLevelLike
|
||||
* @apiGroup Level Like
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Like transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/like/abc...
|
||||
*
|
||||
* @apiSuccess {String} addr Liker cash address
|
||||
* @apiSuccess {String} postTxid Liked post transaction id
|
||||
* @apiSuccess {Number} seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} [tip] Tip amount in satoshis
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/like/:txid Update like record
|
||||
* @apiName UpdateLevelLike
|
||||
* @apiGroup Level Like
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Like transaction id
|
||||
* @apiUse LikeDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/like/abc... \
|
||||
* -d '{"likeData":{"addr":"bitcoincash:q...","postTxid":"post...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Like transaction id updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/like/:txid Delete like record
|
||||
* @apiName DeleteLevelLike
|
||||
* @apiGroup Level Like
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Like transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/like/abc...
|
||||
*
|
||||
* @apiSuccess {String} txid Like transaction id deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine NameDataFields
|
||||
* @apiBody {String} nameData.name Display name
|
||||
* @apiBody {String} nameData.txid Provenance transaction id
|
||||
* @apiBody {String} nameData.addr Cash address
|
||||
* @apiBody {Number} nameData.seen Unix epoch milliseconds
|
||||
* @apiBody {Number} nameData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/name Create name record
|
||||
* @apiName CreateLevelName
|
||||
* @apiGroup Level Name
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiBody {String} addr Cash address (key)
|
||||
* @apiUse NameDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/name \
|
||||
* -d '{"addr":"bitcoincash:q...","nameData":{"name":"memo","txid":"abc...","addr":"bitcoincash:q...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/name/:addr Get name record
|
||||
* @apiName GetLevelName
|
||||
* @apiGroup Level Name
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/name/bitcoincash:q...
|
||||
*
|
||||
* @apiSuccess {String} name Display name
|
||||
* @apiSuccess {String} txid Provenance transaction id
|
||||
* @apiSuccess {String} addr Cash address
|
||||
* @apiSuccess {Number} seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/name/:addr Update name record
|
||||
* @apiName UpdateLevelName
|
||||
* @apiGroup Level Name
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
* @apiUse NameDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/name/bitcoincash:q... \
|
||||
* -d '{"nameData":{"name":"memo","txid":"abc...","addr":"bitcoincash:q...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/name/:addr Delete name record
|
||||
* @apiName DeleteLevelName
|
||||
* @apiGroup Level Name
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/name/bitcoincash:q...
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine ProfileDataFields
|
||||
* @apiBody {String} profileData.text Profile message text
|
||||
* @apiBody {String} profileData.txid Provenance transaction id
|
||||
* @apiBody {String} profileData.addr Cash address
|
||||
* @apiBody {Number} profileData.seen Unix epoch milliseconds
|
||||
* @apiBody {Number} profileData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/profile Create profile record
|
||||
* @apiName CreateLevelProfile
|
||||
* @apiGroup Level Profile
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiBody {String} addr Cash address (key)
|
||||
* @apiUse ProfileDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/profile \
|
||||
* -d '{"addr":"bitcoincash:q...","profileData":{"text":"my bio","txid":"abc...","addr":"bitcoincash:q...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/profile/:addr Get profile record
|
||||
* @apiName GetLevelProfile
|
||||
* @apiGroup Level Profile
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/profile/bitcoincash:q...
|
||||
*
|
||||
* @apiSuccess {String} text Profile message text
|
||||
* @apiSuccess {String} txid Provenance transaction id
|
||||
* @apiSuccess {String} addr Cash address
|
||||
* @apiSuccess {Number} seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/profile/:addr Update profile record
|
||||
* @apiName UpdateLevelProfile
|
||||
* @apiGroup Level Profile
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
* @apiUse ProfileDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/profile/bitcoincash:q... \
|
||||
* -d '{"profileData":{"text":"updated bio","txid":"abc...","addr":"bitcoincash:q...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/profile/:addr Delete profile record
|
||||
* @apiName DeleteLevelProfile
|
||||
* @apiGroup Level Profile
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/profile/bitcoincash:q...
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine ProfilePicDataFields
|
||||
* @apiBody {String} profilePicData.url Avatar image URL
|
||||
* @apiBody {String} profilePicData.txid Provenance transaction id
|
||||
* @apiBody {String} profilePicData.addr Cash address
|
||||
* @apiBody {Number} profilePicData.seen Unix epoch milliseconds
|
||||
* @apiBody {Number} profilePicData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/profilepic Create profile picture record
|
||||
* @apiName CreateLevelProfilePic
|
||||
* @apiGroup Level Profile Pic
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiBody {String} addr Cash address (key)
|
||||
* @apiUse ProfilePicDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/profilepic \
|
||||
* -d '{"addr":"bitcoincash:q...","profilePicData":{"url":"https://example.com/pic.jpg","txid":"abc...","addr":"bitcoincash:q...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/profilepic/:addr Get profile picture record
|
||||
* @apiName GetLevelProfilePic
|
||||
* @apiGroup Level Profile Pic
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/profilepic/bitcoincash:q...
|
||||
*
|
||||
* @apiSuccess {String} url Avatar image URL
|
||||
* @apiSuccess {String} txid Provenance transaction id
|
||||
* @apiSuccess {String} addr Cash address
|
||||
* @apiSuccess {Number} seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/profilepic/:addr Update profile picture record
|
||||
* @apiName UpdateLevelProfilePic
|
||||
* @apiGroup Level Profile Pic
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
* @apiUse ProfilePicDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/profilepic/bitcoincash:q... \
|
||||
* -d '{"profilePicData":{"url":"https://example.com/pic.jpg","txid":"abc...","addr":"bitcoincash:q...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/profilepic/:addr Delete profile picture record
|
||||
* @apiName DeleteLevelProfilePic
|
||||
* @apiGroup Level Profile Pic
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} addr Cash address
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/profilepic/bitcoincash:q...
|
||||
*
|
||||
* @apiSuccess {String} addr Cash address deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine FollowDataFields
|
||||
* @apiBody {String} followData.followerAddr Follower cash address
|
||||
* @apiBody {String} followData.followeePkHash Followee public key hash (hex)
|
||||
* @apiBody {Boolean} followData.unfollow True for unfollow actions
|
||||
* @apiBody {String} followData.txid Provenance transaction id
|
||||
* @apiBody {Number} followData.seen Unix epoch milliseconds
|
||||
* @apiBody {Number} followData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/follow Create follow record
|
||||
* @apiName CreateLevelFollow
|
||||
* @apiGroup Level Follow
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiDescription Store a follow or unfollow edge. Key is followerAddr:followeePkHash.
|
||||
*
|
||||
* @apiBody {String} key Composite key followerAddr:followeePkHash
|
||||
* @apiUse FollowDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/follow \
|
||||
* -d '{"key":"bitcoincash:q...:abc123","followData":{"followerAddr":"bitcoincash:q...","followeePkHash":"abc123","unfollow":false,"txid":"tx...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} key Composite key written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/follow/:key Get follow record
|
||||
* @apiName GetLevelFollow
|
||||
* @apiGroup Level Follow
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key followerAddr:followeePkHash
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/follow/bitcoincash:q...:abc123
|
||||
*
|
||||
* @apiSuccess {String} followerAddr Follower cash address
|
||||
* @apiSuccess {String} followeePkHash Followee public key hash
|
||||
* @apiSuccess {Boolean} unfollow True for unfollow actions
|
||||
* @apiSuccess {String} txid Provenance transaction id
|
||||
* @apiSuccess {Number} seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/follow/:key Update follow record
|
||||
* @apiName UpdateLevelFollow
|
||||
* @apiGroup Level Follow
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key followerAddr:followeePkHash
|
||||
* @apiUse FollowDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/follow/bitcoincash:q...:abc123 \
|
||||
* -d '{"followData":{"followerAddr":"bitcoincash:q...","followeePkHash":"abc123","unfollow":true,"txid":"tx...","seen":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} key Composite key updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/follow/:key Delete follow record
|
||||
* @apiName DeleteLevelFollow
|
||||
* @apiGroup Level Follow
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key followerAddr:followeePkHash
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/follow/bitcoincash:q...:abc123
|
||||
*
|
||||
* @apiSuccess {String} key Composite key deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine RoomDataFields
|
||||
* @apiBody {String} roomData.room Topic or room name
|
||||
* @apiBody {String} roomData.txid Provenance transaction id
|
||||
* @apiBody {Number} roomData.seen Unix epoch milliseconds
|
||||
* @apiBody {String} roomData.type Record type (e.g. "post")
|
||||
* @apiBody {Number} roomData.blockHeight Block height when indexed
|
||||
* @apiBody {String} [roomData.addr] Author cash address for topic follows
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/room Create room record
|
||||
* @apiName CreateLevelRoom
|
||||
* @apiGroup Level Room
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiDescription Store a topic post or topic follow index entry. Key is roomName:txid.
|
||||
*
|
||||
* @apiBody {String} key Composite key roomName:txid
|
||||
* @apiUse RoomDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/room \
|
||||
* -d '{"key":"general:abc...","roomData":{"room":"general","txid":"abc...","seen":1500000000000,"type":"post","blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} key Composite key written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/room/:key Get room record
|
||||
* @apiName GetLevelRoom
|
||||
* @apiGroup Level Room
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key roomName:txid
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/room/general:abc...
|
||||
*
|
||||
* @apiSuccess {String} room Topic or room name
|
||||
* @apiSuccess {String} txid Provenance transaction id
|
||||
* @apiSuccess {Number} seen Unix epoch milliseconds
|
||||
* @apiSuccess {String} type Record type
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/room/:key Update room record
|
||||
* @apiName UpdateLevelRoom
|
||||
* @apiGroup Level Room
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key roomName:txid
|
||||
* @apiUse RoomDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/room/general:abc... \
|
||||
* -d '{"roomData":{"room":"general","txid":"abc...","seen":1500000000000,"type":"post","blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} key Composite key updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/room/:key Delete room record
|
||||
* @apiName DeleteLevelRoom
|
||||
* @apiGroup Level Room
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} key Composite key roomName:txid
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/room/general:abc...
|
||||
*
|
||||
* @apiSuccess {String} key Composite key deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine ProcessErrorDataFields
|
||||
* @apiBody {String} errorData.error Error message
|
||||
* @apiBody {Number} errorData.ts Unix epoch milliseconds when logged
|
||||
* @apiBody {Number} errorData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/processerror Create process error record
|
||||
* @apiName CreateLevelProcessError
|
||||
* @apiGroup Level Process Error
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiDescription Store a validation or parse failure for a memo transaction.
|
||||
*
|
||||
* @apiBody {String} txid Failed transaction id (key)
|
||||
* @apiUse ProcessErrorDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/processerror \
|
||||
* -d '{"txid":"abc...","errorData":{"error":"invalid reply push data count 2","ts":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/processerror/:txid Get process error record
|
||||
* @apiName GetLevelProcessError
|
||||
* @apiGroup Level Process Error
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Failed transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/processerror/abc...
|
||||
*
|
||||
* @apiSuccess {String} error Error message
|
||||
* @apiSuccess {Number} ts Unix epoch milliseconds when logged
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/processerror/:txid Update process error record
|
||||
* @apiName UpdateLevelProcessError
|
||||
* @apiGroup Level Process Error
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Failed transaction id
|
||||
* @apiUse ProcessErrorDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/processerror/abc... \
|
||||
* -d '{"errorData":{"error":"invalid reply push data count 2","ts":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/processerror/:txid Delete process error record
|
||||
* @apiName DeleteLevelProcessError
|
||||
* @apiGroup Level Process Error
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Failed transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/processerror/abc...
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine PtxDataFields
|
||||
* @apiBody {Number} ptxData.processedAt Unix epoch milliseconds when processed
|
||||
* @apiBody {Number} ptxData.blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {post} /level/ptx Create processed transaction marker
|
||||
* @apiName CreateLevelPtx
|
||||
* @apiGroup Level Processed Tx
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiDescription Mark a memo transaction as processed for idempotency.
|
||||
*
|
||||
* @apiBody {String} txid Transaction id (key)
|
||||
* @apiUse PtxDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X POST localhost:5021/level/ptx \
|
||||
* -d '{"txid":"abc...","ptxData":{"processedAt":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id written
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {get} /level/ptx/:txid Get processed transaction marker
|
||||
* @apiName GetLevelPtx
|
||||
* @apiGroup Level Processed Tx
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET localhost:5021/level/ptx/abc...
|
||||
*
|
||||
* @apiSuccess {Number} processedAt Unix epoch milliseconds when processed
|
||||
* @apiSuccess {Number} blockHeight Block height when indexed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {put} /level/ptx/:txid Update processed transaction marker
|
||||
* @apiName UpdateLevelPtx
|
||||
* @apiGroup Level Processed Tx
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Transaction id
|
||||
* @apiUse PtxDataFields
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -H "Content-Type: application/json" -X PUT localhost:5021/level/ptx/abc... \
|
||||
* -d '{"ptxData":{"processedAt":1500000000000,"blockHeight":600000}}'
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id updated
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
/**
|
||||
* @api {delete} /level/ptx/:txid Delete processed transaction marker
|
||||
* @apiName DeleteLevelPtx
|
||||
* @apiGroup Level Processed Tx
|
||||
* @apiUse LevelPublic
|
||||
*
|
||||
* @apiParam {String} txid Transaction id
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X DELETE localhost:5021/level/ptx/abc...
|
||||
*
|
||||
* @apiSuccess {String} txid Transaction id deleted
|
||||
* @apiSuccess {Boolean} success true
|
||||
*/
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
REST API router for /level routes.
|
||||
*/
|
||||
|
||||
import Router from 'koa-router'
|
||||
import LevelRESTControllerLib from './controller.js'
|
||||
import { ENTITY_CONFIG } from './crud-handlers.js'
|
||||
|
||||
class LevelRouter {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
this.useCases = localConfig.useCases
|
||||
this.levelRESTController = new LevelRESTControllerLib({
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
})
|
||||
this.router = new Router({ prefix: '/level' })
|
||||
}
|
||||
|
||||
attach (app) {
|
||||
const ctrl = this.levelRESTController
|
||||
|
||||
for (const cfg of ENTITY_CONFIG) {
|
||||
const h = ctrl.entityHandlers[cfg.route]
|
||||
this.router.post(`/${cfg.route}`, h.create)
|
||||
this.router.get(`/${cfg.route}/:${cfg.keyParam}`, h.get)
|
||||
this.router.put(`/${cfg.route}/:${cfg.keyParam}`, h.update)
|
||||
this.router.delete(`/${cfg.route}/:${cfg.keyParam}`, h.delete)
|
||||
}
|
||||
|
||||
this.router.post('/status', ctrl.createStatus)
|
||||
this.router.get('/status/:statusKey', ctrl.getStatus)
|
||||
this.router.put('/status', ctrl.updateStatus)
|
||||
this.router.delete('/status/:statusKey', ctrl.deleteStatus)
|
||||
|
||||
this.router.post('/backup', ctrl.backup)
|
||||
this.router.post('/restore', ctrl.restore)
|
||||
|
||||
app.use(this.router.routes())
|
||||
app.use(this.router.allowedMethods())
|
||||
}
|
||||
}
|
||||
|
||||
export default LevelRouter
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function errorMiddleware () {
|
||||
return async (ctx, next) => {
|
||||
try {
|
||||
await next()
|
||||
} catch (err) {
|
||||
ctx.status = err.status || 500
|
||||
ctx.body = err.message
|
||||
ctx.app.emit('error', err, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
REST API controller for /posts routes.
|
||||
*/
|
||||
|
||||
import wlogger from '../../../adapters/wlogger.js'
|
||||
|
||||
class PostsRESTControllerLib {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required for Posts REST Controller.')
|
||||
}
|
||||
if (!this.useCases) {
|
||||
throw new Error('Use Cases required for Posts REST Controller.')
|
||||
}
|
||||
|
||||
this.getRecentPosts = this.getRecentPosts.bind(this)
|
||||
this.getPostsByAddr = this.getPostsByAddr.bind(this)
|
||||
this.getPostThread = this.getPostThread.bind(this)
|
||||
this.handleError = this.handleError.bind(this)
|
||||
}
|
||||
|
||||
handleError (ctx, err) {
|
||||
if (err.status) {
|
||||
ctx.throw(err.status, err.message || err)
|
||||
} else {
|
||||
wlogger.error('Error in posts controller: ', err)
|
||||
ctx.throw(500, err.message || 'Internal server error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /posts/recent List recent posts
|
||||
* @apiPermission public
|
||||
* @apiName GetRecentPosts
|
||||
* @apiGroup REST Posts
|
||||
*
|
||||
* @apiDescription Returns top-level posts only (replies excluded), sorted by block height (newest first), with seen timestamp as tie-breaker.
|
||||
*
|
||||
* @apiQuery {Number} [limit=100] Page size (max 100)
|
||||
* @apiQuery {Number} [offset=0] Number of posts to skip after sorting
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET "localhost:5021/posts/recent?limit=50&offset=0"
|
||||
*
|
||||
* @apiSuccess {Object[]} posts Array of post objects
|
||||
* @apiSuccess {String} posts.txid Post transaction id
|
||||
* @apiSuccess {String} posts.addr Author cash address
|
||||
* @apiSuccess {String} posts.text Post text
|
||||
* @apiSuccess {Number} posts.seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} posts.blockHeight Block height when indexed
|
||||
* @apiSuccess {Number} posts.replyCount Number of replies to this post
|
||||
* @apiSuccess {Object} pagination Pagination metadata
|
||||
* @apiSuccess {Number} pagination.limit Page size used
|
||||
* @apiSuccess {Number} pagination.offset Offset used
|
||||
* @apiSuccess {Number} pagination.total Total matching posts
|
||||
* @apiSuccess {Boolean} pagination.hasMore True if more pages exist
|
||||
*/
|
||||
async getRecentPosts (ctx) {
|
||||
try {
|
||||
const { limit, offset } = ctx.query
|
||||
ctx.body = await this.useCases.listRecentPosts.execute({ limit, offset })
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /posts/by/:addr List posts by address
|
||||
* @apiPermission public
|
||||
* @apiName GetPostsByAddr
|
||||
* @apiGroup REST Posts
|
||||
*
|
||||
* @apiDescription Returns top-level posts for a single address (replies excluded), sorted by block height (newest first).
|
||||
*
|
||||
* @apiParam {String} addr Author cash address
|
||||
* @apiQuery {Number} [limit=100] Page size (max 100)
|
||||
* @apiQuery {Number} [offset=0] Number of posts to skip after sorting
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET "localhost:5021/posts/by/bitcoincash:q...?limit=50&offset=0"
|
||||
*
|
||||
* @apiSuccess {Object[]} posts Array of post objects
|
||||
* @apiSuccess {String} posts.txid Post transaction id
|
||||
* @apiSuccess {String} posts.addr Author cash address
|
||||
* @apiSuccess {String} posts.text Post text
|
||||
* @apiSuccess {Number} posts.seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} posts.blockHeight Block height when indexed
|
||||
* @apiSuccess {Number} posts.replyCount Number of replies to this post
|
||||
* @apiSuccess {Object} pagination Pagination metadata
|
||||
*/
|
||||
async getPostsByAddr (ctx) {
|
||||
try {
|
||||
const { addr } = ctx.params
|
||||
const { limit, offset } = ctx.query
|
||||
ctx.body = await this.useCases.listPostsByAddr.execute({ addr, limit, offset })
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
async getPostThread (ctx) {
|
||||
try {
|
||||
const { txid } = ctx.params
|
||||
|
||||
ctx.body = await this.useCases.getPostThread.execute({
|
||||
txid
|
||||
})
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PostsRESTControllerLib
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
REST API router for /posts routes.
|
||||
*/
|
||||
|
||||
import Router from 'koa-router'
|
||||
import PostsRESTControllerLib from './controller.js'
|
||||
|
||||
class PostsRouter {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating Posts REST Controller.')
|
||||
}
|
||||
if (!this.useCases) {
|
||||
throw new Error('Use Cases required when instantiating Posts REST Controller.')
|
||||
}
|
||||
|
||||
this.postsRESTController = new PostsRESTControllerLib({
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
})
|
||||
this.router = new Router({ prefix: '/posts' })
|
||||
}
|
||||
|
||||
attach (app) {
|
||||
this.router.get('/recent', this.postsRESTController.getRecentPosts)
|
||||
this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr)
|
||||
this.router.get('/:txid/thread',this.postsRESTController.getPostThread)
|
||||
app.use(this.router.routes())
|
||||
app.use(this.router.allowedMethods())
|
||||
}
|
||||
}
|
||||
|
||||
export default PostsRouter
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
REST API controller for /profile routes.
|
||||
*/
|
||||
|
||||
import wlogger from '../../../adapters/wlogger.js'
|
||||
|
||||
class ProfileRESTControllerLib {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required for Profile REST Controller.')
|
||||
}
|
||||
if (!this.useCases) {
|
||||
throw new Error('Use Cases required for Profile REST Controller.')
|
||||
}
|
||||
|
||||
this.getRecentProfiles = this.getRecentProfiles.bind(this)
|
||||
this.handleError = this.handleError.bind(this)
|
||||
}
|
||||
|
||||
handleError (ctx, err) {
|
||||
if (err.status) {
|
||||
ctx.throw(err.status, err.message || err)
|
||||
} else {
|
||||
wlogger.error('Error in profile controller: ', err)
|
||||
ctx.throw(500, err.message || 'Internal server error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /profile/recent List recent profiles
|
||||
* @apiPermission public
|
||||
* @apiName GetRecentProfiles
|
||||
* @apiGroup REST Profile
|
||||
*
|
||||
* @apiDescription Returns profiles sorted by block height (newest first), with seen timestamp as tie-breaker.
|
||||
*
|
||||
* @apiQuery {Number} [limit=100] Page size (max 100)
|
||||
* @apiQuery {Number} [offset=0] Number of profiles to skip after sorting
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET "localhost:5021/profile/recent?limit=50&offset=0"
|
||||
*
|
||||
* @apiSuccess {Object[]} profiles Array of profile objects
|
||||
* @apiSuccess {String} profiles.addr Cash address
|
||||
* @apiSuccess {String} profiles.text Profile message text
|
||||
* @apiSuccess {String} profiles.txid Provenance transaction id
|
||||
* @apiSuccess {Number} profiles.seen Unix epoch milliseconds
|
||||
* @apiSuccess {Number} profiles.blockHeight Block height when indexed
|
||||
* @apiSuccess {Object} pagination Pagination metadata
|
||||
* @apiSuccess {Number} pagination.limit Page size used
|
||||
* @apiSuccess {Number} pagination.offset Offset used
|
||||
* @apiSuccess {Number} pagination.total Total matching profiles
|
||||
* @apiSuccess {Boolean} pagination.hasMore True if more pages exist
|
||||
*/
|
||||
async getRecentProfiles (ctx) {
|
||||
try {
|
||||
const { limit, offset } = ctx.query
|
||||
ctx.body = await this.useCases.listRecentProfiles.execute({ limit, offset })
|
||||
} catch (err) {
|
||||
this.handleError(ctx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfileRESTControllerLib
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
REST API router for /profile routes.
|
||||
*/
|
||||
|
||||
import Router from 'koa-router'
|
||||
import ProfileRESTControllerLib from './controller.js'
|
||||
|
||||
class ProfileRouter {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
this.useCases = localConfig.useCases
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating Profile REST Controller.')
|
||||
}
|
||||
if (!this.useCases) {
|
||||
throw new Error('Use Cases required when instantiating Profile REST Controller.')
|
||||
}
|
||||
|
||||
this.profileRESTController = new ProfileRESTControllerLib({
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
})
|
||||
this.router = new Router({ prefix: '/profile' })
|
||||
}
|
||||
|
||||
attach (app) {
|
||||
this.router.get('/recent', this.profileRESTController.getRecentProfiles)
|
||||
app.use(this.router.routes())
|
||||
app.use(this.router.allowedMethods())
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfileRouter
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Retrieve a Memo post and its nested replies.
|
||||
*/
|
||||
|
||||
class GetPostThread {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Adapters required when instantiating GetPostThread.'
|
||||
)
|
||||
}
|
||||
|
||||
this.execute = this.execute.bind(this)
|
||||
this.buildThreadNode = this.buildThreadNode.bind(this)
|
||||
}
|
||||
|
||||
async execute ({ txid } = {}) {
|
||||
if (!txid || typeof txid !== 'string') {
|
||||
const err = new Error('A transaction ID is required.')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
|
||||
const rootPost = await this.buildThreadNode(txid)
|
||||
|
||||
if (!rootPost) {
|
||||
const err = new Error('Post not found.')
|
||||
err.status = 404
|
||||
throw err
|
||||
}
|
||||
|
||||
return {
|
||||
post: rootPost
|
||||
}
|
||||
}
|
||||
|
||||
async buildThreadNode (txid, visited = new Set()) {
|
||||
if (visited.has(txid)) return null
|
||||
|
||||
visited.add(txid)
|
||||
|
||||
let post
|
||||
|
||||
try {
|
||||
post = await this.adapters.postQuery.postsDb.get(txid)
|
||||
} catch (err) {
|
||||
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') {
|
||||
return null
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
const childTxids = []
|
||||
|
||||
for await (
|
||||
const [, child]
|
||||
of this.adapters.postQuery.postChildrenDb.iterator()
|
||||
) {
|
||||
if (child?.parentTxid !== txid) continue
|
||||
if (!child?.childTxid) continue
|
||||
|
||||
childTxids.push(child.childTxid)
|
||||
}
|
||||
|
||||
const replies = []
|
||||
|
||||
for (const childTxid of childTxids) {
|
||||
const reply = await this.buildThreadNode(childTxid, visited)
|
||||
|
||||
if (reply) {
|
||||
replies.push(reply)
|
||||
}
|
||||
}
|
||||
|
||||
replies.sort((a, b) => {
|
||||
const blockDifference =
|
||||
(a.blockHeight ?? 0) - (b.blockHeight ?? 0)
|
||||
|
||||
if (blockDifference !== 0) {
|
||||
return blockDifference
|
||||
}
|
||||
|
||||
return (a.seen ?? 0) - (b.seen ?? 0)
|
||||
})
|
||||
|
||||
return {
|
||||
txid,
|
||||
addr: post.addr,
|
||||
text: post.text,
|
||||
seen: post.seen,
|
||||
blockHeight: post.blockHeight ?? 0,
|
||||
replyCount: replies.length,
|
||||
replies
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GetPostThread
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Use cases for psf-memo-db.
|
||||
*/
|
||||
|
||||
import ListRecentProfiles from './list-recent-profiles.js'
|
||||
import ListRecentPosts from './list-recent-posts.js'
|
||||
import ListPostsByAddr from './list-posts-by-addr.js'
|
||||
import GetPostThread from './get-post-thread.js'
|
||||
|
||||
class UseCases {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
|
||||
if (!this.adapters) {
|
||||
throw new Error(
|
||||
'Adapters required when instantiating UseCases.'
|
||||
)
|
||||
}
|
||||
|
||||
this.listRecentProfiles = null
|
||||
this.listRecentPosts = null
|
||||
this.listPostsByAddr = null
|
||||
this.getPostThread = null
|
||||
}
|
||||
|
||||
async start () {
|
||||
this.listRecentProfiles = new ListRecentProfiles({
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
this.listRecentPosts = new ListRecentPosts({
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
this.listPostsByAddr = new ListPostsByAddr({
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
this.getPostThread = new GetPostThread({
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
console.log('Use cases initialized.')
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default UseCases
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
Use case: list posts for an address ordered by block height (most recent first), paginated.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 100
|
||||
|
||||
class ListPostsByAddr {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating ListPostsByAddr use case.')
|
||||
}
|
||||
if (!this.adapters.postQuery) {
|
||||
throw new Error('postQuery adapter required for ListPostsByAddr use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
}
|
||||
|
||||
parseLimit (limit) {
|
||||
if (limit === undefined || limit === null || limit === '') {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const parsed = parseInt(limit, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
const err = new Error('limit must be a positive integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (parsed > MAX_LIMIT) {
|
||||
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
parseOffset (offset) {
|
||||
if (offset === undefined || offset === null || offset === '') {
|
||||
return 0
|
||||
}
|
||||
const parsed = parseInt(offset, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
const err = new Error('offset must be a non-negative integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
parseAddr (addr) {
|
||||
if (!addr || typeof addr !== 'string') {
|
||||
const err = new Error('addr is required')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
sortPosts (posts) {
|
||||
return posts.sort((a, b) => {
|
||||
if (b.blockHeight !== a.blockHeight) {
|
||||
return b.blockHeight - a.blockHeight
|
||||
}
|
||||
return (b.seen || 0) - (a.seen || 0)
|
||||
})
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const addr = this.parseAddr(inObj.addr)
|
||||
const limit = this.parseLimit(inObj.limit)
|
||||
const offset = this.parseOffset(inObj.offset)
|
||||
|
||||
const allPosts = await this.adapters.postQuery.scanPostsByAddr(addr)
|
||||
const sorted = this.sortPosts(allPosts)
|
||||
const total = sorted.length
|
||||
const posts = sorted.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
posts,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total,
|
||||
hasMore: offset + posts.length < total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ListPostsByAddr
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Use case: list posts ordered by block height (most recent first), paginated.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 100
|
||||
|
||||
class ListRecentPosts {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating ListRecentPosts use case.')
|
||||
}
|
||||
if (!this.adapters.postQuery) {
|
||||
throw new Error('postQuery adapter required for ListRecentPosts use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
}
|
||||
|
||||
parseLimit (limit) {
|
||||
if (limit === undefined || limit === null || limit === '') {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const parsed = parseInt(limit, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
const err = new Error('limit must be a positive integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (parsed > MAX_LIMIT) {
|
||||
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
parseOffset (offset) {
|
||||
if (offset === undefined || offset === null || offset === '') {
|
||||
return 0
|
||||
}
|
||||
const parsed = parseInt(offset, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
const err = new Error('offset must be a non-negative integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
sortPosts (posts) {
|
||||
return posts.sort((a, b) => {
|
||||
if (b.blockHeight !== a.blockHeight) {
|
||||
return b.blockHeight - a.blockHeight
|
||||
}
|
||||
return (b.seen || 0) - (a.seen || 0)
|
||||
})
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const limit = this.parseLimit(inObj.limit)
|
||||
const offset = this.parseOffset(inObj.offset)
|
||||
|
||||
const allPosts = await this.adapters.postQuery.scanPostsWithBlockHeight()
|
||||
const sorted = this.sortPosts(allPosts)
|
||||
const total = sorted.length
|
||||
const posts = sorted.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
posts,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total,
|
||||
hasMore: offset + posts.length < total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ListRecentPosts
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Use case: list profiles ordered by block height (most recent first), paginated.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 100
|
||||
|
||||
class ListRecentProfiles {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating ListRecentProfiles use case.')
|
||||
}
|
||||
if (!this.adapters.profileQuery) {
|
||||
throw new Error('profileQuery adapter required for ListRecentProfiles use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
}
|
||||
|
||||
parseLimit (limit) {
|
||||
if (limit === undefined || limit === null || limit === '') {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const parsed = parseInt(limit, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
const err = new Error('limit must be a positive integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (parsed > MAX_LIMIT) {
|
||||
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
parseOffset (offset) {
|
||||
if (offset === undefined || offset === null || offset === '') {
|
||||
return 0
|
||||
}
|
||||
const parsed = parseInt(offset, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
const err = new Error('offset must be a non-negative integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
sortProfiles (profiles) {
|
||||
return profiles.sort((a, b) => {
|
||||
if (b.blockHeight !== a.blockHeight) {
|
||||
return b.blockHeight - a.blockHeight
|
||||
}
|
||||
return (b.seen || 0) - (a.seen || 0)
|
||||
})
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const limit = this.parseLimit(inObj.limit)
|
||||
const offset = this.parseOffset(inObj.offset)
|
||||
|
||||
const allProfiles = await this.adapters.profileQuery.scanProfilesWithBlockHeight()
|
||||
const sorted = this.sortProfiles(allProfiles)
|
||||
const total = sorted.length
|
||||
const profiles = sorted.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
profiles,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total,
|
||||
hasMore: offset + profiles.length < total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ListRecentProfiles
|
||||
@@ -0,0 +1,144 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import PostQuery from '../../../src/adapters/post-query.js'
|
||||
|
||||
describe('#PostQuery', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
let postsDb
|
||||
let postParentsDb
|
||||
let postChildrenDb
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
postsDb = {
|
||||
iterator: sandbox.stub()
|
||||
}
|
||||
postParentsDb = {
|
||||
iterator: sandbox.stub()
|
||||
}
|
||||
postChildrenDb = {
|
||||
iterator: sandbox.stub()
|
||||
}
|
||||
async function * emptyParents () {}
|
||||
async function * emptyChildren () {}
|
||||
postParentsDb.iterator.returns(emptyParents())
|
||||
postChildrenDb.iterator.returns(emptyChildren())
|
||||
uut = new PostQuery({ postsDb, postParentsDb, postChildrenDb })
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should scan posts and read block height from stored document', async () => {
|
||||
async function * mockIterator () {
|
||||
yield ['tx1', { addr: 'addr1', text: 'hello', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx2', { addr: 'addr2', text: 'world', seen: 2000, blockHeight: 600200 }]
|
||||
}
|
||||
postsDb.iterator.returns(mockIterator())
|
||||
|
||||
const result = await uut.scanPostsWithBlockHeight()
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
assert.equal(result[0].blockHeight, 600100)
|
||||
assert.equal(result[0].replyCount, 0)
|
||||
assert.equal(result[1].txid, 'tx2')
|
||||
assert.equal(result[1].blockHeight, 600200)
|
||||
assert.equal(result[1].replyCount, 0)
|
||||
})
|
||||
|
||||
it('should use block height 0 when field is missing', async () => {
|
||||
async function * mockIterator () {
|
||||
yield ['tx-missing', { addr: 'addr1', text: 'hi', seen: 1000 }]
|
||||
}
|
||||
postsDb.iterator.returns(mockIterator())
|
||||
|
||||
const result = await uut.scanPostsWithBlockHeight()
|
||||
|
||||
assert.equal(result[0].blockHeight, 0)
|
||||
assert.equal(result[0].replyCount, 0)
|
||||
})
|
||||
|
||||
it('should scan posts for a single address', async () => {
|
||||
async function * mockIterator () {
|
||||
yield ['tx1', { addr: 'addr-a', text: 'hello', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx2', { addr: 'addr-b', text: 'world', seen: 2000, blockHeight: 600200 }]
|
||||
yield ['tx3', { addr: 'addr-a', text: 'again', seen: 3000, blockHeight: 600300 }]
|
||||
}
|
||||
postsDb.iterator.returns(mockIterator())
|
||||
|
||||
const result = await uut.scanPostsByAddr('addr-a')
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
assert.equal(result[1].txid, 'tx3')
|
||||
})
|
||||
|
||||
it('should exclude reply posts from recent scan', async () => {
|
||||
async function * mockParents () {
|
||||
yield ['tx-reply', { parentTxid: 'tx1', childTxid: 'tx-reply', blockHeight: 600150 }]
|
||||
}
|
||||
async function * mockPosts () {
|
||||
yield ['tx1', { addr: 'addr1', text: 'top', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx-reply', { addr: 'addr1', text: 'reply', seen: 1500, blockHeight: 600150 }]
|
||||
yield ['tx2', { addr: 'addr2', text: 'other', seen: 2000, blockHeight: 600200 }]
|
||||
}
|
||||
postParentsDb.iterator.returns(mockParents())
|
||||
postsDb.iterator.returns(mockPosts())
|
||||
|
||||
const result = await uut.scanPostsWithBlockHeight()
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
assert.equal(result[1].txid, 'tx2')
|
||||
})
|
||||
|
||||
it('should exclude reply posts from address scan', async () => {
|
||||
async function * mockParents () {
|
||||
yield ['tx-reply', { parentTxid: 'tx1', childTxid: 'tx-reply', blockHeight: 600150 }]
|
||||
}
|
||||
async function * mockPosts () {
|
||||
yield ['tx1', { addr: 'addr-a', text: 'top', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx-reply', { addr: 'addr-a', text: 'reply', seen: 1500, blockHeight: 600150 }]
|
||||
}
|
||||
postParentsDb.iterator.returns(mockParents())
|
||||
postsDb.iterator.returns(mockPosts())
|
||||
|
||||
const result = await uut.scanPostsByAddr('addr-a')
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
})
|
||||
|
||||
it('should include replyCount from postChildren scan', async () => {
|
||||
async function * mockChildren () {
|
||||
yield ['tx1:reply-a', { parentTxid: 'tx1', childTxid: 'reply-a', blockHeight: 600150 }]
|
||||
yield ['tx1:reply-b', { parentTxid: 'tx1', childTxid: 'reply-b', blockHeight: 600160 }]
|
||||
yield ['tx2:reply-c', { parentTxid: 'tx2', childTxid: 'reply-c', blockHeight: 600170 }]
|
||||
}
|
||||
async function * mockPosts () {
|
||||
yield ['tx1', { addr: 'addr1', text: 'top', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx2', { addr: 'addr2', text: 'other', seen: 2000, blockHeight: 600200 }]
|
||||
}
|
||||
postChildrenDb.iterator.returns(mockChildren())
|
||||
postsDb.iterator.returns(mockPosts())
|
||||
|
||||
const result = await uut.scanPostsWithBlockHeight()
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result.find((p) => p.txid === 'tx1').replyCount, 2)
|
||||
assert.equal(result.find((p) => p.txid === 'tx2').replyCount, 1)
|
||||
})
|
||||
|
||||
it('should default replyCount to 0 when post has no replies', async () => {
|
||||
async function * mockPosts () {
|
||||
yield ['tx1', { addr: 'addr1', text: 'solo', seen: 1000, blockHeight: 600100 }]
|
||||
}
|
||||
postsDb.iterator.returns(mockPosts())
|
||||
|
||||
const result = await uut.scanPostsByAddr('addr1')
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].replyCount, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import ProfileQuery from '../../../src/adapters/profile-query.js'
|
||||
|
||||
describe('#ProfileQuery', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
let profilesDb
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
profilesDb = {
|
||||
iterator: sandbox.stub()
|
||||
}
|
||||
uut = new ProfileQuery({ profilesDb })
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should scan profiles and read block height from stored document', async () => {
|
||||
async function * mockIterator () {
|
||||
yield ['addr1', { text: 'hi', txid: 'tx1', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['addr2', { text: 'bye', txid: 'tx2', seen: 2000, blockHeight: 600200 }]
|
||||
}
|
||||
profilesDb.iterator.returns(mockIterator())
|
||||
|
||||
const result = await uut.scanProfilesWithBlockHeight()
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result[0].blockHeight, 600100)
|
||||
assert.equal(result[1].blockHeight, 600200)
|
||||
})
|
||||
|
||||
it('should use block height 0 when field is missing', async () => {
|
||||
async function * mockIterator () {
|
||||
yield ['addr1', { text: 'hi', txid: 'tx1', seen: 1000 }]
|
||||
}
|
||||
profilesDb.iterator.returns(mockIterator())
|
||||
|
||||
const result = await uut.scanProfilesWithBlockHeight()
|
||||
|
||||
assert.equal(result[0].blockHeight, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Unit tests for level REST controller.
|
||||
*/
|
||||
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import LevelRESTControllerLib from '../../../src/controllers/rest-api/level/controller.js'
|
||||
|
||||
describe('#LevelRESTController', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
const mockDb = {
|
||||
get: sinon.stub().resolves({ text: 'hello' }),
|
||||
put: sinon.stub().resolves(),
|
||||
del: sinon.stub().resolves()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new LevelRESTControllerLib({
|
||||
adapters: {
|
||||
level: { postsDb: mockDb, statusDb: mockDb },
|
||||
dbBackup: { zipDb: sandbox.stub().resolves(true) }
|
||||
},
|
||||
useCases: {}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should get status', async () => {
|
||||
const ctx = { params: { statusKey: 'status' }, body: null }
|
||||
await uut.getStatus(ctx)
|
||||
assert.deepEqual(ctx.body, { text: 'hello' })
|
||||
})
|
||||
|
||||
it('should create a post via entity handler', async () => {
|
||||
const ctx = {
|
||||
params: {},
|
||||
request: { body: { txid: 'abc', postData: { addr: '1', text: 'hi' } } },
|
||||
body: null
|
||||
}
|
||||
await uut.entityHandlers.post.create(ctx)
|
||||
assert.equal(ctx.body.success, true)
|
||||
assert.equal(ctx.body.txid, 'abc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import PostsRESTControllerLib from '../../../src/controllers/rest-api/posts/controller.js'
|
||||
|
||||
describe('#PostsRESTController', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new PostsRESTControllerLib({
|
||||
adapters: {},
|
||||
useCases: {
|
||||
listRecentPosts: {
|
||||
execute: sandbox.stub().resolves({
|
||||
posts: [{ txid: 'tx1', blockHeight: 600000 }],
|
||||
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
|
||||
})
|
||||
},
|
||||
listPostsByAddr: {
|
||||
execute: sandbox.stub().resolves({
|
||||
posts: [{ txid: 'tx2', addr: 'addr-a', blockHeight: 600100 }],
|
||||
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should return recent posts from use case', async () => {
|
||||
const ctx = { query: { limit: '50', offset: '0' }, body: null, throw: sandbox.stub() }
|
||||
await uut.getRecentPosts(ctx)
|
||||
|
||||
assert.equal(uut.useCases.listRecentPosts.execute.callCount, 1)
|
||||
assert.deepEqual(uut.useCases.listRecentPosts.execute.firstCall.args[0], {
|
||||
limit: '50',
|
||||
offset: '0'
|
||||
})
|
||||
assert.equal(ctx.body.posts.length, 1)
|
||||
assert.equal(ctx.body.pagination.total, 1)
|
||||
})
|
||||
|
||||
it('should return posts by address from use case', async () => {
|
||||
const ctx = {
|
||||
params: { addr: 'addr-a' },
|
||||
query: { limit: '25', offset: '0' },
|
||||
body: null,
|
||||
throw: sandbox.stub()
|
||||
}
|
||||
await uut.getPostsByAddr(ctx)
|
||||
|
||||
assert.equal(uut.useCases.listPostsByAddr.execute.callCount, 1)
|
||||
assert.deepEqual(uut.useCases.listPostsByAddr.execute.firstCall.args[0], {
|
||||
addr: 'addr-a',
|
||||
limit: '25',
|
||||
offset: '0'
|
||||
})
|
||||
assert.equal(ctx.body.posts.length, 1)
|
||||
assert.equal(ctx.body.posts[0].txid, 'tx2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import ProfileRESTControllerLib from '../../../src/controllers/rest-api/profile/controller.js'
|
||||
|
||||
describe('#ProfileRESTController', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new ProfileRESTControllerLib({
|
||||
adapters: {},
|
||||
useCases: {
|
||||
listRecentProfiles: {
|
||||
execute: sandbox.stub().resolves({
|
||||
profiles: [{ addr: 'q1', blockHeight: 600000 }],
|
||||
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should return recent profiles from use case', async () => {
|
||||
const ctx = { query: { limit: '50', offset: '0' }, body: null, throw: sandbox.stub() }
|
||||
await uut.getRecentProfiles(ctx)
|
||||
|
||||
assert.equal(uut.useCases.listRecentProfiles.execute.callCount, 1)
|
||||
assert.deepEqual(uut.useCases.listRecentProfiles.execute.firstCall.args[0], {
|
||||
limit: '50',
|
||||
offset: '0'
|
||||
})
|
||||
assert.equal(ctx.body.profiles.length, 1)
|
||||
assert.equal(ctx.body.pagination.total, 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Unit tests for bin/server.js
|
||||
*/
|
||||
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import Server from '../../../bin/server.js'
|
||||
|
||||
describe('#server', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new Server()
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
describe('#startServer', () => {
|
||||
it('should start the server', async () => {
|
||||
sandbox.stub(uut.controllers, 'initAdapters').resolves()
|
||||
sandbox.stub(uut.controllers, 'initUseCases').resolves()
|
||||
sandbox.stub(uut.controllers, 'attachRESTControllers').resolves()
|
||||
sandbox.stub(uut.controllers, 'attachControllers').resolves()
|
||||
uut.config.env = 'dev'
|
||||
uut.config.port = 5041
|
||||
|
||||
const result = await uut.startServer()
|
||||
|
||||
assert.property(result, 'env')
|
||||
uut.server.close()
|
||||
uut.config.env = 'test'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import ListPostsByAddr from '../../../src/use-cases/list-posts-by-addr.js'
|
||||
|
||||
describe('#ListPostsByAddr', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
|
||||
const mockPosts = [
|
||||
{ txid: 'tx-a', addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
|
||||
{ txid: 'tx-b', addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
{ txid: 'tx-c', addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600200 }
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new ListPostsByAddr({
|
||||
adapters: {
|
||||
postQuery: {
|
||||
scanPostsByAddr: sandbox.stub().callsFake(async (addr) => {
|
||||
return mockPosts.filter((post) => post.addr === addr)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should return posts for an address sorted by block height descending', async () => {
|
||||
const result = await uut.execute({ addr: 'addr-a', limit: 10, offset: 0 })
|
||||
|
||||
assert.equal(result.posts.length, 2)
|
||||
assert.equal(result.posts[0].txid, 'tx-c')
|
||||
assert.equal(result.posts[1].txid, 'tx-a')
|
||||
assert.equal(result.pagination.total, 2)
|
||||
})
|
||||
|
||||
it('should reject missing addr', async () => {
|
||||
try {
|
||||
await uut.execute({ limit: 10 })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'addr is required')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import ListRecentPosts from '../../../src/use-cases/list-recent-posts.js'
|
||||
|
||||
describe('#ListRecentPosts', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
|
||||
const mockPosts = [
|
||||
{ txid: 'tx-a', addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
|
||||
{ txid: 'tx-b', addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
{ txid: 'tx-c', addr: 'addr-c', text: 'c', seen: 50, blockHeight: 600200 }
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new ListRecentPosts({
|
||||
adapters: {
|
||||
postQuery: {
|
||||
scanPostsWithBlockHeight: sandbox.stub().resolves([...mockPosts])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should return posts sorted by block height descending', async () => {
|
||||
const result = await uut.execute({ limit: 10, offset: 0 })
|
||||
|
||||
assert.equal(result.posts.length, 3)
|
||||
assert.equal(result.posts[0].txid, 'tx-b')
|
||||
assert.equal(result.posts[1].txid, 'tx-c')
|
||||
assert.equal(result.posts[2].txid, 'tx-a')
|
||||
assert.equal(result.pagination.total, 3)
|
||||
assert.equal(result.pagination.hasMore, false)
|
||||
})
|
||||
|
||||
it('should paginate with limit and offset', async () => {
|
||||
const result = await uut.execute({ limit: 1, offset: 1 })
|
||||
|
||||
assert.equal(result.posts.length, 1)
|
||||
assert.equal(result.posts[0].txid, 'tx-c')
|
||||
assert.equal(result.pagination.limit, 1)
|
||||
assert.equal(result.pagination.offset, 1)
|
||||
assert.equal(result.pagination.hasMore, true)
|
||||
})
|
||||
|
||||
it('should default limit to 100 and offset to 0', async () => {
|
||||
const result = await uut.execute({})
|
||||
|
||||
assert.equal(result.pagination.limit, 100)
|
||||
assert.equal(result.pagination.offset, 0)
|
||||
})
|
||||
|
||||
it('should reject limit over 100', async () => {
|
||||
try {
|
||||
await uut.execute({ limit: 101 })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'limit cannot exceed')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import ListRecentProfiles from '../../../src/use-cases/list-recent-profiles.js'
|
||||
|
||||
describe('#ListRecentProfiles', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
|
||||
const mockProfiles = [
|
||||
{ addr: 'addr-a', text: 'a', txid: 'tx-a', seen: 100, blockHeight: 600100 },
|
||||
{ addr: 'addr-b', text: 'b', txid: 'tx-b', seen: 200, blockHeight: 600200 },
|
||||
{ addr: 'addr-c', text: 'c', txid: 'tx-c', seen: 50, blockHeight: 600200 }
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new ListRecentProfiles({
|
||||
adapters: {
|
||||
profileQuery: {
|
||||
scanProfilesWithBlockHeight: sandbox.stub().resolves([...mockProfiles])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should return profiles sorted by block height descending', async () => {
|
||||
const result = await uut.execute({ limit: 10, offset: 0 })
|
||||
|
||||
assert.equal(result.profiles.length, 3)
|
||||
assert.equal(result.profiles[0].addr, 'addr-b')
|
||||
assert.equal(result.profiles[1].addr, 'addr-c')
|
||||
assert.equal(result.profiles[2].addr, 'addr-a')
|
||||
assert.equal(result.pagination.total, 3)
|
||||
assert.equal(result.pagination.hasMore, false)
|
||||
})
|
||||
|
||||
it('should paginate with limit and offset', async () => {
|
||||
const result = await uut.execute({ limit: 1, offset: 1 })
|
||||
|
||||
assert.equal(result.profiles.length, 1)
|
||||
assert.equal(result.profiles[0].addr, 'addr-c')
|
||||
assert.equal(result.pagination.limit, 1)
|
||||
assert.equal(result.pagination.offset, 1)
|
||||
assert.equal(result.pagination.hasMore, true)
|
||||
})
|
||||
|
||||
it('should default limit to 100 and offset to 0', async () => {
|
||||
const result = await uut.execute({})
|
||||
|
||||
assert.equal(result.pagination.limit, 100)
|
||||
assert.equal(result.pagination.offset, 0)
|
||||
})
|
||||
|
||||
it('should reject limit over 100', async () => {
|
||||
try {
|
||||
await uut.execute({ limit: 101 })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'limit cannot exceed')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all follows from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/follow/get_follows.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const followsDb = level(`${__dirname}/../../leveldb/current/follows`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getFollows () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [key, follow] of followsDb.iterator()) {
|
||||
count++
|
||||
console.log(`${key} = ${JSON.stringify(follow, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal follows: ${count}`)
|
||||
await followsDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading follows:', err.message)
|
||||
try {
|
||||
await followsDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getFollows()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all likes from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/like/get_likes.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const likesDb = level(`${__dirname}/../../leveldb/current/likes`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getLikes () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [txid, like] of likesDb.iterator()) {
|
||||
count++
|
||||
console.log(`${txid} = ${JSON.stringify(like, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal likes: ${count}`)
|
||||
await likesDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading likes:', err.message)
|
||||
try {
|
||||
await likesDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getLikes()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all names from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/name/get_names.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const namesDb = level(`${__dirname}/../../leveldb/current/names`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getNames () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [addr, name] of namesDb.iterator()) {
|
||||
count++
|
||||
console.log(`${addr} = ${JSON.stringify(name, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal names: ${count}`)
|
||||
await namesDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading names:', err.message)
|
||||
try {
|
||||
await namesDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getNames()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all posts from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/post/get_posts.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const postsDb = level(`${__dirname}/../../leveldb/current/posts`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getPosts () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [txid, post] of postsDb.iterator()) {
|
||||
count++
|
||||
console.log(`${txid} = ${JSON.stringify(post, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal posts: ${count}`)
|
||||
await postsDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading posts:', err.message)
|
||||
try {
|
||||
await postsDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getPosts()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all postChildren from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/postchild/get_post_children.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const postChildrenDb = level(`${__dirname}/../../leveldb/current/postChildren`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getPostChildren () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [key, child] of postChildrenDb.iterator()) {
|
||||
count++
|
||||
console.log(`${key} = ${JSON.stringify(child, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal post children: ${count}`)
|
||||
await postChildrenDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading postChildren:', err.message)
|
||||
try {
|
||||
await postChildrenDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getPostChildren()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all postParents from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/postparent/get_post_parents.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const postParentsDb = level(`${__dirname}/../../leveldb/current/postParents`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getPostParents () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [txid, parent] of postParentsDb.iterator()) {
|
||||
count++
|
||||
console.log(`${txid} = ${JSON.stringify(parent, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal post parents: ${count}`)
|
||||
await postParentsDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading postParents:', err.message)
|
||||
try {
|
||||
await postParentsDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getPostParents()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all processErrors from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/processErrors/get_process_errors.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const processErrorsDb = level(`${__dirname}/../../leveldb/current/processErrors`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getProcessErrors () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [txid, errorData] of processErrorsDb.iterator()) {
|
||||
count++
|
||||
console.log(`${txid} = ${JSON.stringify(errorData, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal process errors: ${count}`)
|
||||
await processErrorsDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading processErrors:', err.message)
|
||||
try {
|
||||
await processErrorsDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getProcessErrors()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all profilePics from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/profilepic/get_profile_pics.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const profilePicsDb = level(`${__dirname}/../../leveldb/current/profilePics`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getProfilePics () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [addr, profilePic] of profilePicsDb.iterator()) {
|
||||
count++
|
||||
console.log(`${addr} = ${JSON.stringify(profilePic, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal profile pics: ${count}`)
|
||||
await profilePicsDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading profilePics:', err.message)
|
||||
try {
|
||||
await profilePicsDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getProfilePics()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all profiles from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/profiles/get_profiles.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const profilesDb = level(`${__dirname}/../../leveldb/current/profiles`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getProfiles () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [addr, profile] of profilesDb.iterator()) {
|
||||
count++
|
||||
console.log(`${addr} = ${JSON.stringify(profile, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal profiles: ${count}`)
|
||||
await profilesDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading profiles:', err.message)
|
||||
try {
|
||||
await profilesDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getProfiles()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all ptxs (processed transactions) from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/ptx/get_ptxs.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const ptxsDb = level(`${__dirname}/../../leveldb/current/ptxs`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getPtxs () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [txid, ptx] of ptxsDb.iterator()) {
|
||||
count++
|
||||
console.log(`${txid} = ${JSON.stringify(ptx, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal ptxs: ${count}`)
|
||||
await ptxsDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading ptxs:', err.message)
|
||||
try {
|
||||
await ptxsDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getPtxs()
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Utility: read all rooms from the Memo indexer LevelDB and print to the terminal.
|
||||
|
||||
Run from the psf-memo-db repo root:
|
||||
node util/room/get_rooms.js
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as url from 'url'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const roomsDb = level(`${__dirname}/../../leveldb/current/rooms`, {
|
||||
valueEncoding: 'json'
|
||||
})
|
||||
|
||||
async function getRooms () {
|
||||
try {
|
||||
let count = 0
|
||||
|
||||
for await (const [key, room] of roomsDb.iterator()) {
|
||||
count++
|
||||
console.log(`${key} = ${JSON.stringify(room, null, 2)}`)
|
||||
}
|
||||
|
||||
console.log(`\nTotal rooms: ${count}`)
|
||||
await roomsDb.close()
|
||||
} catch (err) {
|
||||
console.error('Error reading rooms:', err.message)
|
||||
try {
|
||||
await roomsDb.close()
|
||||
} catch (closeErr) {
|
||||
// ignore close errors after read failure
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
getRooms()
|
||||
Reference in New Issue
Block a user