run pouchdb on a web worker.

This commit is contained in:
fiatjaf 2022-01-03 20:34:07 -03:00
parent 171e8b3530
commit ff232bb017
6 changed files with 476 additions and 351 deletions

View File

@ -8,8 +8,7 @@
"scripts": {
"lint": "eslint --ext .js,.vue ./",
"dev": "quasar dev --port 3001",
"build": "quasar build",
"publish": "quasar build && netlify deploy --dir=dist/spa/ --prod"
"publish": "rm -r dist/spa && quasar build && netlify deploy --dir=dist/spa/ --prod"
},
"dependencies": {
"@quasar/extras": "^1.0.0",

View File

@ -4,7 +4,6 @@
<q-form @submit="sendPost">
<q-input
v-model="text"
dense
autogrow
autofocus
label="Say something"

View File

@ -2,7 +2,6 @@
<q-form class="px-24" @submit="sendReply">
<q-input
v-model="text"
dense
autogrow
autofocus
label="Reply to this note"

387
src/db.js
View File

@ -1,381 +1,96 @@
/* global emit */
const worker = new Worker(new URL('./worker-db.js', import.meta.url))
import PouchDB from 'pouchdb-core'
import PouchDBUpsert from 'pouchdb-upsert'
import PouchDBMapReduce from 'pouchdb-mapreduce'
import PouchDBAdapterIDB from 'pouchdb-adapter-idb'
const hub = {}
PouchDB.plugin(PouchDBAdapterIDB).plugin(PouchDBMapReduce).plugin(PouchDBUpsert)
worker.onmessage = ev => {
let {id, success, error, data, stream} = JSON.parse(ev.data)
// instantiate db (every doc will be an event, that's it)
// ~
export const db = new PouchDB('nostr-events', {
auto_compaction: true,
revs_limit: 1
})
window.db = db
if (stream) {
console.log('db ~>>', id, data)
hub[id](data)
return
}
// db schema (views)
// ~
const DESIGN_VERSION = 3
db.upsert('_design/main', current => {
if (current && current.version >= DESIGN_VERSION) return false
if (!success) {
hub[id].reject(new Error(error))
delete hub[id]
return
}
return {
version: DESIGN_VERSION,
views: {
profiles: {
map: function (event) {
if (event.kind === 0) {
emit(event.pubkey)
}
}.toString()
},
homefeed: {
map: function (event) {
if (event.kind === 1) {
emit(event.created_at)
}
}.toString()
},
mentions: {
map: function (event) {
if (event.kind === 1) {
for (var i = 0; i < event.tags.length; i++) {
var tag = event.tags[i]
if (tag[0] === 'p') emit([tag[1], event.created_at])
if (tag[0] === 'e') emit([tag[1], event.created_at])
}
}
}.toString()
},
messages: {
map: function (event) {
if (event.kind === 4) {
for (var i = 0; i < event.tags.length; i++) {
var tag = event.tags[i]
if (tag[0] === 'p') {
emit([tag[1], event.created_at])
break
}
}
emit([event.pubkey, event.created_at])
}
}.toString()
},
contactlists: {
map: function (event) {
if (event.kind === 3) {
emit(event.pubkey)
}
}.toString()
},
followers: {
map: function (event) {
if (event.kind === 3) {
for (let i = 0; i < event.tags.length; i++) {
var tag = event.tags[i]
if (tag.length >= 2 && tag[0] === 'p') {
emit(tag[1], event.pubkey)
}
}
}
}.toString()
},
petnames: {
map: function (event) {
if (event.kind === 3) {
for (let i = 0; i < event.tags.length; i++) {
var tag = event.tags[i]
if (tag.length >= 4 && tag[0] === 'p') {
emit(tag[1], [event.pubkey, tag[3]])
}
}
}
}.toString()
}
}
}
}).then(() => {
// cleanup old views after a design doc change
db.viewCleanup().then(r => console.log('view cleanup done', r))
})
console.log('db ->', id, data)
hub[id].resolve(data)
delete hub[id]
}
// delete old events after the first 1000 (this is slow, so do it after a while)
//
setTimeout(async () => {
let result = await db.query('main/homefeed', {
descending: true,
skip: 1000,
include_docs: true
function call(name, args) {
let id = name + ' ' + Math.random().toString().slice(-4)
console.log('db <-', id, name, args)
worker.postMessage(JSON.stringify({id, name, args}))
return new Promise((resolve, reject) => {
hub[id] = {resolve, reject}
})
result.rows.forEach(row => db.remove(row.doc))
}, 1000 * 60 * 15 /* 15 minutes */)
}
// general function for saving an event, with granular logic for each kind
//
export async function dbSave(event) {
switch (event.kind) {
case 0: {
// first check if we don't have a newer metadata for this user
let current = await dbGetProfile(event.pubkey)
if (current && current.created_at >= event.created_at) {
// don't save
return
}
break
}
case 1:
break
case 2:
break
case 3: {
// first check if we don't have a newer contact list for this user
let current = await dbGetContactList(event.pubkey)
if (current && current.created_at >= event.created_at) {
// don't save
return
}
break
}
case 4: {
// cleanup extra fields if somehow they manage to get in here (they shouldn't)
delete event.appended
delete event.plaintext
break
}
}
event._id = event.id
try {
await db.put(event)
} catch (err) {
if (err.name !== 'conflict') {
console.error('unexpected error saving event', event, err)
function stream(name, args, callback) {
let id = name + ' ' + Math.random().toString().slice(-4)
console.log('db <-', id, args)
worker.postMessage(JSON.stringify({id, name, args, stream: true}))
return {
cancel() {
worker.postMessage(JSON.stringify({id, cancel: true}))
}
}
}
// db queries
// ~
export async function eraseDatabase() {
return call('eraseDatabase', [])
}
export async function dbSave(event) {
return call('dbSave', [event])
}
export async function dbGetHomeFeedNotes(
limit = 50,
since = Math.round(Date.now() / 1000)
) {
let result = await db.query('main/homefeed', {
include_docs: true,
descending: true,
limit,
startkey: since
})
return result.rows.map(r => r.doc)
return call('dbGetHomeFeedNotes', [limit, since])
}
export function onNewHomeFeedNote(callback = () => {}) {
// listen for changes
let changes = db.changes({
live: true,
since: 'now',
include_docs: true,
filter: '_view',
view: 'main/homefeed'
})
changes.on('change', change => callback(change.doc))
return changes
return stream('onNewHomeFeedNote', [], callback)
}
export async function dbGetChats(ourPubKey) {
let result = await db.query('main/messages')
let chats = result.rows
.map(r => r.key)
.reduce((acc, [peer, date]) => {
acc[peer] = acc[peer] || 0
if (date > acc[peer]) acc[peer] = date
return acc
}, {})
delete chats[ourPubKey]
return Object.entries(chats)
.sort((a, b) => b[1] - a[1])
.map(([peer, lastMessage]) => ({peer, lastMessage}))
return call('dbGetChats', [ourPubKey])
}
export async function dbGetMessages(
peerPubKey,
limit = 50,
since = Math.round(Date.now() / 1000)
) {
let result = await db.query('main/messages', {
include_docs: true,
descending: true,
startkey: [peerPubKey, since],
endkey: [peerPubKey, 0],
limit
})
return result.rows
.map(r => r.doc)
.reverse()
.reduce((acc, event) => {
if (!acc.length) return [event]
let last = acc[acc.length - 1]
if (
last.pubkey === event.pubkey &&
last.created_at + 120 >= event.created_at
) {
last.appended = last.appended || []
last.appended.push(event)
} else {
acc.push(event)
}
return acc
}, [])
return call('dbGetMessages', [peerPubKey, limit, since])
}
export function onNewMessage(peerPubKey, callback = () => {}) {
// listen for changes
let changes = db.changes({
live: true,
since: 'now',
include_docs: true,
filter: '_view',
view: 'main/messages'
})
changes.on('change', change => {
if (
change.doc.pubkey === peerPubKey ||
change.doc.tags.find(([t, v]) => t === 'p' && v === peerPubKey)
) {
callback(change.doc)
}
})
return changes
return stream('onNewMessage', [], callback)
}
export async function dbGetEvent(id) {
try {
return await db.get(id)
} catch (err) {
if (err.name === 'not_found') return null
else throw err
}
return call('dbGetEvent', [id])
}
export async function dbGetMentions(ourPubKey, limit = 40, since, until) {
let result = await db.query('main/mentions', {
include_docs: true,
descending: true,
startkey: [ourPubKey, until],
endkey: [ourPubKey, since],
limit
})
return result.rows.map(r => r.doc)
return call('dbGetMentions', [ourPubKey, limit, since, until])
}
export function onNewMention(ourPubKey, callback = () => {}) {
// listen for changes
let changes = db.changes({
live: true,
since: 'now',
include_docs: true,
filter: '_view',
view: 'main/mentions'
})
changes.on('change', change => {
if (change.doc.tags.find(([t, v]) => t === 'p' && v === ourPubKey)) {
callback(change.doc)
}
})
return changes
return stream('onNewMention', [ourPubKey], callback)
}
export function onNewAnyMessage(callback = () => {}) {
// listen for changes
let changes = db.changes({
live: true,
since: 'now',
include_docs: true,
filter: '_view',
view: 'main/messages'
})
changes.on('change', change => {
callback(change.doc)
})
return changes
return stream('onNewAnyMessage', [], callback)
}
export async function dbGetUnreadNotificationsCount(ourPubKey, since) {
let result = await db.query('main/mentions', {
include_docs: false,
descending: true,
startkey: [ourPubKey, {}],
endkey: [ourPubKey, since]
})
return result.rows.length
return call('dbGetUnreadNotificationsCount', [ourPubKey, since])
}
export async function dbGetUnreadMessages(pubkey, since) {
let result = await db.query('main/messages', {
include_docs: false,
descending: true,
startkey: [pubkey, {}],
endkey: [pubkey, since]
})
return result.rows.length
return call('dbGetUnreadMessages', [pubkey, since])
}
export async function dbGetProfile(pubkey) {
let result = await db.query('main/profiles', {
include_docs: true,
key: pubkey
})
switch (result.rows.length) {
case 0:
return null
case 1:
return result.rows[0].doc
default: {
let sorted = result.rows.sort(
(a, b) => (b.doc?.created_at || 0) - (a.doc?.created_at || 0)
)
sorted
.slice(1)
.filter(row => row.doc)
.forEach(row => db.remove(row.doc))
return sorted[0].doc
}
}
return call('dbGetProfile', [pubkey])
}
export async function dbGetContactList(pubkey) {
let result = await db.query('main/contactlists', {
include_docs: true,
key: pubkey
})
switch (result.rows.length) {
case 0:
return null
case 1:
return result.rows[0].doc
default: {
let sorted = result.rows.sort(
(a, b) => (b.doc?.created_at || 0) - (a.doc?.created_at || 0)
)
sorted
.slice(1)
.filter(row => row.doc)
.forEach(row => db.remove(row.doc))
return sorted[0].doc
}
}
return call('dbGetContactList', [pubkey])
}

View File

@ -35,7 +35,7 @@
<q-separator />
<div class="my-8">
<div class="text-lg p-4">Relays</div>
<q-list class="mb-3" dense>
<q-list class="mb-3">
<q-item v-for="(opts, url) in $store.state.relays" :key="url">
<q-item-section class="text-slate-800">
<div class="flex-inline">
@ -71,13 +71,7 @@
</q-item>
</q-list>
<q-form @submit="addRelay">
<q-input
v-model="addingRelay"
class="mx-3"
filled
dense
label="Add a relay"
>
<q-input v-model="addingRelay" class="mx-3" filled label="Add a relay">
<template #append>
<q-btn
label="Add"
@ -150,7 +144,7 @@ import {LocalStorage} from 'quasar'
import {nextTick} from 'vue'
import helpersMixin from '../utils/mixin'
import {db} from '../db'
import {eraseDatabase} from '../db'
export default {
name: 'Settings',
@ -239,7 +233,7 @@ export default {
})
.onOk(async () => {
LocalStorage.clear()
await db.destroy()
await eraseDatabase()
window.location.reload()
})
}

419
src/worker-db.js Normal file
View File

@ -0,0 +1,419 @@
/* global emit */
import PouchDB from 'pouchdb-core'
import PouchDBUpsert from 'pouchdb-upsert'
import PouchDBMapReduce from 'pouchdb-mapreduce'
import PouchDBAdapterIDB from 'pouchdb-adapter-idb'
PouchDB.plugin(PouchDBAdapterIDB).plugin(PouchDBMapReduce).plugin(PouchDBUpsert)
// instantiate db (every doc will be an event, that's it)
// ~
const db = new PouchDB('nostr-events', {
auto_compaction: true,
revs_limit: 1
})
// db schema (views)
// ~
const DESIGN_VERSION = 3
db.upsert('_design/main', current => {
if (current && current.version >= DESIGN_VERSION) return false
return {
version: DESIGN_VERSION,
views: {
profiles: {
map: function (event) {
if (event.kind === 0) {
emit(event.pubkey)
}
}.toString()
},
homefeed: {
map: function (event) {
if (event.kind === 1) {
emit(event.created_at)
}
}.toString()
},
mentions: {
map: function (event) {
if (event.kind === 1) {
for (var i = 0; i < event.tags.length; i++) {
var tag = event.tags[i]
if (tag[0] === 'p') emit([tag[1], event.created_at])
if (tag[0] === 'e') emit([tag[1], event.created_at])
}
}
}.toString()
},
messages: {
map: function (event) {
if (event.kind === 4) {
for (var i = 0; i < event.tags.length; i++) {
var tag = event.tags[i]
if (tag[0] === 'p') {
emit([tag[1], event.created_at])
break
}
}
emit([event.pubkey, event.created_at])
}
}.toString()
},
contactlists: {
map: function (event) {
if (event.kind === 3) {
emit(event.pubkey)
}
}.toString()
},
followers: {
map: function (event) {
if (event.kind === 3) {
for (let i = 0; i < event.tags.length; i++) {
var tag = event.tags[i]
if (tag.length >= 2 && tag[0] === 'p') {
emit(tag[1], event.pubkey)
}
}
}
}.toString()
},
petnames: {
map: function (event) {
if (event.kind === 3) {
for (let i = 0; i < event.tags.length; i++) {
var tag = event.tags[i]
if (tag.length >= 4 && tag[0] === 'p') {
emit(tag[1], [event.pubkey, tag[3]])
}
}
}
}.toString()
}
}
}
}).then(() => {
// cleanup old views after a design doc change
db.viewCleanup().then(r => console.log('view cleanup done', r))
})
// delete old events after the first 1000 (this is slow, so do it after a while)
//
setTimeout(async () => {
let result = await db.query('main/homefeed', {
descending: true,
skip: 1000,
include_docs: true
})
result.rows.forEach(row => db.remove(row.doc))
}, 1000 * 60 * 15 /* 15 minutes */)
const methods = {
// delete everything
//
async eraseDatabase() {
return await db.destroy()
},
// general function for saving an event, with granular logic for each kind
//
async dbSave(event) {
switch (event.kind) {
case 0: {
// first check if we don't have a newer metadata for this user
let current = await methods.dbGetProfile(event.pubkey)
if (current && current.created_at >= event.created_at) {
// don't save
return
}
break
}
case 1:
break
case 2:
break
case 3: {
// first check if we don't have a newer contact list for this user
let current = await methods.dbGetContactList(event.pubkey)
if (current && current.created_at >= event.created_at) {
// don't save
return
}
break
}
case 4: {
// cleanup extra fields if somehow they manage to get in here (they shouldn't)
delete event.appended
delete event.plaintext
break
}
}
event._id = event.id
try {
await db.put(event)
} catch (err) {
if (err.name !== 'conflict') {
console.error('unexpected error saving event', event, err)
}
}
},
// db queries
// ~
async dbGetHomeFeedNotes(limit = 50, since = Math.round(Date.now() / 1000)) {
let result = await db.query('main/homefeed', {
include_docs: true,
descending: true,
limit,
startkey: since
})
return result.rows.map(r => r.doc)
},
onNewHomeFeedNote(callback = () => {}) {
// listen for changes
let changes = db.changes({
live: true,
since: 'now',
include_docs: true,
filter: '_view',
view: 'main/homefeed'
})
changes.on('change', change => callback(change.doc))
return changes
},
async dbGetChats(ourPubKey) {
let result = await db.query('main/messages')
let chats = result.rows
.map(r => r.key)
.reduce((acc, [peer, date]) => {
acc[peer] = acc[peer] || 0
if (date > acc[peer]) acc[peer] = date
return acc
}, {})
delete chats[ourPubKey]
return Object.entries(chats)
.sort((a, b) => b[1] - a[1])
.map(([peer, lastMessage]) => ({peer, lastMessage}))
},
async dbGetMessages(
peerPubKey,
limit = 50,
since = Math.round(Date.now() / 1000)
) {
let result = await db.query('main/messages', {
include_docs: true,
descending: true,
startkey: [peerPubKey, since],
endkey: [peerPubKey, 0],
limit
})
return result.rows
.map(r => r.doc)
.reverse()
.reduce((acc, event) => {
if (!acc.length) return [event]
let last = acc[acc.length - 1]
if (
last.pubkey === event.pubkey &&
last.created_at + 120 >= event.created_at
) {
last.appended = last.appended || []
last.appended.push(event)
} else {
acc.push(event)
}
return acc
}, [])
},
onNewMessage(peerPubKey, callback = () => {}) {
// listen for changes
let changes = db.changes({
live: true,
since: 'now',
include_docs: true,
filter: '_view',
view: 'main/messages'
})
changes.on('change', change => {
if (
change.doc.pubkey === peerPubKey ||
change.doc.tags.find(([t, v]) => t === 'p' && v === peerPubKey)
) {
callback(change.doc)
}
})
return changes
},
async dbGetEvent(id) {
try {
return await db.get(id)
} catch (err) {
if (err.name === 'not_found') return null
else throw err
}
},
async dbGetMentions(ourPubKey, limit = 40, since, until) {
let result = await db.query('main/mentions', {
include_docs: true,
descending: true,
startkey: [ourPubKey, until],
endkey: [ourPubKey, since],
limit
})
return result.rows.map(r => r.doc)
},
onNewMention(ourPubKey, callback = () => {}) {
// listen for changes
let changes = db.changes({
live: true,
since: 'now',
include_docs: true,
filter: '_view',
view: 'main/mentions'
})
changes.on('change', change => {
if (change.doc.tags.find(([t, v]) => t === 'p' && v === ourPubKey)) {
callback(change.doc)
}
})
return changes
},
onNewAnyMessage(callback = () => {}) {
// listen for changes
let changes = db.changes({
live: true,
since: 'now',
include_docs: true,
filter: '_view',
view: 'main/messages'
})
changes.on('change', change => {
callback(change.doc)
})
return changes
},
async dbGetUnreadNotificationsCount(ourPubKey, since) {
let result = await db.query('main/mentions', {
include_docs: false,
descending: true,
startkey: [ourPubKey, {}],
endkey: [ourPubKey, since]
})
return result.rows.length
},
async dbGetUnreadMessages(pubkey, since) {
let result = await db.query('main/messages', {
include_docs: false,
descending: true,
startkey: [pubkey, {}],
endkey: [pubkey, since]
})
return result.rows.length
},
async dbGetProfile(pubkey) {
let result = await db.query('main/profiles', {
include_docs: true,
key: pubkey
})
switch (result.rows.length) {
case 0:
return null
case 1:
return result.rows[0].doc
default: {
let sorted = result.rows.sort(
(a, b) => (b.doc?.created_at || 0) - (a.doc?.created_at || 0)
)
sorted
.slice(1)
.filter(row => row.doc)
.forEach(row => db.remove(row.doc))
return sorted[0].doc
}
}
},
async dbGetContactList(pubkey) {
let result = await db.query('main/contactlists', {
include_docs: true,
key: pubkey
})
switch (result.rows.length) {
case 0:
return null
case 1:
return result.rows[0].doc
default: {
let sorted = result.rows.sort(
(a, b) => (b.doc?.created_at || 0) - (a.doc?.created_at || 0)
)
sorted
.slice(1)
.filter(row => row.doc)
.forEach(row => db.remove(row.doc))
return sorted[0].doc
}
}
}
}
var streams = {}
self.onmessage = async function (ev) {
let {name, args, id, stream, cancel} = JSON.parse(ev.data)
if (stream) {
let changes = methods[name](...args, data => {
self.postMessage(
JSON.stringify({
id,
data,
stream: true
})
)
})
streams[id] = changes
} else if (cancel) {
streams[id].cancel()
delete streams[id]
} else {
var reply = {id}
try {
let data = await methods[name](...args)
reply.success = true
reply.data = data
} catch (err) {
reply.success = false
reply.error = err.message
}
self.postMessage(JSON.stringify(reply))
}
}