Switch to lokijs

This commit is contained in:
Jonathan Staab 2023-04-10 16:11:15 -05:00
parent 08d6195880
commit f2a76b319f
47 changed files with 323 additions and 630 deletions

View File

@ -1,9 +1,8 @@
# Current
- [ ] Refactor
- Split out Note pieces
- Move global modals to child components?
- Consolidate person search/list, other person components
- Consolidate relay components
- [ ] Relays bounty
- [ ] Ability to create custom feeds

71
package-lock.json generated
View File

@ -18,8 +18,7 @@
"fuse.js": "^6.6.2",
"hurdak": "github:ConsignCloud/hurdak",
"husky": "^8.0.3",
"localforage": "^1.10.0",
"localforage-memoryStorageDriver": "^0.9.2",
"lokijs": "^1.5.12",
"lru-cache": "^7.18.3",
"nostr-tools": "^1.7.4",
"npm-run-all": "^4.1.5",
@ -4921,11 +4920,6 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -5492,14 +5486,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz",
@ -5531,22 +5517,6 @@
"node": ">=4"
}
},
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/localforage-memoryStorageDriver": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/localforage-memoryStorageDriver/-/localforage-memoryStorageDriver-0.9.2.tgz",
"integrity": "sha512-DRB4BkkW9o5HIetbsuvtcg98GP7J1JBRDyDMJK13hfr9QsNpnMW6UUWmU9c6bcRg99akR1mGZ/ubUV1Ek0fbpg==",
"dependencies": {
"localforage": ">=1.4.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -5582,6 +5552,11 @@
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="
},
"node_modules/lokijs": {
"version": "1.5.12",
"resolved": "https://registry.npmjs.org/lokijs/-/lokijs-1.5.12.tgz",
"integrity": "sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q=="
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@ -12097,11 +12072,6 @@
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
"dev": true
},
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -12483,14 +12453,6 @@
"type-check": "~0.4.0"
}
},
"lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"requires": {
"immediate": "~3.0.5"
}
},
"lilconfig": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz",
@ -12515,22 +12477,6 @@
}
}
},
"localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"requires": {
"lie": "3.1.1"
}
},
"localforage-memoryStorageDriver": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/localforage-memoryStorageDriver/-/localforage-memoryStorageDriver-0.9.2.tgz",
"integrity": "sha512-DRB4BkkW9o5HIetbsuvtcg98GP7J1JBRDyDMJK13hfr9QsNpnMW6UUWmU9c6bcRg99akR1mGZ/ubUV1Ek0fbpg==",
"requires": {
"localforage": ">=1.4.0"
}
},
"locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -12560,6 +12506,11 @@
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="
},
"lokijs": {
"version": "1.5.12",
"resolved": "https://registry.npmjs.org/lokijs/-/lokijs-1.5.12.tgz",
"integrity": "sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q=="
},
"lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",

View File

@ -41,8 +41,7 @@
"fuse.js": "^6.6.2",
"hurdak": "github:ConsignCloud/hurdak",
"husky": "^8.0.3",
"localforage": "^1.10.0",
"localforage-memoryStorageDriver": "^0.9.2",
"lokijs": "^1.5.12",
"lru-cache": "^7.18.3",
"nostr-tools": "^1.7.4",
"npm-run-all": "^4.1.5",

View File

@ -4,7 +4,7 @@ import {doPipe} from "hurdak/lib/hurdak"
import {parseContent} from "src/util/html"
import {Tags, roomAttrs, displayPerson, findReplyId, findRootId} from "src/util/nostr"
import {getRelayForPersonHint} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
import pool from "src/agent/pool"
import sync from "src/agent/sync"
import keys from "src/agent/keys"

200
src/agent/db.ts Normal file
View File

