lmdb-localstorage
Version:
A browser localStorage implementation of the NodeJS lmdb API
617 lines (567 loc) • 21.6 kB
JavaScript
import {JSChaCha8} from "./src/js-chacha8.js";
import {LZUTF8} from "./src/lzutf8.js";
import {EventEmitter} from "./src/event-emitter.js"
const stringify = (_,value) => {
if(typeof(value)==="symbol") return value.toString();
return value;
}
const parse = (key,value) => {
if(typeof(value)==="string") {
const name = (value.match(/Symbol\((.*)\)/)||[])[1];
if(name) return Symbol.for(name)
}
return value;
}
const commit = (transaction=[],asynchronous) => {
const previous = {}, keysAdded = [], keysRemoved = [];
try {
for(let item of transaction) {
let [key,value,version,ifVersion,db] = item;
const {database,state,keys,fullName,useVersions,encryptionKey,cache} = db,
store = db.store = cache ? state : {};
previous[fullName] ||= {};
const skey = JSON.stringify(key,stringify),
addKeys = state[skey]==null,
entry = database.getEntry(key);
previous[fullName][skey] = {store,entry};
if(value!=null && entry) {
if(useVersions) {
if(ifVersion && entry.version!==ifVersion) {
continue;
}
if(!version && entry.version) {
version = entry.version + 1;
}
}
if(!version) version = entry.version || 1;
}
store[skey] = { value, version };
if(value==null) {
keysRemoved.push([key,db]);
if(cache) {
const index = findIndex(key,keys);
if(index!=null) keys.splice(index,1);
}
} else if(addKeys) {
keysAdded.push([key,db]); // getEntry above will have added key to keys and sorted if necessary
}
TRANSACTIONS.dirty[fullName] ||= {};
TRANSACTIONS.dirty[fullName][skey] ||= db;
}
TRANSACTIONS.db.emit("beforecommit");
} catch(e) {
Object.values(previous).forEach((database) => Object.entries(database).forEach(([key,value]) => {
if(value.entry==null) {
delete value.store[key];
} else {
value.store[key] = value.entry
}
}));
keysAdded.forEach(([added,db]) => {
const {keys,cache} = db;
if(cache) {
const index = findIndex(added, keys);
if (index != null) keys.splice(index, 1);
}
})
keysRemoved.forEach(([removed,db]) => {
const {keys,cache} = db;
if(cache) {
const index = insortIndex(removed, keys)
if (index != null) keys.splice(index, 0, removed);
}
})
throw e;
} finally {
if(TRANSACTIONS.length===0) {
const f = () => {
Object.entries(TRANSACTIONS.dirty).forEach(([fullName,keys]) => {
Object.entries(keys).forEach(([key,db]) => {
const {state,keys,encryptionKey,compression,cache,store} = db;
const entry = store[key];
if(entry.value===null) {
localStorage.removeItem(fullName+":"+key);
delete state[key];
if(!cache) {
key = JSON.parse(key,parse);
const index = findIndex(key,keys);
if(index!=null) keys.splice(index,1);
}
} else {
localStorage.setItem(fullName+":"+key,conditionalEncrypt(conditionalCompress(JSON.stringify(entry),compression),encryptionKey));
if(cache) state[key] = entry;
key = JSON.parse(key,parse);
const index = insortIndex(key,keys);
if(index!=null) keys.splice(index,0,key);
}
})
})
TRANSACTIONS.dirty = {};
}
return asynchronous ? new Promise((resolve) => setTimeout(() => { resolve(f()); })) : f();
} else {
commit(TRANSACTIONS.shift());
}
}
}
const sort = (a,b) => {
let aWasPrimitive, bWasPrimitive;
if(!Array.isArray(a)) {
aWasPrimitive = true;
a = [a];
}
if(!Array.isArray(b)) {
bWasPrimitive = true;
b = [b];
}
for(let i=0;i<a.length && i<b.length;i++) {
if(a==null && b!==null) return -1;
if(a!==null && b===null) return 1;
const atype = typeof(a[i]),
btype = typeof(b[i]);
if(atype!==btype) { // boolean,number,string
if(atype==="symbol") return -1;
if(btype==="symbol" || atype>btype) return 1;
return -1;
}
const avalue = atype==="symbol" ? a[i].toString() : a[i],
bvalue = btype==="symbol" ? b[i].toString() : b[i];
if(aWasPrimitive!==bWasPrimitive && bWasPrimitive) return 1;
if(avalue===bvalue) continue;
if(avalue>bvalue) return 1;
return -1;
}
return a.length - b.length;
};
const insortIndex = (key,keys,forSearch) => {
let aWasPrimitive;
if(!Array.isArray(key)) {
aWasPrimitive = true;
key = [key];
}
for(let i=0;i<keys.length;i++) {
let bWasPrimitive, otherKey = keys[i];
if(!Array.isArray(otherKey)) {
bWasPrimitive = true;
otherKey = [otherKey];
}
if(otherKey.length===key.length && otherKey.every((value,i) => value===key[i])) {
return forSearch ? i : undefined;
}
for(let j=0;j<key.length;j++) {
const atype = typeof(otherKey[j]),
btype = typeof(key[j]),
a = atype==="symbol" ? otherKey[j].toString() : otherKey[j],
b = btype==="symbol" ? key[j].toString() : key[j];
if(a==null && b!==null) continue;
if(a!==null && b===null) {
return forSearch ? i - 1 : i;
}
if(atype!==btype) {
if(atype==="symbol") continue;
if(btype==="symbol" || atype>btype) {
return forSearch ? i - 1 : i;
}
continue;
}
if(aWasPrimitive!==bWasPrimitive && aWasPrimitive) return i;
if(a>b) {
return forSearch ? i - 1 : i;
}
}
}
return keys.length;
}
const findIndex = (key,keys) => {
let aWasPrimitive;
if(!Array.isArray(key)) {
aWasPrimitive = true;
key = [key];
}
for(let i=0;i<keys.length;i++) {
let otherWasPrimitive, otherKey = keys[i];
if(!Array.isArray(otherKey)) {
otherWasPrimitive = true;
otherKey = [otherKey];
}
if(otherKey.length===key.length && otherKey.every((value,i) => value===key[i])) return i;
for(let j=0;j<key.length;j++) {
const a = otherKey[j],
b = key[j],
atype = typeof(a),
btype = typeof(b);
if(a==null && b!==null) continue
if(a!==null && b===null) return;
if(atype!==btype) {
if(atype==="symbol") continue;
if(btype==="symbol" || atype>btype) return;
continue;
}
if(aWasPrimitive!==otherWasPrimitive && aWasPrimitive) return; // is this correct?
if(a>b) return;
}
}
}
const conditionalVersion = (entry,version) => {
if(version) entry.version = version;
return entry;
}
const conditionalReverse = (array,reverse) => {
if(reverse) return array.reverse();
return array;
}
const conditionalEncrypt = (string,encryptionKey) => {
return typeof(string)==="string" && encryptionKey ? new JSChaCha8(encryptionKey,NONCE).encrypt(new TextEncoder().encode(string)) : string;
}
const conditionalDecrypt = (string,encryptionKey) => {
return typeof(string)==="string" && encryptionKey ? new JSChaCha8(encryptionKey,NONCE).decrypt(new TextEncoder().encode(string)) : string;
}
const conditionalCompress = (string,compression) => {
return typeof(string)==="string" && compression ? LZUTF8.compress(string) : string;
}
function conditionalDecompress(string,compression) {
return typeof(string)==="string" && compression ? LZUTF8.decompress(string) : string;
}
function reduce(item,reducers) {
let reducer;
while(reducer = reducers.shift()) {
const [type,f] = reducer,
value = f(item);
if(value && value.then) {
value.then((value) => {
if(type==="filter" && !value) return;
reduce(value,reducers);
})
return;
}
if(type=="filter") {
if(!value) return;
} else if(type==="map") {
item = value;
}
}
}
let TRANSACTION_LOCK;
let TRANSACTIONS = [];
const CHILDREN = [];
const NONCE = new Uint8Array([1,2,3,4,5,6,7,8,9,10,11,12]);
class LMDBLocalStorage extends EventEmitter {
#keys = [];
#lastVersion;
#path;
#fullName;
#state = {};
#useVersions;
#encryptionKey;
#compression;
#cache;
#currentTransaction() {
return TRANSACTIONS[TRANSACTIONS.length-1];
}
constructor({name,path,useVersions,encryptionKey,compression,cache}={}) {
super();
this.#path = path;
this.#fullName = path + "/" + name;
this.#state = Object.keys(localStorage).reduce((state,key) => {
if(key.startsWith(this.#fullName +":")) {
key = key.substring(key.indexOf(":")+1);
state[key] = undefined;
this.#keys.push(JSON.parse(key,parse));
}
return state;
},{});
this.#keys.sort(sort);
TRANSACTIONS.dirty = {};
this.#useVersions = useVersions;
this.#encryptionKey = encryptionKey ? new TextEncoder().encode(encryptionKey.padEnd(32,"!")).slice(0,32) : undefined;
this.#compression = compression;
this.#cache = cache;
}
async childTransaction(callback) {
const transaction = this.#currentTransaction();
if(transaction) {
const lock = TRANSACTION_LOCK;
TRANSACTION_LOCK = null;
const value = await this.transaction(callback);
TRANSACTION_LOCK = lock;
return value;
}
return this.transaction(callback);
}
async clearAsync() {
return this.clearSync();
}
clearSync() {
this.#state = {};
TRANSACTIONS.splice(0,TRANSACTIONS.length);
TRANSACTION_LOCK = null;
this.#keys.forEach((key) => {
key = typeof(key)==="symbol" ? key.toString() : JSON.stringify(key, stringify);
localStorage.removeItem(this.#fullName+":"+key);
})
this.#keys = [];
}
async close() {
return new Promise((resolve) => {
let interval = setInterval(async () => {
if(TRANSACTIONS.length===0) {
clearInterval(interval);
interval = null;
if(!CHILDREN.includes(this)) {
for(const child of CHILDREN) await child.close();
}
if(this.#path==null) {
this.#keys.forEach((key) => {
key = typeof(key)==="symbol" ? key.toString() : JSON.stringify(key, stringify);
localStorage.removeItem(this.#fullName+":"+key);
})
}
resolve();
}
},100)
});
}
async commited() {
return TRANSACTION_LOCK ? TRANSACTION_LOCK.then(() => {}): Promise.resolve();
}
doesExist(key,valueOrVersion) {
const entry = this.getEntry(key);
if(!entry) return false;
return this.#useVersions ? entry.version === valueOrVersion : entry.value === valueOrVersion
}
async drop() {
await this.close();
return this.clearSync();
}
dropSync() {
this.#state = {};
TRANSACTIONS.splice(0,TRANSACTIONS.length);
TRANSACTION_LOCK = null;
Object.keys(localStorage).forEach((key) => {
if(key.startsWith(this.#fullName+":")) localStorage.removeItem(key);
})
}
async flushed() {
return TRANSACTION_LOCK ? TRANSACTION_LOCK.then(() => {}) : Promise.resolve();
}
get(key) {
const entry = this.getEntry(key);
if(entry) {
this.#lastVersion = entry.version;
return entry.value;
}
}
getEntry(key) {
if(key==null) throw new TypeError(`key cannot be null or undefined`);
const skey = JSON.stringify(key,stringify);
if(this.#state[skey]!=undefined) return this.#state[skey];
let entry;
try {
entry = JSON.parse(conditionalDecompress(conditionalDecrypt(localStorage.getItem(this.#fullName + ":" + skey),this.#encryptionKey),this.#compression)||"null");
} catch(e) {
console.warn("Error parsing database entry:" + e);
}
if(this.#cache && entry) {
this.#state[key] = entry;
const index = insortIndex(key,this.#keys);
if(index!=null) this.#keys.splice(index,0,key);
}
return entry==null ? undefined : entry;
}
getKeys({start,end,limit=Infinity,offset=0,reverse,versions,snapshot}={}) {
const iterable = (function*() {
start = start ? insortIndex(start,this.#keys,true) : 0;
end = end ? insortIndex(end,this.#keys,true) : this.#keys.length;
const keys = this.#keys.slice(start,end+1);
for(const key of conditionalReverse(keys,reverse).slice(offset)) {
if(limit--<=0) return;
yield key;
}
}).call(this);
iterable.forEach = (f) => {
reducers.push(["forEach",f]);
for(let item of iterable) {
reduce(item,reducers,f);
}
}
const reducers = []
iterable.map = (f) => {
reducers.push(["map",f]);
return iterable;
}
iterable.filter = (f) => {
reducers.push(["filter",f]);
return iterable;
}
return iterable;
}
getRange({start,end,limit=Infinity,offset=0,reverse,versions,snapshot}={}) {
const useVersions = this.#useVersions,
iterable = (function*() {
start = start ? insortIndex(start,this.#keys,true) : 0;
end = end ? insortIndex(end,this.#keys,true) : this.#keys.length;
const keys = this.#keys.slice(start,end+1);
for(const key of conditionalReverse(keys,reverse).slice(offset)) {
if(limit--<=0) return;
const {value,version} = this.#state[JSON.stringify(key,stringify)];
yield conditionalVersion({key,value},useVersions && versions ? version : null);
}
}).call(this);
iterable.forEach = (f) => {
reducers.push(["forEach",f]);
for(let item of iterable) {
reduce(item,[...reducers]);
}
}
const reducers = []
iterable.map = (f) => {
reducers.push(["map",f]);
return iterable;
}
iterable.filter = (f) => {
reducers.push(["filter",f]);
return iterable;
}
return iterable;
}
getValues(key,{limit=Infinity,offset=0,reverse,snapshot}={}) {
throw new Error("getValues is not supported")
}
getLastVersion() {
return this.#lastVersion;
}
async ifNoExists(key,callback) {
const entry = this.getEntry(key);
if(entry.value==null) {
await callback();
return true;
}
return false;
}
async ifVersion(key,ifVersion,callback) {
const entry = this.getEntry(key);
if(entry.version===ifVersion) {
await callback();
return true;
}
return false;
}
openDB(name,options={}) {
if(name && typeof(name)==="object") {
options = {...name};
} else {
options.name = name;
}
options.path = this.#path;
const db = new LMDBLocalStorage(options);
CHILDREN.push(db);
return db;
}
async put(key,value,version,ifVersion) {
if(key==null) throw new TypeError(`key cannot be null or undefined`);
if(value===undefined) throw new TypeError(`value cannot be undefined`);
const transaction = this.#currentTransaction(),
db = {database:this,state:this.#state,keys:this.#keys,fullName:this.#fullName,useVersions:this.#useVersions,encryptionKey:this.#encryptionKey,compression:this.#compression,cache:this.#cache};
if(transaction) {
transaction.push([key,value,version,ifVersion,db]);
} else {
return commit([[key,value,version,ifVersion,db]],true);
}
}
putSync(key,value,version,ifVersion) {
if(key==null) throw new TypeError(`key cannot be null or undefined`);
if(value===undefined) throw new TypeError(`value cannot be undefined`);
const transaction = this.#currentTransaction(),
db = {database:this,state:this.#state,keys:this.#keys,fullName:this.#fullName,useVersions:this.#useVersions,encryptionKey:this.#encryptionKey,compression:this.#compression,cache:this.#cache};
if(transaction) {
transaction.push([key,value,version,ifVersion,db]);
} else {
return commit([[key,value,version,ifVersion,db]]);
}
}
async remove(key,ifVersion) {
return this.removeSync(key,ifVersion);
}
removeSync(key,ifVersion) {
if(key==null) throw new TypeError(`key cannot be null or undefined`);
const transaction = this.#currentTransaction(),
db = {database:this,state:this.#state,keys:this.#keys,fullName:this.#fullName,useVersions:this.#useVersions,encryptionKey:this.#encryptionKey,compression:this.#compression,cache:this.#cache};
if(transaction) {
transaction.push([key,null,null,ifVersion,db]);
} else {
return commit([[key,null,null,ifVersion,db]]);
}
return true;
}
async transaction(callback) {
const promise = new Promise(async (resolve,reject) => {
if(TRANSACTION_LOCK) {
try {
await TRANSACTION_LOCK;
} catch(e) {
}
}
TRANSACTIONS.push([]);
let value, rejected;
try {
value = await callback();
if(value===ABORT) {
throw new Error("Transaction aborted")
}
} catch(e) {
TRANSACTIONS.pop();
reject(e);
} finally {
try {
commit(TRANSACTIONS.shift());
TRANSACTION_LOCK = undefined;
if(!rejected) resolve(value);
} catch(e) {
TRANSACTION_LOCK = undefined;
reject(e);
}
}
});
TRANSACTION_LOCK = promise;
return promise
.finally(() => {
TRANSACTION_LOCK = undefined;
})
}
transactionSync(callback) {
TRANSACTIONS.push([]);
let value;
try {
value = callback();
if(value===ABORT) throw new Error("Transaction aborted")
} catch(e) {
TRANSACTIONS.pop();
throw e;
return;
}
if(value && value.then) {
return value.then((value) => {
if(value===ABORT) {
TRANSACTIONS.pop();
throw new Error("Transaction aborted");
}
commit(TRANSACTIONS.shift());
return value;
})
}
commit(TRANSACTIONS.shift());
return value;
}
}
const open = (path=null,options={}) => {
if(path && typeof(path)==="object") {
options = {...path};
options.path ||= null;
} else {
options.path = path;
}
options.name ||= null;
return TRANSACTIONS.db = new LMDBLocalStorage(options);
}
const ABORT = Object.freeze({});
export {open, ABORT};