UNPKG

@hydre/disk

Version:

Store and query your GraphQL types in Redis

214 lines (176 loc) 6.42 kB
import { v4 as uuid4 } from 'uuid' import Parser from './Parser.js' import Call from './Call.js' const complex_query = ( namespace, { keys, fields, limit = 0, offset = 0, search } = {}, ) => [ 'FT.SEARCH', namespace, search, ...keys ? ['INKEYS', keys.length, keys] : [], ...fields?.length ? ['RETURN', fields.length, fields] : [], ...limit > 0 ? ['LIMIT', offset, Math.max(1, limit)] : [], ] const proxify = handle => new Proxy(Object.create(null), { get: (_, namespace) => query => { if (query?.fields) { // we always want the uuid inside a result query.fields = [...new Set([...query.fields, 'uuid']).values()] } return handle(namespace, query) }, }) const adequate_command = requested_fields => { // no need to check if the array length is 0 because // as we always force include the `uuid`, it can never be 0 if (!requested_fields) return 'HGETALL' if (requested_fields.length === 1) return 'HGET' return 'HMGET' } export default ({ master_client, slave_client, events_enabled = false, events_name = '__disk__', } = {}) => { const call = Call(master_client, slave_client) const keys = async (namespace, query = {}) => { if (query.search) { const [, ...ids] = await call.one([ complex_query(namespace, query), 'NOCONTENT', ]) return ids } const { keys: search_keys = [], limit = Infinity, offset = 0 } = query if (!search_keys.length) { // if no limit we return the whole db if (limit === Infinity) return call.one(['KEYS', `${ namespace }:*`]) const scan_database = async (cursor, all = []) => { const operation = ['SCAN', cursor, 'MATCH', `${ namespace }:*`] const [next_cursor, scan_keys] = await call.one(operation) const concat = [...all, ...scan_keys] if (concat.length >= limit) return concat.slice(0, limit) if (+next_cursor) return scan_database(+next_cursor, concat) return concat } return scan_database(0) } return search_keys.slice(offset, limit) } return { KEYS : proxify(keys), CREATE: proxify(async (namespace, { document }) => { const uuid = `${ namespace }:${ uuid4() }` document.uuid = uuid const filter_nulls = ([, value]) => value !== undefined && value !== null const entries = Object.entries(document).filter(filter_nulls) await call.one(['HSET', uuid, entries]) try { // trying to index manually a created hash (may not have any index) // https://github.com/RediSearch/RediSearch/issues/1287#issuecomment-646511427 // we can remove this in the next redisearch version await call.one(['FT.ADD', namespace, uuid, 1, 'FIELDS', entries]) } catch { // we don't care if it works or not } if (events_enabled) { const cmd = ['PUBLISH', `${ events_name }:CREATE:${ namespace }`, uuid] await call.one(cmd) } return uuid }), GET: proxify(async (namespace, query) => { // presence of search means we can't use regular HMGET if (query.search) { const [, ...results] = await call.one(complex_query(namespace, query)) return Parser.array_result(results) } /* c8 ignore next 13 */ // this is actually covered but c8 doesn't pick it because of the // await keys, weird const { keys: skeys = await keys(namespace, query), fields, limit = Infinity, offset = 0, } = query const command = adequate_command(fields) const commands = skeys .slice(offset, limit) .map(key => [command, key, ...fields?.length ? fields : []]) const results = await call.many(commands) if (command === 'HGETALL') // seems that the redis client already map HGETALL to an object return results.map(Parser.node) if (command === 'HGET') // HGET returns a direct value response return results.filter(x => !!x).map(result => ({ [fields[0]]: result })) // HMGET return an array response of values const to_entry = (value, i) => [fields[i], Parser.value(value)] return ( results // filtering out not found documents .filter(values => !values.every(x => x === null)) .map(values => Object.fromEntries(values.map(to_entry))) ) }), SET: proxify(async (namespace, query = {}) => { const { document } = query const ids = await keys(namespace, query) if (!ids.length) return [] const fields_to_delete = new Set() const entries = Object.entries(document).filter(([key, value]) => { if (value === undefined || value === null) { fields_to_delete.add(key) return false } return true }) const deletion_values = [...fields_to_delete.values()] if (deletion_values.length) await call.many(ids.map(id => ['HDEL', id, ...deletion_values])) const make_result = () => { if (query.search) { const head = ['FT.ADD', namespace] const tail = [1, 'REPLACE', 'PARTIAL', 'FIELDS', entries] return call.many(ids.map(id => [...head, id, ...tail])) } return call.many(ids.map(id => ['HSET', id, entries])) } const result = await make_result() if (events_enabled) { const to_operation = id => [ 'PUBLISH', `${ events_name }:SET:${ namespace }`, id, ] await call.many(ids.map(to_operation)) } return result }), DELETE: proxify(async (namespace, query) => { const ids = await keys(namespace, query) if (!ids.length) return 0 if (events_enabled) { const to_operation = id => [ 'PUBLISH', `${ events_name }:DELETE:${ namespace }`, id, ] await call.many(ids.map(to_operation)) } try { // trying to index manually a created hash (may not have any index) // https://github.com/RediSearch/RediSearch/issues/1287#issuecomment-646511427 // we can remove this in the next redisearch version await call.many(ids.map(id => ['FT.DEL', namespace, id])) } catch { // we don't care if it works or not } return call.one(['DEL', ...ids]) }), } }