@ -0,0 +1,200 @@
import type {Writable} from "svelte/store"
import Loki from "lokijs"
import IncrementalIndexedAdapter from "lokijs/src/incremental-indexeddb-adapter"
import {partition, sortBy, prop, pluck, without, is} from "ramda"
import {throttle} from "throttle-debounce"
import {writable} from "svelte/store"
import {ensurePlural, createMap} from "hurdak/lib/hurdak"
import {log} from "src/util/logger"
import {Tags} from "src/util/nostr"
import user from "src/agent/user"
const loki = new Loki("agent.db", {
autoload: true,
autosave: true,
adapter: window.indexedDB ? new IncrementalIndexedAdapter() : new Loki.LokiMemoryAdapter(),
autoloadCallback: () => ready.set(true),
})
// ----------------------------------------------------------------------------
// Database table abstraction around loki
const registry = {} as Record<string, Table>
class Table {
name: string
pk: string
_max: number
_sort: (xs: Array<Record<string, any>>) => Array<Record<string, any>>
_coll: Loki
constructor(name, pk, {max = 500, sort = null} = {}) {
this.name = name
this.pk = pk
this._max = max
this._sort = sort
this._coll = loki.addCollection(name, {unique: [pk]})
registry[name] = this
}
subscribe(cb) {
const keys = ["insert", "update"]
this._coll.addListener(keys, cb)
cb(this)
return () => this._coll.removeListener(keys, cb)
}
patch(items) {
const [updates, creates] = partition(item => this.get(item[this.pk]), ensurePlural(items))
if (creates.length > 0) {
this._coll.insert(creates)
}
if (updates.length > 0) {
const updatesByPk = createMap(this.pk, updates)
this._coll.updateWhere(
item => Boolean(updatesByPk[item[this.pk]]),
item => ({...item, ...updatesByPk[item[this.pk]]})
)
}
}
remove(ks) {
this._coll.chain().removeWhere({[this.pk]: {$in: ks}})
}
prune() {
if (this._coll.count() < this._max * 1.1) {
return
}
let data = this.all()
if (this._sort) {
data = this._sort(data)
}
const pks = pluck(this.pk, data.slice(this._max))
this._coll.findAndRemove({[this.pk]: {$in: pks}})
}
drop() {
this._coll.clear({removeIndices: true})
}
all(spec = null) {
return this._coll.find(spec)
}
find(spec = null) {
return this._coll.findOne(spec)
}
get(k) {
return this._coll.by(this.pk, k)
}
}
const listener = (() => {
let listeners = []
return {
connect: () => {
for (const table of Object.values(registry) as Array<Table>) {
table.subscribe(() => listeners.forEach(f => f(table.name)))
}
},
subscribe: f => {
listeners.push(f)
return () => {
listeners = without([f], listeners)
}
},
}
})()
// Periodically prune data. One table at a time to avoid interfering with the UI
setInterval(() => {
const tables = Object.values(registry)
const table = tables[Math.floor(tables.length * Math.random())]
table.prune()
}, 10_000)
type WatchStore<T> = Writable<T> & {
refresh: () => void
}
export const watch = (names, f) => {
names = ensurePlural(names)
const store = writable(null) as WatchStore<any>
const tables = names.map(name => registry[name])
// Initialize synchronously if possible
const initialValue = f(...tables)
if (is(Promise, initialValue)) {
initialValue.then(v => store.set(v))
} else {
store.set(initialValue)
}
// Debounce refresh so we don't get UI lag
store.refresh = throttle(300, async () => store.set(await f(...tables)))
// Listen for changes
listener.subscribe(name => {
if (names.includes(name)) {
store.refresh()
}
})
return store
}
export const dropAll = async () => {
for (const table of Object.values(registry)) {
await table.drop()
log(`Successfully dropped table ${table.name}`)
}
}
// ----------------------------------------------------------------------------
// Domain-specific collections
const sortByCreatedAt = sortBy(prop("created_at"))
const sortByLastSeen = sortBy(prop("last_seen"))
export const people = new Table("people", "pubkey", {
max: 5000,
// Don't delete the user's own profile or those of direct follows
sort: xs => {
const follows = Tags.wrap(user.getPetnames()).values().all()
const whitelist = new Set(follows.concat(user.getPubkey()))
return sortBy(x => (whitelist.has(x.pubkey) ? 0 : x.created_at), xs)
},
})
export const userEvents = new Table("userEvents", "id", {max: 2000, sort: sortByCreatedAt})
export const notifications = new Table("notifications", "id")
export const contacts = new Table("contacts", "pubkey")
export const rooms = new Table("rooms", "id")
export const relays = new Table("relays", "url")
export const routes = new Table("routes", "id", {max: 3000, sort: sortByLastSeen})
listener.connect()
export const getPersonWithFallback = pubkey => people.get(pubkey) || {pubkey}
export const getRelayWithFallback = url => relays.get(url) || {url}
const ready = writable(false)
export const onReady = cb => {
const unsub = ready.subscribe($ready => {
if ($ready) {
cb()
setTimeout(() => unsub())
}
})
}

View File

@ -10,7 +10,7 @@ import {
getRelaysForEventChildren,
sampleRelays,
} from "src/agent/relays"
import {people} from "src/agent/tables"
import {people} from "src/agent/db"
import pool from "src/agent/pool"
import sync from "src/agent/sync"

View File

