minimongo
Version:
Client-side mongo database with server sync over http
461 lines (395 loc) • 13.5 kB
text/typescript
// Utilities for db handling
import _ from "lodash"
import { compileDocumentSelector, compileSort } from "./selector"
import { default as booleanPointInPolygon } from "@turf/boolean-point-in-polygon"
import { default as intersect } from "@turf/intersect"
import { default as booleanCrosses } from "@turf/boolean-crosses"
import { default as booleanWithin } from "@turf/boolean-within"
import { MinimongoDb, MinimongoLocalCollection, MinimongoLocalDb } from "./types"
import { default as IndexedDb } from "./IndexedDb"
import { default as WebSQLDb } from "./WebSQLDb"
import { default as LocalStorageDb } from "./LocalStorageDb"
import { default as MemoryDb } from "./MemoryDb"
import { default as HybridDb } from "./HybridDb"
import { LineString, Point } from "@turf/helpers"
import distance from "@turf/distance"
import nearestPointOnLine from "@turf/nearest-point-on-line"
// Test window.localStorage
function isLocalStorageSupported() {
if (!window.localStorage) {
return false
}
try {
window.localStorage.setItem("test", "test")
window.localStorage.removeItem("test")
return true
} catch (e) {
return false
}
}
// Compile a document selector (query) to a lambda function
export { compileDocumentSelector }
// Select appropriate local database, prefering IndexedDb, then WebSQLDb, then LocalStorageDb, then MemoryDb
export function autoselectLocalDb(options: any, success: any, error: any) {
// Browsers with no localStorage support don't deserve anything better than a MemoryDb
if (!isLocalStorageSupported()) {
return new MemoryDb(options, success)
}
// Always use WebSQL plugin in cordova iOS only
if ((window as any)["cordova"]) {
if ((window as any)["device"]?.platform === "iOS" && (window as any)["sqlitePlugin"]) {
console.log("Selecting WebSQLDb(sqlite) for Cordova")
options.storage = "sqlite"
return new WebSQLDb(options, success, error)
}
}
// Always use IndexedDb in browser if supported
if (window.indexedDB) {
console.log("Selecting IndexedDb for browser")
return new IndexedDb(options, success, (err: any) => {
console.log("Failed to create IndexedDb: " + (err ? err.message : undefined))
// Create LocalStorageDb instead
return new LocalStorageDb(options, success, (err: any) => {
console.log("Failed to create LocalStorageDb: " + (err ? err.message : undefined))
// Create MemoryDb instead
return new MemoryDb(options, success)
})
})
}
// Use Local Storage otherwise
console.log("Selecting LocalStorageDb for fallback")
return new LocalStorageDb(options, success, error)
}
// Migrates a local database's pending upserts and removes from one database to another
// Useful for upgrading from one type of database to another
export function migrateLocalDb(fromDb: any, toDb: any, success: any, error: any) {
// Migrate collection using a HybridDb
const hybridDb = new HybridDb(fromDb, toDb)
for (let name in fromDb.collections) {
const col = fromDb.collections[name]
if (toDb[name]) {
hybridDb.addCollection(name)
}
}
return hybridDb.upload(success, error)
}
/** Clone a local database collection's caches, pending upserts and removes from one database to another
* Useful for making a replica */
export function cloneLocalDb(
fromDb: MinimongoLocalDb,
toDb: MinimongoLocalDb
): Promise<void>
export function cloneLocalDb(
fromDb: MinimongoLocalDb,
toDb: MinimongoLocalDb,
success: () => void,
error: (err: any) => void
): void
export function cloneLocalDb(
fromDb: MinimongoLocalDb,
toDb: MinimongoLocalDb,
success?: () => void,
error?: (err: any) => void
): Promise<void> | void {
if (!success && !error) {
return new Promise<void>((resolve, reject) => {
cloneLocalDb(fromDb, toDb, resolve, reject)
})
}
async function clone() {
// Create collections in toDb for all collections in fromDb
for (const name in fromDb.collections) {
if (!toDb.collections[name]) {
await new Promise<void>((resolve, reject) => {
toDb.addCollection(name, resolve, reject)
})
}
}
// Clone each collection in parallel
await Promise.all(Object.values(fromDb.collections).map((fromCol) => {
return cloneLocalCollection(fromCol, toDb.collections[fromCol.name])
}))
}
clone().then(success).catch(error)
}
/** Clone a local database collection's caches, pending upserts and removes from one database to another
* Useful for making a replica */
export function cloneLocalCollection(
fromCol: MinimongoLocalCollection,
toCol: MinimongoLocalCollection
): Promise<void>
export function cloneLocalCollection(
fromCol: MinimongoLocalCollection,
toCol: MinimongoLocalCollection,
success: () => void,
error: (err: any) => void
): void
export function cloneLocalCollection(
fromCol: MinimongoLocalCollection,
toCol: MinimongoLocalCollection,
success?: () => void,
error?: (err: any) => void
): Promise<void> | void {
if (!success && !error) {
return new Promise<void>((resolve, reject) => {
cloneLocalCollection(fromCol, toCol, resolve, reject)
})
}
async function clone() {
// Get all items
const items = await fromCol.find({}).fetch()
// Seed items
await new Promise<void>((resolve, reject) => {
toCol.seed(items, resolve, reject)
})
// Copy upserts
const upserts = await new Promise<any[]>((resolve, reject) => {
fromCol.pendingUpserts(resolve, reject)
})
// Upsert items
await toCol.upsert(upserts.map((item) => item.doc), upserts.map((item) => item.base))
// Copy removes
const removes = await new Promise<string[]>((resolve, reject) => {
fromCol.pendingRemoves(resolve, reject)
})
// Remove items
for (let remove of removes) {
await toCol.remove(remove)
}
}
clone().then(success).catch(error)
}
// Processes a find with sorting and filtering and limiting
export function processFind(items: any, selector: any, options: any) {
let filtered = _.filter(items, compileDocumentSelector(selector))
// Handle geospatial operators
filtered = processNearOperator(selector, filtered)
filtered = processGeoIntersectsOperator(selector, filtered)
if (options && options.sort) {
filtered.sort(compileSort(options.sort))
}
if (options && options.skip) {
filtered = _.slice(filtered, options.skip)
}
if (options && options.limit) {
filtered = _.take(filtered, options.limit)
}
// Apply fields if present
if (options && options.fields) {
filtered = filterFields(filtered, options.fields)
}
return filtered
}
/** Include/exclude fields in mongo-style */
export function filterFields(items: any[], fields: any = {}): any[] {
// Handle trivial case
if (_.keys(fields).length === 0) {
return items
}
// For each item
return _.map(items, function (item: any) {
let field, obj, path, pathElem
const newItem: any = {}
if (_.first(_.values(fields)) === 1) {
// Include fields
for (field of _.keys(fields).concat(["_id"])) {
path = field.split(".")
// Determine if path exists
obj = item
for (pathElem of path) {
if (obj) {
obj = obj[pathElem]
}
}
if (obj == null) {
continue
}
// Go into path, creating as necessary
let from = item
let to = newItem
for (pathElem of _.initial(path)) {
to[pathElem] = to[pathElem] || {}
// Move inside
to = to[pathElem]
from = from[pathElem]
}
// Copy value
to[_.last(path)!] = from[_.last(path)!]
}
return newItem
} else {
// Deep clone as we will be deleting keys from item to exclude fields
item = JSON.parse(JSON.stringify(item))
// Exclude fields
for (field of _.keys(fields)) {
path = field.split(".")
// Go inside path
obj = item
for (pathElem of _.initial(path)) {
if (obj) {
obj = obj[pathElem]
}
}
// If not there, don't exclude
if (obj == null) {
continue
}
delete obj[_.last(path)!]
}
return item
}
})
}
// Creates a unique identifier string
export function createUid() {
return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0
const v = c === "x" ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
function processNearOperator(selector: any, list: any) {
for (var key in selector) {
var value = selector[key]
if (value != null && value["$near"]) {
var geo = value["$near"]["$geometry"]
if (geo.type !== "Point") {
break
}
// Filter to points and lines
list = _.filter(list, (doc: any) => doc[key] && (doc[key].type === "Point" || doc[key].type === "LineString"))
// Get distances
let distances = _.map(list, (doc: any) => ({
doc,
distance: getDistance(geo, doc[key])
}))
// Filter non-points
distances = _.filter(distances, (item: any) => item.distance >= 0)
// Sort by distance
distances = _.sortBy(distances, "distance")
// Filter by maxDistance
if (value["$near"]["$maxDistance"]) {
distances = _.filter(distances, (item: any) => item.distance <= value["$near"]["$maxDistance"])
}
// Extract docs
list = _.map(distances, "doc")
}
}
return list
}
function getDistance(from: Point, to: Point | LineString) {
if (to.type === "Point") {
return distance(from, to, { units: "meters" })
}
if (to.type === "LineString") {
const nearest = nearestPointOnLine(to, from, { units: "meters" })
return nearest.properties.dist
}
throw new Error("Unsupported type")
}
function pointInPolygon(point: any, polygon: any) {
return booleanPointInPolygon(point, polygon)
}
function polygonIntersection(polygon1: any, polygon2: any) {
return intersect(polygon1, polygon2) != null
}
// From http://www.movable-type.co.uk/scripts/latlong.html
function getDistanceFromLatLngInM(lat1: any, lng1: any, lat2: any, lng2: any) {
const R = 6370986 // Radius of the earth in m
const dLat = deg2rad(lat2 - lat1) // deg2rad below
const dLng = deg2rad(lng2 - lng1)
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
const d = R * c // Distance in m
return d
}
function deg2rad(deg: any) {
return deg * (Math.PI / 180)
}
function processGeoIntersectsOperator(selector: any, list: any) {
for (var key in selector) {
const value = selector[key]
if (value != null && value["$geoIntersects"]) {
var geo = value["$geoIntersects"]["$geometry"]
// Can only test intersection with polygon
if (geo.type !== "Polygon") {
break
}
// Check within for each
list = _.filter(list, function (doc: any) {
// Ignore if null
if (!doc[key]) {
return false
}
// Check point or polygon
if (doc[key].type === "Point") {
return pointInPolygon(doc[key], geo)
} else if (["Polygon", "MultiPolygon"].includes(doc[key].type)) {
return polygonIntersection(doc[key], geo)
} else if (doc[key].type === "LineString") {
// Special case for empty line string (bug Dec 2023)
if (doc[key].coordinates.length === 0) {
return false
}
return booleanCrosses(doc[key], geo) || booleanWithin(doc[key], geo)
} else if (doc[key].type === "MultiLineString") {
// Bypass deficiencies in turf.js by splitting it up
for (let line of doc[key].coordinates) {
const lineGeo = { type: "LineString", coordinates: line }
// Special case for empty line string (bug Dec 2023)
if (lineGeo.coordinates.length === 0) {
continue
}
if (booleanCrosses(lineGeo, geo) || booleanWithin(lineGeo, geo)) {
return true
}
}
return false
}
})
}
}
return list
}
/** Tidy up upsert parameters to always be a list of { doc: <doc>, base: <base> },
* doing basic error checking and making sure that _id is present
* Returns [items, success, error]
*/
export function regularizeUpsert<T>(
docs: any,
bases: any,
success: any,
error: any
): [{ doc: T; base?: T }[], (docs: T[]) => void, (err: any) => void] {
// Handle case of bases not present
if (_.isFunction(bases)) {
;[bases, success, error] = [undefined, bases, success]
}
// Handle single upsert
if (!_.isArray(docs)) {
docs = [docs]
bases = [bases]
} else {
bases = bases || []
}
// Make into list of { doc: .., base: }
const items = _.map(docs, (doc, i) => ({
doc,
base: i < bases.length ? bases[i] : undefined
}))
// Set _id
for (let item of items) {
if (!item.doc._id) {
item.doc._id = createUid()
}
if (item.base && !item.base._id) {
throw new Error("Base needs _id")
}
if (item.base && item.base._id !== item.doc._id) {
throw new Error("Base needs same _id")
}
}
return [items, success, error]
}