From 7cbd84d90e7d4733e80074a9968ac0c97f80baff Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Sat, 23 Mar 2024 23:22:11 +0100 Subject: [PATCH] experimenting with new pledge replacement for promises --- indexeddb.test.js | 2 +- indexeddbV2.js | 265 ++++++++++++++++++++++++++++++++++++++++++++ indexeddbV2.test.js | 118 ++++++++++++++++++++ object.js | 7 ++ pledge.js | 259 +++++++++++++++++++++++++++++++++++++++++++ pledge.test.js | 60 ++++++++++ prng.test.js | 8 +- test.js | 4 + 8 files changed, 718 insertions(+), 5 deletions(-) create mode 100644 indexeddbV2.js create mode 100644 indexeddbV2.test.js create mode 100644 pledge.js create mode 100644 pledge.test.js diff --git a/indexeddb.test.js b/indexeddb.test.js index 85ccf6d..c67095b 100644 --- a/indexeddb.test.js +++ b/indexeddb.test.js @@ -104,5 +104,5 @@ export const testBlocked = async () => { await idb.put(store, 0, ['t', 1]) await idb.put(store, 1, ['t', 2]) db.close() - idb.deleteDB(testDBName) + await idb.deleteDB(testDBName) } diff --git a/indexeddbV2.js b/indexeddbV2.js new file mode 100644 index 0000000..eb0d75f --- /dev/null +++ b/indexeddbV2.js @@ -0,0 +1,265 @@ +/* eslint-env browser */ + +/** + * Helpers to work with IndexedDB. + * This is an experimental implementation using Pledge instead of Promise. + * + * @experimental + * + * @module indexeddbv2 + */ + +import * as pledge from './pledge.js' + +/* c8 ignore start */ + +/** + * IDB Request to Pledge transformer + * + * @param {pledge.PledgeInstance} p + * @param {IDBRequest} request + */ +export const bindPledge = (p, request) => { + // @ts-ignore + request.onerror = event => p.cancel(event.target.error) + // @ts-ignore + request.onsuccess = event => p.resolve(event.target.result) +} + +/** + * @param {string} name + * @param {function(IDBDatabase):any} initDB Called when the database is first created + * @return {pledge.PledgeInstance} + */ +export const openDB = (name, initDB) => { + /** + * @type {pledge.PledgeInstance} + */ + const p = pledge.create() + const request = indexedDB.open(name) + /** + * @param {any} event + */ + request.onupgradeneeded = event => initDB(event.target.result) + /** + * @param {any} event + */ + request.onerror = event => p.cancel(event.target.error) + /** + * @param {any} event + */ + request.onsuccess = event => { + /** + * @type {IDBDatabase} + */ + const db = event.target.result + db.onversionchange = () => { db.close() } + p.resolve(db) + } + return p +} + +/** + * @param {pledge.Pledge} name + * @return {pledge.PledgeInstance} + */ +export const deleteDB = name => pledge.createWithDependencies((p, name) => bindPledge(p, indexedDB.deleteDatabase(name)), name) + +/** + * @param {IDBDatabase} db + * @param {Array|Array>} definitions + */ +export const createStores = (db, definitions) => definitions.forEach(d => + // @ts-ignore + db.createObjectStore.apply(db, d) +) + +/** + * @param {pledge.Pledge} db + * @param {pledge.Pledge>} stores + * @param {"readwrite"|"readonly"} [access] + * @return {pledge.Pledge>} + */ +export const transact = (db, stores, access = 'readwrite') => pledge.createWithDependencies((p, db, stores) => { + const transaction = db.transaction(stores, access) + p.resolve(stores.map(store => getStore(transaction, store))) +}, db, stores) + +/** + * @param {IDBObjectStore} store + * @param {pledge.Pledge} [range] + * @return {pledge.PledgeInstance} + */ +export const count = (store, range) => pledge.createWithDependencies((p, store, range) => bindPledge(p, store.count(range)), store, range) + +/** + * @param {pledge.Pledge} store + * @param {pledge.Pledge>} key + * @return {pledge.PledgeInstance>} + */ +export const get = (store, key) => pledge.createWithDependencies((p, store, key) => bindPledge(p, store.get(key)), store, key) + +/** + * @param {pledge.Pledge} store + * @param {String | number | ArrayBuffer | Date | IDBKeyRange | Array } key + */ +export const del = (store, key) => pledge.createWithDependencies((p, store, key) => bindPledge(p, store.delete(key)), store, key) + +/** + * @param {pledge.Pledge} store + * @param {String | number | ArrayBuffer | Date | boolean} item + * @param {String | number | ArrayBuffer | Date | Array} [key] + */ +export const put = (store, item, key) => pledge.createWithDependencies((p, store, item, key) => bindPledge(p, store.put(item, key)), store, item, key) + +/** + * @param {pledge.Pledge} store + * @param {String | number | ArrayBuffer | Date | boolean} item + * @param {String | number | ArrayBuffer | Date | Array} key + * @return {pledge.PledgeInstance} + */ +export const add = (store, item, key) => pledge.createWithDependencies((p, store, item, key) => bindPledge(p, store.add(item, key)), store, item, key) + +/** + * @param {pledge.Pledge} store + * @param {String | number | ArrayBuffer | Date} item + * @return {pledge.PledgeInstance} Returns the generated key + */ +export const addAutoKey = (store, item) => pledge.createWithDependencies((p, store, item) => bindPledge(p, store.add(item)), store, item) + +/** + * @param {pledge.Pledge} store + * @param {IDBKeyRange} [range] + * @param {number} [limit] + * @return {pledge.PledgeInstance>} + */ +export const getAll = (store, range, limit) => pledge.createWithDependencies((p, store, range, limit) => bindPledge(p, store.getAll(range, limit)), store, range, limit) + +/** + * @param {pledge.Pledge} store + * @param {IDBKeyRange} [range] + * @param {number} [limit] + * @return {pledge.PledgeInstance>} + */ +export const getAllKeys = (store, range, limit) => pledge.createWithDependencies((p, store, range, limit) => bindPledge(p, store.getAllKeys(range, limit)), store, range, limit) + +/** + * @param {IDBObjectStore} store + * @param {IDBKeyRange|null} query + * @param {'next'|'prev'|'nextunique'|'prevunique'} direction + * @return {pledge.PledgeInstance} + */ +export const queryFirst = (store, query, direction) => { + /** + * @type {any} + */ + let first = null + return iterateKeys(store, query, key => { + first = key + return false + }, direction).map(() => first) +} + +/** + * @param {IDBObjectStore} store + * @param {IDBKeyRange?} [range] + * @return {pledge.PledgeInstance} + */ +export const getLastKey = (store, range = null) => queryFirst(store, range, 'prev') + +/** + * @param {IDBObjectStore} store + * @param {IDBKeyRange?} [range] + * @return {pledge.PledgeInstance} + */ +export const getFirstKey = (store, range = null) => queryFirst(store, range, 'next') + +/** + * @typedef KeyValuePair + * @type {Object} + * @property {any} k key + * @property {any} v Value + */ + +/** + * @param {pledge.Pledge} store + * @param {pledge.Pledge} [range] + * @param {pledge.Pledge} [limit] + * @return {pledge.PledgeInstance>} + */ +export const getAllKeysValues = (store, range, limit) => pledge.createWithDependencies((p, store, range, limit) => { + pledge.all([getAllKeys(store, range, limit), getAll(store, range, limit)]).map(([ks, vs]) => ks.map((k, i) => ({ k, v: vs[i] }))).whenResolved(p.resolve.bind(p)) +}, store, range, limit) + +/** + * @param {pledge.PledgeInstance} p + * @param {any} request + * @param {function(IDBCursorWithValue):void|boolean|Promise} f + */ +const iterateOnRequest = (p, request, f) => { + request.onerror = p.cancel.bind(p) + /** + * @param {any} event + */ + request.onsuccess = async event => { + const cursor = event.target.result + if (cursor === null || (await f(cursor)) === false) { + p.resolve(undefined) + return + } + cursor.continue() + } +} + +/** + * Iterate on keys and values + * @param {pledge.Pledge} store + * @param {pledge.Pledge} keyrange + * @param {function(any,any):void|boolean|Promise} f Callback that receives (value, key) + * @param {'next'|'prev'|'nextunique'|'prevunique'} direction + */ +export const iterate = (store, keyrange, f, direction = 'next') => pledge.createWithDependencies((p, store, keyrange) => { + iterateOnRequest(p, store.openCursor(keyrange, direction), cursor => f(cursor.value, cursor.key)) +}, store, keyrange) + +/** + * Iterate on the keys (no values) + * + * @param {pledge.Pledge} store + * @param {pledge.Pledge} keyrange + * @param {function(any):void|boolean|Promise} f callback that receives the key + * @param {'next'|'prev'|'nextunique'|'prevunique'} direction + */ +export const iterateKeys = (store, keyrange, f, direction = 'next') => pledge.createWithDependencies((p, store, keyrange) => { + iterateOnRequest(p, store.openKeyCursor(keyrange, direction), cursor => f(cursor.key)) +}, store, keyrange) + +/** + * Open store from transaction + * @param {IDBTransaction} t + * @param {String} store + * @returns {IDBObjectStore} + */ +export const getStore = (t, store) => t.objectStore(store) + +/** + * @param {any} lower + * @param {any} upper + * @param {boolean} lowerOpen + * @param {boolean} upperOpen + */ +export const createIDBKeyRangeBound = (lower, upper, lowerOpen, upperOpen) => IDBKeyRange.bound(lower, upper, lowerOpen, upperOpen) + +/** + * @param {any} upper + * @param {boolean} upperOpen + */ +export const createIDBKeyRangeUpperBound = (upper, upperOpen) => IDBKeyRange.upperBound(upper, upperOpen) + +/** + * @param {any} lower + * @param {boolean} lowerOpen + */ +export const createIDBKeyRangeLowerBound = (lower, lowerOpen) => IDBKeyRange.lowerBound(lower, lowerOpen) + +/* c8 ignore stop */ diff --git a/indexeddbV2.test.js b/indexeddbV2.test.js new file mode 100644 index 0000000..d65a9f3 --- /dev/null +++ b/indexeddbV2.test.js @@ -0,0 +1,118 @@ +import * as t from './testing.js' +import * as idb from './indexeddbV2.js' +import * as pledge from './pledge.js' +import { isBrowser } from './environment.js' + +/* c8 ignore next */ +/** + * @param {IDBDatabase} db + */ +const initTestDB = db => idb.createStores(db, [['test', { autoIncrement: true }]]) +const testDBName = 'idb-test' + +/* c8 ignore next */ +/** + * @param {pledge.Pledge} db + */ +const createTransaction = db => pledge.createWithDependencies((p, db) => p.resolve(db.transaction(['test'], 'readwrite')), db) + +/* c8 ignore next */ +/** + * @param {pledge.Pledge} t + * @return {pledge.PledgeInstance} + */ +const getStore = t => pledge.createWithDependencies((p, t) => p.resolve(idb.getStore(t, 'test')), t) + +/* c8 ignore next */ +export const testRetrieveElements = async () => { + t.skip(!isBrowser) + t.describe('create, then iterate some keys') + await idb.deleteDB(testDBName).promise() + const db = idb.openDB(testDBName, initTestDB) + const transaction = createTransaction(db) + const store = getStore(transaction) + await idb.put(store, 0, ['t', 1]).promise() + await idb.put(store, 1, ['t', 2]).promise() + const expectedKeys = [['t', 1], ['t', 2]] + const expectedVals = [0, 1] + const expectedKeysVals = [{ v: 0, k: ['t', 1] }, { v: 1, k: ['t', 2] }] + t.describe('idb.getAll') + const valsGetAll = await idb.getAll(store).promise() + t.compare(valsGetAll, expectedVals) + t.describe('idb.getAllKeys') + const valsGetAllKeys = await idb.getAllKeys(store).promise() + t.compare(valsGetAllKeys, expectedKeys) + t.describe('idb.getAllKeysVals') + const valsGetAllKeysVals = await idb.getAllKeysValues(store).promise() + t.compare(valsGetAllKeysVals, expectedKeysVals) + + /** + * @param {string} desc + * @param {IDBKeyRange?} keyrange + */ + const iterateTests = async (desc, keyrange) => { + t.describe(`idb.iterate (${desc})`) + /** + * @type {Array<{v:any,k:any}>} + */ + const valsIterate = [] + await idb.iterate(store, keyrange, (v, k) => { + valsIterate.push({ v, k }) + }).promise() + t.compare(valsIterate, expectedKeysVals) + t.describe(`idb.iterateKeys (${desc})`) + /** + * @type {Array} + */ + const keysIterate = [] + await idb.iterateKeys(store, keyrange, key => { + keysIterate.push(key) + }).promise() + t.compare(keysIterate, expectedKeys) + } + await iterateTests('range=null', null) + const range = idb.createIDBKeyRangeBound(['t', 1], ['t', 2], false, false) + // adding more items that should not be touched by iteration with above range + await idb.put(store, 2, ['t', 3]).promise() + await idb.put(store, 2, ['t', 0]).promise() + await iterateTests('range!=null', range) + + t.describe('idb.get') + const getV = await idb.get(store, ['t', 1]).promise() + t.assert(getV === 0) + t.describe('idb.del') + await idb.del(store, ['t', 0]).promise() + const getVDel = await idb.get(store, ['t', 0]).promise() + t.assert(getVDel === undefined) + t.describe('idb.add') + await idb.add(store, 99, 42).promise() + const idbVAdd = await idb.get(store, 42).promise() + t.assert(idbVAdd === 99) + t.describe('idb.addAutoKey') + const key = await idb.addAutoKey(store, 1234).promise() + const retrieved = await idb.get(store, key).promise() + t.assert(retrieved === 1234) +} + +/* c8 ignore next */ +export const testBlocked = async () => { + t.skip(!isBrowser) + t.describe('ignore blocked event') + await idb.deleteDB(testDBName).map(() => { + const db = idb.openDB(testDBName, initTestDB) + const transaction = createTransaction(db) + const store = getStore(transaction) + return pledge.all({ + _req1: idb.put(store, 0, ['t', 1]), + _req2: idb.put(store, 1, ['t', 2]), + db + }) + }).map(({ db }) => { + db.close() + return idb.deleteDB(testDBName) + }).promise() +} + +export const testPerf = async () => { + t.measureTime('resolve 1000 wait pledges') +} diff --git a/object.js b/object.js index 149448c..7497e19 100644 --- a/object.js +++ b/object.js @@ -47,11 +47,18 @@ export const map = (obj, f) => { } /** + * @deprecated use object.size instead * @param {Object} obj * @return {number} */ export const length = obj => keys(obj).length +/** + * @param {Object} obj + * @return {number} + */ +export const size = obj => keys(obj).length + /** * @param {Object} obj * @param {function(any,string):boolean} f diff --git a/pledge.js b/pledge.js new file mode 100644 index 0000000..f7bbe24 --- /dev/null +++ b/pledge.js @@ -0,0 +1,259 @@ +import * as object from './object.js' + +/** + * @template V + * @typedef {V | PledgeInstance} Pledge + */ + +/** + * @template {any} Val + * @template {any} [CancelReason=Error] + */ +export class PledgeInstance { + constructor () { + /** + * @type {Val | CancelReason | null} + */ + this._v = null + this.isResolved = false + /** + * @type {Array | null} + */ + this._whenResolved = [] + /** + * @type {Array | null} + */ + this._whenCanceled = [] + } + + get isDone () { + return this._whenResolved === null + } + + get isCanceled () { + return !this.isResolved && this._whenResolved === null + } + + /** + * @param {Val} v + */ + resolve (v) { + const whenResolved = this._whenResolved + if (whenResolved === null) return + this._v = v + this.isResolved = true + this._whenResolved = null + this._whenCanceled = null + for (let i = 0; i < whenResolved.length; i++) { + whenResolved[i](v) + } + } + + /** + * @param {CancelReason} reason + */ + cancel (reason) { + const whenCanceled = this._whenCanceled + if (whenCanceled === null) return + this._v = reason + this._whenResolved = null + this._whenCanceled = null + for (let i = 0; i < whenCanceled.length; i++) { + whenCanceled[i](reason) + } + } + + /** + * @template R + * @param {function(Val):Pledge} f + * @return {PledgeInstance} + */ + map (f) { + /** + * @type {PledgeInstance} + */ + const p = new PledgeInstance() + this.whenResolved(v => { + const result = f(v) + if (result instanceof PledgeInstance) { + if (result._whenResolved === null) { + result.resolve(/** @type {R} */ (result._v)) + } else { + result._whenResolved.push(p.resolve.bind(p)) + } + } else { + p.resolve(result) + } + }) + return p + } + + /** + * @param {function(Val):void} f + */ + whenResolved (f) { + if (this.isResolved) { + f(/** @type {Val} */ (this._v)) + } else { + this._whenResolved?.push(f) + } + } + + /** + * @param {(reason: CancelReason) => void} f + */ + whenCanceled (f) { + if (this.isCanceled) { + f(/** @type {CancelReason} */ (this._v)) + } else { + this._whenCanceled?.push(f) + } + } + + /** + * @return {Promise} + */ + promise () { + return new Promise((resolve, reject) => { + this.whenResolved(resolve) + this.whenCanceled(reject) + }) + } +} + +/** + * @template T + * @return {PledgeInstance} + */ +export const create = () => new PledgeInstance() + +/** + * @typedef {Array> | Object>} PledgeMap + */ + +/** + * @template {Pledge | PledgeMap} P + * @typedef {P extends PledgeMap ? { [K in keyof P]: P[K] extends Pledge ? V : P[K]} : (P extends Pledge ? V : never)} Resolved

+ */ + +/** + * @todo Create a "resolveHelper" that will simplify creating indxeddbv2 functions. Double arguments + * are not necessary. + * + * @template V + * @template {Array>} DEPS + * @param {(p: PledgeInstance, ...deps: Resolved) => void} init + * @param {DEPS} deps + * @return {PledgeInstance} + */ +export const createWithDependencies = (init, ...deps) => { + /** + * @type {PledgeInstance} + */ + const p = new PledgeInstance() + // @ts-ignore @todo remove this + all(deps).whenResolved(ds => init(p, ...ds)) + return p +} + +/** + * @template R + * @param {Pledge} p + * @param {function(R):void} f + */ +export const whenResolved = (p, f) => { + if (p instanceof PledgeInstance) { + p.whenResolved(f) + } else { + f(p) + } +} + +/** + * @template {Pledge} P + * @param {P} p + * @param {P extends PledgeInstance ? function(CancelReason):void : function(any):void} f + */ +export const whenCanceled = (p, f) => { + if (p instanceof PledgeInstance) { + p.whenCanceled(f) + } +} + +/** + * @template {PledgeMap} PS + * @param {PS} ps + * @return {PledgeInstance>} + */ +export const all = ps => { + /** + * @type {any} + */ + const pall = create() + /** + * @type {any} + */ + const result = ps instanceof Array ? new Array(ps.length) : {} + let waitingPs = ps instanceof Array ? ps.length : object.size(ps) + for (const key in ps) { + const p = ps[key] + whenResolved(p, r => { + result[key] = r + if (--waitingPs === 0) { + // @ts-ignore + pall.resolve(result) + } + }) + } + return pall +} + +/** + * @template T + * @param {Pledge} p + * @param {function(T):Pledge} f + */ +export const map = (p, f) => { + if (p instanceof PledgeInstance) { + return p.map(f) + } + return f(p) +} + +/** + * @template Result + * @template {any} YieldResults + * @param {() => Generator | PledgeInstance, Result, any>} f + * @return {PledgeInstance} + */ +export const coroutine = f => { + const p = create() + const gen = f() + /** + * @param {any} [yv] + */ + const handleGen = (yv) => { + const res = gen.next(yv) + if (res.done) { + p.resolve(res.value) + return + } + // @ts-ignore + whenCanceled(res.value, (reason) => { + gen.throw(reason) + }) + whenResolved(res.value, handleGen) + } + handleGen() + return p +} + +/** + * @param {number} timeout + * @return {PledgeInstance} + */ +export const wait = timeout => { + const p = create() + setTimeout(p.resolve.bind(p), timeout) + return p +} diff --git a/pledge.test.js b/pledge.test.js new file mode 100644 index 0000000..278633e --- /dev/null +++ b/pledge.test.js @@ -0,0 +1,60 @@ +import * as t from './testing.js' +import * as pledge from './pledge.js' +import * as promise from './promise.js' + +/** + * @param {t.TestCase} _tc + */ +export const testPledgeCoroutine = async _tc => { + let called = false + const p = pledge.coroutine(function * () { + const y = pledge.wait(10).map(() => 42) + const num = yield y + console.log({ num }) + t.assert(num === 42) + called = true + return 42 + }) + t.assert(!called) + await p.promise() + t.assert(called) +} + +/** + * @param {t.TestCase} _tc + */ +export const testPledgeVsPromisePerformanceTimeout = async _tc => { + const iterations = 1000 + const waitTime = 0 + await t.measureTimeAsync(`Awaiting ${iterations} callbacks (pledge)`, () => + pledge.coroutine(function * () { + for (let i = 0; i < iterations; i++) { + yield pledge.wait(waitTime) + } + }).promise() + ) + await t.measureTimeAsync(`Awaiting ${iterations} callbacks (promise)`, async () => { + for (let i = 0; i < iterations; i++) { + await promise.wait(waitTime) + } + }) +} + +/** + * @param {t.TestCase} _tc + */ +export const testPledgeVsPromisePerformanceResolved = async _tc => { + const iterations = 100000 + await t.measureTimeAsync(`Awaiting ${iterations} callbacks (promise)`, async () => { + for (let i = 0; i < iterations; i++) { + await promise.resolve(0) + } + }) + await t.measureTimeAsync(`Awaiting ${iterations} callbacks (pledge)`, () => + pledge.coroutine(function * () { + for (let i = 0; i < iterations; i++) { + yield 0 + } + }).promise() + ) +} diff --git a/prng.test.js b/prng.test.js index fba1300..0749223 100644 --- a/prng.test.js +++ b/prng.test.js @@ -12,10 +12,10 @@ import * as math from './math.js' const genTestData = 5000 /** - * @param {t.TestCase} tc + * @param {t.TestCase} _tc * @param {prng.PRNG} gen */ -const runGenTest = (tc, gen) => { +const runGenTest = (_tc, gen) => { t.group('next - average distribution', () => { let sum = 0 for (let i = 0; i < genTestData; i++) { @@ -207,9 +207,9 @@ export const testGeneratorMt19937 = tc => { /* c8 ignore next */ /** * @param {prng.PRNG} gen - * @param {t.TestCase} tc + * @param {t.TestCase} _tc */ -const printDistribution = (gen, tc) => { +const printDistribution = (gen, _tc) => { const DIAMETER = genTestData / 50 const canvas = dom.canvas(DIAMETER * 3, DIAMETER) const ctx = canvas.getContext('2d') diff --git a/test.js b/test.js index b40ff7b..13723e9 100644 --- a/test.js +++ b/test.js @@ -10,6 +10,7 @@ import * as encoding from './encoding.test.js' import * as diff from './diff.test.js' import * as testing from './testing.test.js' import * as indexeddb from './indexeddb.test.js' +import * as indexeddbV2 from './indexeddbV2.test.js' import * as prng from './prng.test.js' import * as log from 'lib0/logging' import * as statistics from './statistics.test.js' @@ -23,6 +24,7 @@ import * as time from './time.test.js' import * as pair from './pair.test.js' import * as object from './object.test.js' import * as observable from './observable.test.js' +import * as pledge from './pledge.test.js' import * as math from './math.test.js' import * as number from './number.test.js' import * as buffer from './buffer.test.js' @@ -55,6 +57,7 @@ runTests({ diff, testing, indexeddb, + indexeddbV2, prng, statistics, binary, @@ -67,6 +70,7 @@ runTests({ pair, object, observable, + pledge, math, number, buffer,