@ -5,7 +5,7 @@ import {filter, pipe, pick, groupBy, objOf, map, assoc, sortBy, uniqBy, prop} fr
import {first} from "hurdak/lib/hurdak"
import {Tags, isRelay, findReplyId} from "src/util/nostr"
import {shuffle, fetchJson} from "src/util/misc"
import {relays, routes} from "src/agent/tables"
import {relays, routes} from "src/agent/db"
import pool from "src/agent/pool"
import user from "src/agent/user"
@ -25,7 +25,7 @@ import user from "src/agent/user"
export const initializeRelayList = async () => {
// Throw some hardcoded defaults in there
await relays.bulkPatch([
await relays.patch([
{url: "wss://brb.io"},
{url: "wss://nostr.zebedee.cloud"},
{url: "wss://nostr-pub.wellorder.net"},
@ -40,7 +40,7 @@ export const initializeRelayList = async () => {
const url = import.meta.env.VITE_DUFFLEPUD_URL + "/relay"
const json = await fetchJson(url)
await relays.bulkPatch(json.relays.filter(isRelay).map(objOf("url")))
await relays.patch(json.relays.filter(isRelay).map(objOf("url")))
} catch (e) {
warn("Failed to fetch relays list", e)
}
@ -74,7 +74,7 @@ export const getPubkeyWriteRelays = pubkey => getPubkeyRelays(pubkey, "write")
export const getAllPubkeyRelays = (pubkeys, mode = null) => {
// As an optimization, filter the database once and group by pubkey
const filter = mode ? {pubkey: pubkeys, mode} : {pubkey: pubkeys}
const filter = mode ? {pubkey: {$in: pubkeys}, mode} : {pubkey: {$in: pubkeys}}
const routesByPubkey = groupBy(prop("pubkey"), routes.all(filter))
return aggregateScores(

View File

@ -1,6 +1,6 @@
import {uniq, without} from "ramda"
import {Tags} from "src/util/nostr"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
import user from "src/agent/user"
export const getFollows = pubkey =>

View File

@ -1,264 +0,0 @@
import type {Writable} from "svelte/store"
import {throttle} from "throttle-debounce"
import {objOf, is, without} from "ramda"
import {writable} from "svelte/store"
import {isObject, mapValues, ensurePlural} from "hurdak/lib/hurdak"
import Cache from "src/util/cache"
import {log, error} from "src/util/logger"
import {where} from "src/util/misc"
// ----------------------------------------------------------------------------
// Localforage interface via web worker
type Message = {
topic: string
payload: object
}
const worker = new Worker(new URL("./workers/database.js", import.meta.url), {type: "module"})
worker.addEventListener("error", error)
class Channel {
id: string
onMessage: (e: MessageEvent) => void
constructor({onMessage}) {
this.id = Math.random().toString().slice(2)
this.onMessage = e => {
if (e.data.channel === this.id) {
onMessage(e.data as Message)
}
}
worker.addEventListener("message", this.onMessage)
}
close() {
worker.removeEventListener("message", this.onMessage)
}
send(topic, payload) {
worker.postMessage({channel: this.id, topic, payload})
}
}
const call = (topic, payload): Promise<Message> => {
return new Promise(resolve => {
const channel = new Channel({
onMessage: message => {
resolve(message)
channel.close()
},
})
channel.send(topic, payload)
})
}
const lf = async (method, ...args) => {
const message = await call("localforage.call", {method, args})
if (message.topic !== "localforage.return") {
throw new Error(`callLocalforage received invalid response: ${message}`)
}
return message.payload
}
export const setItem = (k, v) => lf("setItem", k, v)
export const removeItem = k => lf("removeItem", k)
export const getItem = k => lf("getItem", k)
// ----------------------------------------------------------------------------
// Database table abstraction, synced to worker storage
type CacheEntry = [string, {value: any; lru: number}]
type TableOpts = {
cache?: Cache
initialize?: (table: Table) => Promise<Array<CacheEntry>>
}
export const registry = {} as Record<string, Table>
export class Table {
name: string
pk: string
opts: TableOpts
cache: Cache
listeners: Array<(Table) => void>
ready: Writable<boolean>
interval: number
constructor(name, pk, opts: TableOpts = {}) {
this.name = name
this.pk = pk
this.opts = opts
this.cache = this.opts.cache || new Cache()
this.listeners = []
this.ready = writable(false)
registry[name] = this
// Sync from storage initially
;(async () => {
const t = Date.now()
let data = (await getItem(this.name)) || []
// Backwards compat - we used to store objects rather than cache dump arrays
if (isObject(data)) {
data = Object.entries(mapValues(objOf("value"), data))
}
// Initialize our cache and notify listeners
this.cache.load(data)
this._notify()
log(`Table ${name} ready in ${Date.now() - t}ms (${this.cache.size()} records)`)
this.ready.set(true)
})()
// Prune the cache periodically, with jitter to avoid doing all caches at once
this.interval = window.setInterval(() => {
this.cache.prune()
this._persist()
}, 30_000 + 10_000 * Math.random())
}
_persist = throttle(4_000, () => {
setItem(this.name, this.cache.dump())
})
_notify() {
// Notify subscribers
for (const cb of this.listeners) {
cb(this)
}
// Save to localstorage
this._persist()
}
subscribe(cb) {
this.listeners.push(cb)
cb(this)
return () => {
this.listeners = without([cb], this.listeners)
}
}
bulkPut(items) {
for (const item of items) {
const k = item[this.pk]
if (!k) {
throw new Error(`Missing gray-6 key on ${this.name}`)
}
this.cache.set(k, item)
}
this._notify()
}
put(item) {
this.bulkPut([item])
}
bulkPatch(items) {
for (const item of items) {
const k = item[this.pk]
if (!k) {
throw new Error(`Missing gray-6 key on ${this.name}`)
}
this.cache.set(k, {...this.cache.get(k), ...item})
}
this._notify()
}
patch(item) {
this.bulkPatch([item])
}
bulkRemove(ks) {
for (const k of ks) {
this.cache.delete(k)
}
this._notify()
}
remove(k) {
this.bulkRemove([k])
}
async drop() {
this.cache.clear()
await removeItem(this.name)
}
toArray() {
return Array.from(this.cache.values())
}
all(spec = {}) {
return this.toArray().filter(where(spec))
}
find(spec = {}) {
return this.cache.find(where(spec))
}
get(k) {
return this.cache.get(k)
}
}
export const listener = (() => {
let listeners = []
return {
connect: () => {
for (const table of Object.values(registry) as Array<Table>) {
table.subscribe(() => listeners.forEach(f => f(table.name)))
}
},
subscribe: f => {
listeners.push(f)
return () => {
listeners = without([f], listeners)
}
},
}
})()
type WatchStore<T> = Writable<T> & {
refresh: () => void
}
export const watch = (names, f) => {
names = ensurePlural(names)
const store = writable(null) as WatchStore<any>
const tables = names.map(name => registry[name])
// Initialize synchronously if possible
const initialValue = f(...tables)
if (is(Promise, initialValue)) {
initialValue.then(v => store.set(v))
} else {
store.set(initialValue)
}
// Debounce refresh so we don't get UI lag
store.refresh = throttle(300, async () => store.set(await f(...tables)))
// Listen for changes
listener.subscribe(name => {
if (names.includes(name)) {
store.refresh()
}
})
return store
}
export const dropAll = async () => {
for (const table of Object.values(registry)) {
await table.drop()
log(`Successfully dropped table ${table.name}`)
}
}

View File

@ -13,7 +13,7 @@ import {
hash,
} from "src/util/misc"
import {Tags, roomAttrs, isRelay, isShareableRelay, normalizeRelayUrl} from "src/util/nostr"
import {people, userEvents, relays, rooms, routes} from "src/agent/tables"
import {people, userEvents, relays, rooms, routes} from "src/agent/db"
import {uniqByUrl} from "src/agent/relays"
import user from "src/agent/user"
@ -31,7 +31,7 @@ const processEvents = async events => {
for (let i = 0; i < chunks.length; i++) {
for (const event of chunks[i]) {
if (event.pubkey === userPubkey) {
userEvents.put(event)
userEvents.patch(event)
}
for (const handler of handlers[event.kind] || []) {
@ -57,15 +57,15 @@ const updatePerson = (pubkey, data) => {
}
}
const verifyNip05 = (pubkey, as) =>
nip05.queryProfile(as).then(result => {
const verifyNip05 = (pubkey, alias) =>
nip05.queryProfile(alias).then(result => {
if (result?.pubkey === pubkey) {
updatePerson(pubkey, {verified_as: as})
updatePerson(pubkey, {verified_as: alias})
if (result.relays?.length > 0) {
const urls = result.relays.filter(isRelay)
relays.bulkPatch(urls.map(url => ({url: normalizeRelayUrl(url)})))
relays.patch(urls.map(url => ({url: normalizeRelayUrl(url)})))
urls.forEach(url => {
addRoute(pubkey, url, "nip05", "write", now())
@ -299,7 +299,7 @@ const addRoute = (pubkey, rawUrl, type, mode, created_at) => {
url: route.url,
})
routes.put({
routes.patch({
...route,
count: newCount,
score: newTotalScore / newCount,

View File

@ -1,50 +0,0 @@
import {sortBy, pluck, all, identity} from "ramda"
import {derived} from "svelte/store"
import Cache from "src/util/cache"
import {Tags} from "src/util/nostr"
import {Table, listener, registry} from "src/agent/storage"
import user from "src/agent/user"
const sortByCreatedAt = sortBy(([k, x]) => x.value.created_at)
const sortByLastSeen = sortBy(([k, x]) => x.value.last_seen)
export const people = new Table("people", "pubkey", {
cache: new Cache({
max: 5000,
// Don't delete the user's own profile or those of direct follows
sort: xs => {
const follows = Tags.wrap(user.getPetnames()).values().all()
const whitelist = new Set(follows.concat(user.getPubkey()))
return sortBy(([k, {lru, value}]) => (whitelist.has(value.pubkey) ? 0 : value.created_at), xs)
},
}),
})
export const userEvents = new Table("userEvents", "id", {
cache: new Cache({max: 2000, sort: sortByCreatedAt}),
})
export const notifications = new Table("notifications", "id")
export const contacts = new Table("contacts", "pubkey")
export const rooms = new Table("rooms", "id")
export const relays = new Table("relays", "url")
export const routes = new Table("routes", "id", {
cache: new Cache({max: 3000, sort: sortByLastSeen}),
})
listener.connect()
export const getPersonWithFallback = pubkey => people.get(pubkey) || {pubkey}
export const getRelayWithFallback = url => relays.get(url) || {url}
const ready = derived(pluck("ready", Object.values(registry)), all(identity))
export const onReady = cb => {
const unsub = ready.subscribe($ready => {
if ($ready) {
cb()
setTimeout(() => unsub())
}
})
}

View File

@ -42,7 +42,7 @@ const profile = synced("agent/user/profile", {
const settings = derived(profile, prop("settings"))
const petnames = derived(profile, prop("petnames"))
const relays = derived(profile, prop("relays")) as Readable<Array<Relay>>
const mutes = derived(profile, prop("mutes"))
const mutes = derived(profile, prop("mutes")) as Readable<Array<[string, string]>>
const canPublish = derived(
[keys.pubkey, relays],

View File

@ -1,28 +0,0 @@
import lf from "localforage"
import memoryStorageDriver from "localforage-memoryStorageDriver"
import {switcherFn} from "hurdak/lib/hurdak"
import {error} from "src/util/logger"
// Firefox private mode doesn't have access to any storage options
lf.defineDriver(memoryStorageDriver)
lf.setDriver([lf.INDEXEDDB, lf.WEBSQL, lf.LOCALSTORAGE, "memoryStorageDriver"])
addEventListener("message", async ({data: {topic, payload, channel}}) => {
const reply = (topic, payload) => postMessage({channel, topic, payload})
switcherFn(topic, {
"localforage.call": async () => {
const {method, args} = payload
const result = await lf[method](...args)
reply("localforage.return", result)
},
default: () => {
throw new Error(`invalid topic: ${topic}`)
},
})
})
addEventListener("error", error)
addEventListener("unhandledrejection", error)

View File

@ -4,8 +4,8 @@ import {doPipe} from "hurdak/lib/hurdak"
import {synced, now, timedelta} from "src/util/misc"
import {Tags, isNotification} from "src/util/nostr"
import {getUserReadRelays} from "src/agent/relays"
import {notifications, userEvents, contacts, rooms} from "src/agent/tables"
import {watch} from "src/agent/storage"
import {notifications, userEvents, contacts, rooms} from "src/agent/db"
import {watch} from "src/agent/db"
import network from "src/agent/network"
import user from "src/agent/user"
@ -33,7 +33,7 @@ export const newChatMessages = derived(
// Synchronization from events to state
const processNotifications = async (pubkey, events) => {
notifications.bulkPut(events.filter(e => isNotification(e, pubkey)))
notifications.patch(events.filter(e => isNotification(e, pubkey)))
}
const processMessages = async (pubkey, events) => {
@ -91,7 +91,7 @@ const listen = async pubkey => {
// Include an offset so we don't miss notifications on one relay but not another
const since = now() - timedelta(30, "days")
const roomIds = pluck("id", rooms.all({joined: true}))
const eventIds = doPipe(userEvents.all({"created_at:gt": since, kind: 1}), [
const eventIds = doPipe(userEvents.all({kind: 1, created_at: {$gt: since}}), [
sortBy(e => -e.created_at),
slice(0, 256),
pluck("id"),

View File

@ -10,13 +10,13 @@
import {warn} from "src/util/logger"
import {timedelta, hexToBech32, bech32ToHex, shuffle, now} from "src/util/misc"
import cmd from "src/agent/cmd"
import {onReady, relays} from "src/agent/tables"
import {onReady, relays} from "src/agent/db"
import keys from "src/agent/keys"
import network from "src/agent/network"
import pool from "src/agent/pool"
import {initializeRelayList} from "src/agent/relays"
import sync from "src/agent/sync"
import * as tables from "src/agent/tables"
import * as db from "src/agent/db"
import user from "src/agent/user"
import {loadAppData} from "src/app"
import {theme, getThemeVariables} from "src/app/ui"
@ -28,7 +28,7 @@
import Modal from "src/app2/Modal.svelte"
import ForegroundButtons from "src/app2/ForegroundButtons.svelte"
Object.assign(window, {cmd, user, keys, network, pool, sync, tables, bech32ToHex, hexToBech32})
Object.assign(window, {cmd, user, keys, network, pool, sync, db, bech32ToHex, hexToBech32})
export let pathname = location.pathname
@ -117,11 +117,8 @@
// Find relays with old/missing metadata and refresh them. Only pick a
// few so we're not sending too many concurrent http requests
const staleRelays = shuffle(
await relays.all({
"refreshed_at:lt": now() - timedelta(7, "days"),
})
).slice(0, 10)
const query = {refreshed_at: {$lt: now() - timedelta(7, "days")}}
const staleRelays = shuffle(relays.all(query)).slice(0, 10)
const freshRelays = await Promise.all(
staleRelays.map(async ({url}) => {
@ -145,7 +142,7 @@
})
)
relays.bulkPatch(freshRelays.filter(identity))
relays.patch(freshRelays.filter(identity))
}, 30_000)
return () => {

View File

@ -6,7 +6,7 @@
import RelaySearch from "src/app2/shared/RelaySearch.svelte"
import RelayCard from "src/app2/shared/RelayCard.svelte"
import PersonSearch from "src/app2/shared/PersonSearch.svelte"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
import user from "src/agent/user"
export let enforceRelays = true

View File

@ -1,6 +1,6 @@
<script lang="ts">
import {Route} from "svelte-routing"
import {onReady} from "src/agent/tables"
import {onReady} from "src/agent/db"
import EnsureData from "src/app2/EnsureData.svelte"
import Notifications from "src/app2/views/Notifications.svelte"
import Bech32Entity from "src/app2/views/Bech32Entity.svelte"

View File

@ -54,8 +54,8 @@
import keys from "src/agent/keys"
import network from "src/agent/network"
import {getEventPublishRelays, getRelaysForEventParent} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
import {getPersonWithFallback} from "src/agent/db"
import {watch} from "src/agent/db"
import cmd from "src/agent/cmd"
import {routes} from "src/app/ui"
import {publishWithToast} from "src/app"
@ -666,7 +666,7 @@
</div>
{/if}
<h1 class="staatliches text-2xl">Relays</h1>
<p>This note was found on the {quantify(note.seen_on.length, "relay")} below.</p>
<p>This note was found on {quantify(note.seen_on.length, "relay")} below.</p>
<div class="flex flex-col gap-2">
{#each note.seen_on as url}
<RelayCard theme="black" relay={{url}} />

View File

@ -14,7 +14,7 @@
import {sampleRelays} from "src/agent/relays"
import user from "src/agent/user"
import network from "src/agent/network"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
import {routes, modal} from "src/app/ui"
export let note

View File

@ -3,7 +3,7 @@
import {parseContent} from "src/util/html"
import {displayPerson} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
export let person
export let truncate = false

View File

@ -6,8 +6,8 @@
import Spinner from "src/partials/Spinner.svelte"
import PersonInfo from "src/app2/shared/PersonInfo.svelte"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
import {getPersonWithFallback} from "src/agent/db"
import {watch} from "src/agent/db"
import network from "src/agent/network"
export let type

View File

@ -5,7 +5,7 @@
import Spinner from "src/partials/Spinner.svelte"
import PersonInfo from "src/app2/shared/PersonInfo.svelte"
import {getUserReadRelays} from "src/agent/relays"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
import network from "src/agent/network"
import user from "src/agent/user"
@ -16,7 +16,7 @@
const {petnamePubkeys} = user
const search = watch("people", t =>
fuzzy(t.all({"kind0.name:!nil": null}), {
fuzzy(t.all({"kind0.name": {$type: "string"}}), {
keys: ["kind0.name", "kind0.about", "pubkey"],
})
)

View File

@ -1,35 +1,36 @@
<script lang="ts">
import {last} from "ramda"
import {last, nth} from "ramda"
import {navigate} from "svelte-routing"
import {displayPerson} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte"
import user from "src/agent/user"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
import {getPersonWithFallback} from "src/agent/db"
import {watch} from "src/agent/db"
import {routes} from "src/app/ui"
import PersonCircle from "src/app2/shared/PersonCircle.svelte"
import PersonAbout from "src/app2/shared/PersonAbout.svelte"
export let pubkey
const {petnamePubkeys, canPublish} = user
const {petnamePubkeys, mutes, canPublish} = user
const getRelays = () => sampleRelays(getPubkeyWriteRelays(pubkey))
const person = watch("people", () => getPersonWithFallback(pubkey))
let following = false
$: following = $petnamePubkeys.includes(pubkey)
$: muted = $mutes.map(nth(1)).includes(pubkey)
const follow = async () => {
const follow = () => {
const [{url}] = getRelays()
user.addPetname(pubkey, url, displayPerson($person))
}
const unfollow = async () => {
user.removePetname(pubkey)
}
const unfollow = () => user.removePetname(pubkey)
const unmute = () => user.removeMute(pubkey)
const mute = () => user.addMute("p", pubkey)
</script>
<div class="relative flex flex-col gap-4 py-2 px-3">
@ -49,16 +50,26 @@
</div>
{/if}
</div>
<div class="flex gap-2">
<div class="flex gap-4 py-2 text-lg">
{#if $canPublish}
{#if following}
<Anchor type="button-circle" on:click={unfollow}>
<i class="fa fa-user-minus" />
</Anchor>
{#if muted}
<i
title="Unmute"
class="fa fa-microphone-slash w-6 cursor-pointer text-center"
on:click={unmute} />
{:else}
<Anchor type="button-circle" on:click={follow}>
<i class="fa fa-user-plus" />
</Anchor>
<i title="Mute" class="fa fa-microphone w-6 cursor-pointer text-center" on:click={mute} />
{/if}
{#if following}
<i
title="Unfollow"
class="fa fa-user-minus w-6 cursor-pointer text-center"
on:click={unfollow} />
{:else}
<i
title="Follow"
class="fa fa-user-plus w-6 cursor-pointer text-center"
on:click={follow} />
{/if}
{/if}
</div>

View File

@ -4,7 +4,7 @@
import Popover from "src/partials/Popover.svelte"
import OverflowMenu from "src/partials/OverflowMenu.svelte"
import user from "src/agent/user"
import {getRelayWithFallback} from "src/agent/tables"
import {getRelayWithFallback} from "src/agent/db"
export let relay

View File

@ -3,7 +3,7 @@
import {fuzzy} from "src/util/misc"
import Input from "src/partials/Input.svelte"
import RelayCard from "src/app2/shared/RelayCard.svelte"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
import user from "src/agent/user"
export let q = ""

View File

@ -10,7 +10,7 @@
import user from "src/agent/user"
import {getRelaysForEventChildren, sampleRelays} from "src/agent/relays"
import network from "src/agent/network"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
import cmd from "src/agent/cmd"
import {modal} from "src/app/ui"
import {lastChecked} from "src/app/listener"

View File

@ -8,7 +8,7 @@
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
import {getUserWriteRelays} from "src/agent/relays"
import {rooms} from "src/agent/tables"
import {rooms} from "src/agent/db"
import cmd from "src/agent/cmd"
import {toast, modal} from "src/app/ui"
import {publishWithToast} from "src/app"

View File

@ -5,7 +5,7 @@
import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.svelte"
import ChatListItem from "src/app2/views/ChatListItem.svelte"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
import network from "src/agent/network"
import {getUserReadRelays} from "src/agent/relays"
import {modal} from "src/app/ui"
@ -15,7 +15,7 @@
let results = []
const userRooms = watch("rooms", t => t.all({joined: true}))
const otherRooms = watch("rooms", t => t.all({"joined:!eq": true}))
const otherRooms = watch("rooms", t => t.all({joined: {$ne: true}}))
$: search = fuzzy($otherRooms, {keys: ["name", "about"]})
$: results = search(q).slice(0, 50)

View File

@ -4,7 +4,7 @@
import {fly} from "svelte/transition"
import {ellipsize} from "hurdak/lib/hurdak"
import Anchor from "src/partials/Anchor.svelte"
import {rooms} from "src/agent/tables"
import {rooms} from "src/agent/db"
export let room

View File

@ -12,7 +12,7 @@
import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte"
import RelayCard from "src/app2/shared/RelayCard.svelte"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
import network from "src/agent/network"
import user from "src/agent/user"
import pool from "src/agent/pool"

View File

@ -2,7 +2,7 @@
import {fly} from "svelte/transition"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import {dropAll} from "src/agent/storage"
import {dropAll} from "src/agent/db"
let confirmed = false

View File

@ -8,8 +8,8 @@
import Anchor from "src/partials/Anchor.svelte"
import NoteContent from "src/app2/shared/NoteContent.svelte"
import {getAllPubkeyRelays, sampleRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
import {getPersonWithFallback} from "src/agent/db"
import {watch} from "src/agent/db"
import network from "src/agent/network"
import keys from "src/agent/keys"
import user from "src/agent/user"

View File

@ -4,7 +4,7 @@
import Tabs from "src/partials/Tabs.svelte"
import Content from "src/partials/Content.svelte"
import MessagesListItem from "src/app2/views/MessagesListItem.svelte"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
let activeTab = "messages"
let contacts = []
@ -19,7 +19,7 @@
const accepted = watch("contacts", t => sortBy(e => -e.lastMessage, t.all({accepted: true})))
const requests = watch("contacts", t =>
sortBy(e => -e.lastMessage, t.all({"accepted:!eq": true}))
sortBy(e => -e.lastMessage, t.all({accepted: {$ne: true}}))
)
const getDisplay = tab => ({

View File

@ -3,7 +3,7 @@
import {navigate} from "svelte-routing"
import {ellipsize} from "hurdak/lib/hurdak"
import {displayPerson} from "src/util/nostr"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
import {lastChecked} from "src/app/listener"
import PersonCircle from "src/app2/shared/PersonCircle.svelte"
import Card from "src/partials/Card.svelte"

View File

@ -17,7 +17,7 @@
import RelayCard from "src/app2/shared/RelayCard.svelte"
import RelaySearch from "src/app2/shared/RelaySearch.svelte"
import {getUserWriteRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
import cmd from "src/agent/cmd"
import user from "src/agent/user"
import {toast, modal} from "src/app/ui"

View File

@ -8,7 +8,7 @@
import Popover from "src/partials/Popover.svelte"
import NoteContent from "src/app2/shared/NoteContent.svelte"
import NotificationSection from "src/app2/views/NotificationSection.svelte"
import {getPersonWithFallback, userEvents} from "src/agent/tables"
import {getPersonWithFallback, userEvents} from "src/agent/db"
import {modal} from "src/app/ui"
export let event

View File

@ -1,6 +1,6 @@
<script lang="ts">
import Badge from "src/partials/Badge.svelte"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
export let pubkeys
</script>

View File

@ -7,9 +7,9 @@
import Spinner from "src/partials/Spinner.svelte"
import Content from "src/partials/Content.svelte"
import Notification from "src/app2/views/Notification.svelte"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
import user from "src/agent/user"
import {userEvents} from "src/agent/tables"
import {userEvents} from "src/agent/db"
import {lastChecked} from "src/app/listener"
let limit = 0

View File

@ -13,7 +13,7 @@
import OnboardingComplete from "src/app2/views/OnboardingComplete.svelte"
import {getFollows} from "src/agent/social"
import {getPubkeyWriteRelays, sampleRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
import network from "src/agent/network"
import pool from "src/agent/pool"
import user from "src/agent/user"

View File

@ -6,8 +6,8 @@
import Heading from "src/partials/Heading.svelte"
import Content from "src/partials/Content.svelte"
import PersonInfo from "src/app2/shared/PersonInfo.svelte"
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
import {getPersonWithFallback} from "src/agent/db"
import {watch} from "src/agent/db"
import {modal} from "src/app/ui"
export let follows
@ -15,7 +15,7 @@
let q = ""
let search
const knownPeople = watch("people", t => t.all({"kind0.name:!nil": null}))
const knownPeople = watch("people", t => t.all({"kind0.name": {$type: "string"}}))
$: search = fuzzy(
$knownPeople.filter(p => !follows.includes(p.pubkey)),

View File

@ -6,7 +6,7 @@
import Heading from "src/partials/Heading.svelte"
import Content from "src/partials/Content.svelte"
import RelayCard from "src/app2/shared/RelayCard.svelte"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
import {modal} from "src/app/ui"
export let relays

View File

@ -19,7 +19,7 @@
import pool from "src/agent/pool"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
import network from "src/agent/network"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback, watch} from "src/agent/db"
import {routes, modal, theme, getThemeColor} from "src/app/ui"
import PersonCircle from "src/app2/shared/PersonCircle.svelte"
import PersonAbout from "src/app2/shared/PersonAbout.svelte"
@ -31,12 +31,12 @@
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const {petnamePubkeys, canPublish, mutes} = user
const tabs = ["notes", "likes", pool.forceUrls.length === 0 && "relays"].filter(identity)
const pubkey = toHex(npub)
const person = watch("people", () => getPersonWithFallback(pubkey))
let pubkey = toHex(npub)
let following = false
let muted = false
let followersCount = tweened(0, {interpolate, duration: 1000})
let person = getPersonWithFallback(pubkey)
let loading = true
let actions = []
let rgb, rgba
@ -95,14 +95,13 @@
}
onMount(async () => {
log("Person", npub, person)
log("Person", npub, $person)
document.title = displayPerson(person)
document.title = displayPerson($person)
// Refresh our person
network.loadPeople([pubkey], {relays, force: true}).then(() => {
ownRelays = getPubkeyWriteRelays(pubkey)
person = getPersonWithFallback(pubkey)
loading = false
})
@ -142,7 +141,7 @@
const follow = async () => {
const [{url}] = relays
user.addPetname(pubkey, url, displayPerson(person))
user.addPetname(pubkey, url, displayPerson($person))
}
const unfollow = async () => {
@ -158,11 +157,11 @@
}
const openProfileInfo = () => {
modal.set({type: "person/info", person})
modal.set({type: "person/info", $person})
}
const share = () => {
modal.set({type: "person/share", person})
modal.set({type: "person/share", $person})
}
</script>
@ -172,31 +171,31 @@
background-size: cover;
background-image:
linear-gradient(to bottom, {rgba}, {rgb}),
url('{person.kind0?.banner}')" />
url('{$person.kind0?.banner}')" />
<Content>
<div class="flex gap-4 text-gray-1">
<PersonCircle {person} size={16} class="sm:h-32 sm:w-32" />
<PersonCircle person={$person} size={16} class="sm:h-32 sm:w-32" />
<div class="flex flex-grow flex-col gap-4">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-grow flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="text-2xl">{displayPerson(person)}</h1>
<h1 class="text-2xl">{displayPerson($person)}</h1>
</div>
{#if person.verified_as}
{#if $person.verified_as}
<div class="flex gap-1 text-sm">
<i class="fa fa-user-check text-accent" />
<span class="text-gray-1">{last(person.verified_as.split("@"))}</span>
<span class="text-gray-1">{last($person.verified_as.split("@"))}</span>
</div>
{/if}
</div>
<OverflowMenu {actions} />
</div>
<PersonAbout {person} />
{#if person?.petnames}
<PersonAbout person={$person} />
{#if $person?.petnames}
<div class="flex gap-8" in:fly={{y: 20}}>
<button on:click={showFollows}>
<strong>{person.petnames.length}</strong> following
<strong>{$person.petnames.length}</strong> following
</button>
<button on:click={showFollowers}>
<strong>{numberFmt.format($followersCount)}</strong> followers

View File

@ -4,7 +4,7 @@
import Feed from "src/app2/shared/Feed.svelte"
import RelayTitle from "src/app2/shared/RelayTitle.svelte"
import RelayActions from "src/app2/shared/RelayActions.svelte"
import {relays} from "src/agent/tables"
import {relays} from "src/agent/db"
export let url

View File

@ -6,7 +6,7 @@
import {sleep, createScroller, Cursor} from "src/util/misc"
import Spinner from "src/partials/Spinner.svelte"
import user from "src/agent/user"
import {getPersonWithFallback} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/db"
import network from "src/agent/network"
export let loadMessages

View File

@ -6,7 +6,7 @@
import Badge from "src/partials/Badge.svelte"
import ContentEditable from "src/partials/ContentEditable.svelte"
import Suggestions from "src/partials/Suggestions.svelte"
import {watch} from "src/agent/storage"
import {watch} from "src/agent/db"
import {getPubkeyWriteRelays} from "src/agent/relays"
export let onSubmit
@ -27,7 +27,7 @@
}
const searchPeople = watch("people", t => {
return fuzzy(t.all({"kind0.name:!nil": null}), {keys: ["kind0.name", "pubkey"]})
return fuzzy(t.all({"kind0.name": {$type: "string"}}), {keys: ["kind0.name", "pubkey"]})
})
const applySearch = word => {

View File

@ -1,80 +0,0 @@
import {sortBy, nth} from "ramda"
type CacheEntry = [string, {value: any; lru: number}]
type SortFn = (xs: Array<CacheEntry>) => Array<CacheEntry>
type CacheOptions = {
max?: number
sort?: SortFn
}
const sortByLru = sortBy(([k, x]) => x.lru)
export default class Cache {
max: number
sort: SortFn
data: Map<string, any>
lru: Map<string, number>
constructor({max = 1000, sort = sortByLru}: CacheOptions = {}) {
this.max = max
this.sort = sort
this.data = new Map()
this.lru = new Map()
}
set(k, v) {
this.data.set(k, v)
this.lru.set(k, Date.now())
}
delete(k) {
this.data.delete(k)
this.lru.delete(k)
}
get(k) {
return this.data.get(k)
}
size() {
return this.data.size
}
entries() {
return this.data.entries()
}
keys() {
return this.data.keys()
}
values() {
return this.data.values()
}
find(f) {
for (const v of this.data.values()) {
if (f(v)) {
return v
}
}
}
load(entries) {
for (const [k, {value, lru}] of entries) {
this.data.set(k, value)
this.lru.set(k, lru)
}
}
dump(): Array<CacheEntry> {
return Array.from(this.data.keys()).map(k => [
k,
{value: this.data.get(k), lru: this.lru.get(k)},
])
}
prune() {
if (this.data.size <= this.max) {
return
}
for (const k of this.sort(this.dump()).map(nth(0)).slice(this.max)) {
this.delete(k)
}
}
clear() {
this.data.clear()
this.lru.clear()
}
}

View File

@ -9,12 +9,7 @@ import {
mergeDeepRight,
aperture,
filter,
path as getPath,
allPass,
pipe,
isNil,
complement,
equals,
is,
pluck,
sum,
@ -173,7 +168,7 @@ export class Cursor {
until: number
limit: number
count: number
constructor({limit = 50, delta = undefined} = {}) {
constructor({limit = 20, delta = undefined} = {}) {
this.delta = delta
this.since = delta ? now() - delta : undefined
this.until = now()
@ -266,42 +261,6 @@ export const defer = (): Deferred<any> => {
export const avg = xs => sum(xs) / xs.length
export const where = filters =>
allPass(
Object.entries(filters).map(([key, value]) => {
/* eslint prefer-const: 0 */
let [field, operator = "eq"] = key.split(":")
let test,
modifier = identity,
parts = field.split(".")
if (operator.startsWith("!")) {
operator = operator.slice(1)
modifier = complement
}
if (operator === "eq" && is(Array, value)) {
test = v => (value as Array<any>).includes(v)
} else if (operator === "eq") {
test = equals(value)
} else if (operator === "lt") {
test = v => (v || 0) < value
} else if (operator === "lte") {
test = v => (v || 0) <= value
} else if (operator === "gt") {
test = v => (v || 0) > value
} else if (operator === "gte") {
test = v => (v || 0) >= value
} else if (operator === "nil") {
test = isNil
} else {
throw new Error(`Invalid operator ${operator}`)
}
return pipe(getPath(parts), modifier(test))
})
)
// https://stackoverflow.com/a/21682946
export const stringToHue = value => {
let hash = 0