diff --git a/README.md b/README.md index 0e678c8..8234a5c 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Built to answer the question, "how can I have a queryable, browsable db that als ## Goals -- **Simple**: less than 500 lines. Only one dependency, `ioredis`. You can copy-paste the code instead if you want. -- **Serverless-friendly**: no Redis modules, only core Redis. +- **Simple**: less than 500 lines, and no dependencies. You can copy-paste the code instead if you want. +- **Serverless-friendly**: no Redis modules, only core Redis. Multiple Redis clients supported, including REST-based ones. - **Fast**: Compared to optimized Postgres, 150% faster at paginating unindexed data. See [all benchmarks](#benchmarks) below. - **Indexable**: Supports hierarchical [tags](#tags), a lightweight primitive for indexing your data. - **Browsable**: [browser-friendly API](#example-browsing-your-data) included for paginating and browsing by tag. @@ -37,7 +37,10 @@ Exploration API: - [Redbase](#redbase) - [Goals](#goals) - [Install](#install) + - [Redis Client Compatibility](#redis-client-compatibility) - [Usage](#usage) + - [One-to-Many Relationships](#one-to-many-relationships) + - [Many-to-Many Relationships](#many-to-many-relationships) - [Core concepts](#core-concepts) - [Entries](#entries) - [Tags](#tags) @@ -47,6 +50,8 @@ Exploration API: - [For the cache use-case](#for-the-cache-use-case) - [For the database use-case](#for-the-database-use-case) - [Results](#results) + - [Contributing](#contributing) + - [Writing Adapters](#writing-adapters) - [License](#license) ## Install @@ -55,6 +60,13 @@ Exploration API: npm install redbase ``` +### Redis Client Compatibility + +Redbase can support arbitrary Redis clients through the use of [custom adapters](#writing-adapters). The current clients supported are: + +- [ioredis](https://github.com/luin/ioredis) +- [redis](https://github.com/redis/node-redis) + ## Usage ```ts @@ -68,8 +80,8 @@ type MyValue = { } } -// Options can also use your own ioredis instance if already defined, -// as `redisInstance` +// Options can also use your own Redis instance if already defined, +// as `redis` const db = new Redbase('myProject', { redisUrl: 'redis://...' }) const key = uuid() @@ -77,12 +89,12 @@ const value = { a: 'result' } await db.get(key) // undefined -await db.set(key, value) +await db.save(key, value) await db.get(key) // value // Type safety! -await db.set(key, { c: 'result2' }) // Type error on value +await db.save(key, { c: 'result2' }) // Type error on value // Browsing results let data = await db.filter() @@ -92,9 +104,9 @@ assertEqual(await db.count(), 1) // Hierarchical indexes, using a customizable tag separator (default: '/') await Promise.all([ // Redis auto-pipelines these calls into one fast request! - db.set(uuid(), { a: 'hi' }, { tags: ['user1/project1'] }), - db.set(uuid(), { a: 'there' }, { tags: ['user1/project2'] }), - db.set(uuid(), { a: 'bye' }, { tags: ['user2/project1'] }) + db.save(uuid(), { a: 'hi' }, { tags: ['user1/project1'] }), + db.save(uuid(), { a: 'there' }, { tags: ['user1/project2'] }), + db.save(uuid(), { a: 'bye' }, { tags: ['user2/project1'] }) ]) data = await db.filter() @@ -108,8 +120,8 @@ assertEqual(data.length, 2) data = await db.filter({ where: {OR: ['user1', 'user2']}}) assertEqual(data.length, 3) -const count = await db.count({ where: {OR: ['user1', 'user2']}}) -assertEqual(count, 3) +const count = await db.count({ where: {AND: ['user1', 'user2']}}) +assertEqual(count, 0) // See all your indexes: const tags = await db.tags("user1") @@ -120,6 +132,75 @@ const numberDeleted = await db.clear({ where: 'user2' }) assertEqual(numberDeleted, 1) ``` +### One-to-Many Relationships + +Let's say you have User and Post interfaces that look like this: +```ts +interface User { + id: number + name: string +} +interface Post { + content: string + userId: number +} +``` + +Now we want to store this data in Redbase. Rather than store all posts in an array inside of each `User` (which you might do in a NoSQL database), you can simply create separate Redbase clients for the two interfaces: + +```ts +// Slightly more efficient to share the same redis instance: +import { IORedis } from 'redbase' +const redis = new IORedis() +const users = new Redbase('myProject-user', { redis }) +const posts = new Redbase('myProject-post', { redis }) +``` + +When inserting a new Post for a given `userId`, make sure you tag it: + +```ts +await posts.save(uuid(), post, { tags: [`user-${userId}`] }) +``` + +Now we can query all posts for a given user: + +```ts +const userPosts = await posts.filter({ where: `user-${userId}`, limit: 100 }) +``` + +Note that tags are simple and don't have contraints; if the user's id changes, you have to re-save each of its posts with the new tag. + +### Many-to-Many Relationships + +Let's say you have Posts and Categories that look like this: + +```ts +interface Post { + id: number +} +interface Category { + name: string +} +``` + +For many-to-many relationships, e.g. `Post` <-> `Category`, you can make the save atomic inside a Redis transaction by calling `multiSet` instead of `save`: + +```ts +const redis = new IORedis() +const categories = new Redbase('myProject-category', { redis }) +const posts = new Redbase('myProject-post', { redis }) + +const post = { id, ... } +const categoryNames = ['tech', 'draft', ...] + +let multi = redis.multi() +multi = posts.multiSet(multi, post.postId, post, { tags: categoryNames }) +for (const name of categoryNames) { + multi = categories.multiSet(name, { name }, { tags: [`post-${post.id}`] }) +} +await multi.exec() +``` + For all functionality, see `test/database.spec.ts`. ## Core concepts @@ -135,18 +216,19 @@ An entry is composed of an `id` and a `value`: ### Tags -Tags are a lightweight primitive for indexing your values. You attach them at insert-time, and they are schemaless. This makes them simple and flexible for many use cases. It also allows you to index values by external attributes (that aren't a part of the value itself: [example](#example-prompt-cache)). +Tags are a lightweight primitive for indexing your values. You attach them at insert-time, and they are schemaless. This makes them simple and flexible for many use cases: -Calling `db.filter({ where: { AND: [...]}})` etc. allows you to compose them together as a list. +- Tags allow you to index values by external attributes (that aren't a part of the value itself: [example](#example-prompt-cache)). +- Tags allow you to link values between Redbase instances in [one-to-many relationships](#one-to-many-relationships), or even have [many-to-many relationships](#many-to-many-relationships) between data types. You can shard data between multiple Redis instances this way. +- Tags can be nested with a chosen separator, e.g. `parentindex/childindex`. This effectively allows you to group your indexes, and makes [browsing](#example-browsing-your-data) your data easier and more fun. -Tags are sort of self-cleaning: indexes delete themselves during bulk-delete operations, and they shrink when entries are deleted individually. However, when the last entry for an index is deleted, the index is not. This shouldn't cause a significant zombie index issue unless you're creating and wiping out an unbounded number of tags. +Calling `db.filter({ where: { AND: [...]}})` etc. allows you to intersect tags together, and doing the same with `OR` allows you to union them. -Tags can get unruly, you you can keep them organized by nesting them: -`parentindex/childindex`. This effectively allows you to group your indexes, and makes [browsing](#example-browsing-your-data) your data easier and more fun. +Call `db.tags(parentIndex)` to get all the children tags of `parentIndex`. -Call `db.tags("parentindex")` to get all the children tags. +Tags are sort of self-cleaning: indexes delete themselves during bulk-delete operations, and they shrink when entries are deleted individually. However, when the last entry for an index is deleted, the index is not. This shouldn't cause a significant zombie index issue unless you're creating and wiping out an unbounded number of tags. -As you might expect, when indexing an entry under `parentindex/childindex` the entry is automatically indexed under `parentindex` as well. This makes it easy to build a url-based [cache exploration server](#example-browsing-your-data). Call `db.filter({ where: 'parentindex' })` to get all entries for all children tags. +Tags can get unruly, so you can keep them organized by nesting them. As you might expect, when indexing an entry under `parentindex/childindex`, the entry is automatically indexed under `parentindex` as well. This makes it easy to build a url-based [cache exploration server](#example-browsing-your-data). Call `db.filter({ where: parentIndex })` to get all entries for all children tags under `parentIndex`. ### Example: Prompt Cache @@ -193,7 +275,7 @@ To browse, paginate, filter, and delete your data directly from a browser, just **Note:** I'm very new to benchmarking open-sourced code, and would appreciate pull requests here! One issue, for example, is that increasing the number of runs can cause the data to scale up (depending on which benchmarks you're running), which seems to e.g. make Redis win on pagination by a larger margin. -This project uses [hyperfine](https://github.com/sharkdp/hyperfine) to compare Redis in a persistent mode with Postgres in an optimized mode. **Yes, this is comparing apples to oranges.** I decided to do it anyway because: +These benchmarks use [hyperfine](https://github.com/sharkdp/hyperfine) and compare Redis in a persistent mode with Postgres in an optimized mode. Yes, this is still comparing apples to oranges in a sense, but I decided to do it anyway because: 1. A big question this project answers is "how can I have a queryable, browsable db that also works well as a cache?" Redis and Postgres are two backend choices that pop up frequently. @@ -219,16 +301,24 @@ Comment-out the call to `ALTER DATABASE ... SET synchronous_commit=OFF;` in `/be ### Results - **Inserting data**: Tie -- **Paginating unindexed data**: Redis is ~150% faster +- **Paginating unindexed data**: Redbase is ~150% faster - **Single-index pagination**: Postgres is ~50% faster - **Joint-index pagination**: Postgres is ~60% faster -- **Inserting and deleting data**: Redis is ~25% faster +- **Inserting and deleting data**: Redbase is ~25% faster Results on Apple M1 Max, 2021: ![Insert and scroll](files/insert_and_scroll.png) ![Scroll along an index](files/index_scrolling.png) ![Delete data](files/deleting.png) +## Contributing + +See open pull requests and issues on [Github](https://github.com/alexanderatallah/redbase). + +### Writing Adapters + +You can add support for new [Redis clients](#redis-client-compatibility) by writing an adapter. Adapters are located in `src/adapters/`. It's easy to duplicate one and adjust. + ## License MIT diff --git a/src/adapters/base.ts b/src/adapters/base.ts new file mode 100644 index 0000000..e5c326f --- /dev/null +++ b/src/adapters/base.ts @@ -0,0 +1,49 @@ +export const defaultErrorHandler = (err: unknown): void | never => { + // Screetching halt failure by default + throw err +} + +export type RawValue = string | number | Buffer +export type AggregationMode = 'SUM' | 'MIN' | 'MAX' +export type OrderingMode = 'ASC' | 'DESC' +export type Score = number | '-inf' | '+inf' + +export abstract class RedisMultiAdapter { + abstract set(key: string, value: RawValue): RedisMultiAdapter + abstract expire(key: string, ttl: number): RedisMultiAdapter + abstract sadd(key: string, values: RawValue[]): RedisMultiAdapter + abstract zadd( + key: string, + scores: Score[], + members: RawValue[] + ): RedisMultiAdapter + abstract exec(): Promise + abstract del(keys: string[]): RedisMultiAdapter + abstract zrem(key: string, values: RawValue[]): RedisMultiAdapter + abstract zunionstore( + destination: string, + keys: string[], + aggregate?: AggregationMode + ): RedisMultiAdapter + abstract zinterstore( + destination: string, + keys: string[], + aggregate?: AggregationMode + ): RedisMultiAdapter +} + +export abstract class RedisAdapter { + abstract multi(): RedisMultiAdapter + abstract quit(): Promise + abstract ttl(key: string): Promise + abstract smembers(key: string): Promise + abstract zcount(key: string, min?: Score, max?: Score): Promise + abstract zrange( + key: string, + start: number, + stop: number, + order?: OrderingMode + ): Promise + abstract get(key: string): Promise + abstract del(keys: string[]): Promise +} diff --git a/src/adapters/ioredis.ts b/src/adapters/ioredis.ts new file mode 100644 index 0000000..75ed3cc --- /dev/null +++ b/src/adapters/ioredis.ts @@ -0,0 +1,151 @@ +import Redis, { ChainableCommander } from 'ioredis' +import { + RedisAdapter, + defaultErrorHandler, + RedisMultiAdapter, + RawValue, + AggregationMode, + OrderingMode, + Score, +} from './base' +const DEFAULT_URL = process.env['REDIS_URL'] || 'redis://localhost:6379' + +export class IORedisMulti extends RedisMultiAdapter { + multi: ChainableCommander + errorHandler: (err: unknown) => void + + constructor(origRedis: Redis, errorHandler = defaultErrorHandler) { + super() + this.multi = origRedis.multi() + this.errorHandler = errorHandler + } + + set(key: string, value: RawValue) { + this.multi = this.multi.set(key, value) + return this + } + + expire(key: string, ttl: number) { + this.multi = this.multi.expire(key, ttl) + return this + } + + sadd(key: string, values: RawValue[]) { + this.multi = this.multi.sadd(key, ...values) + return this + } + + zadd(key: string, scores: Score[], members: RawValue[]) { + const zipped = scores.flatMap((s, i) => [s, members[i]]) + this.multi = this.multi.zadd(key, ...zipped) + return this + } + + async exec() { + const res = await this.multi.exec() + if (!res || res.map(r => r[0]).filter(e => !!e).length) { + // Errors occurred during the exec, so record backend error + this.errorHandler(res) + } + } + + del(keys: string[]) { + this.multi = this.multi.del(...keys) + return this + } + + zrem(key: string, values: RawValue[]) { + this.multi = this.multi.zrem(key, ...values) + return this + } + + zunionstore( + destination: string, + keys: string[], + aggregate?: AggregationMode + ): RedisMultiAdapter { + const aggSettings = aggregate ? ['AGGREGATE', aggregate] : [] + this.multi = this.multi.zunionstore( + destination, + keys.length, + ...keys, + ...aggSettings + ) + return this + } + + zinterstore( + destination: string, + keys: string[], + aggregate?: AggregationMode + ): RedisMultiAdapter { + const aggSettings = aggregate ? ['AGGREGATE', aggregate] : [] + this.multi = this.multi.zinterstore( + destination, + keys.length, + ...keys, + ...aggSettings + ) + return this + } +} + +export class IORedis extends RedisAdapter { + origRedis: Redis + errorHandler: (err: unknown) => void + + constructor(url = DEFAULT_URL, errorHandler = defaultErrorHandler) { + super() + this.origRedis = new Redis(url, { + enableAutoPipelining: true, + }) + + this.errorHandler = errorHandler + this.origRedis.on('error', errorHandler) + } + + multi() { + return new IORedisMulti(this.origRedis, this.errorHandler) + } + + async quit() { + await this.origRedis.quit() + } + + async ttl(key: string) { + return this.origRedis.ttl(key) + } + + async get(key: string) { + return this.origRedis.get(key) + } + + async del(keys: string[]) { + return this.origRedis.del(...keys) + } + + async smembers(key: string): Promise { + return this.origRedis.smembers(key) + } + + async zcount( + key: string, + min: Score = '-inf', + max: Score = '+inf' + ): Promise { + return this.origRedis.zcount(key, min, max) + } + + async zrange( + key: string, + start: number, + stop: number, + order?: OrderingMode + ): Promise { + if (order === 'DESC') { + return this.origRedis.zrange(key, start, stop, 'REV') + } else { + return this.origRedis.zrange(key, start, stop) + } + } +} diff --git a/src/backend.ts b/src/backend.ts deleted file mode 100644 index d3c9051..0000000 --- a/src/backend.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Redis from 'ioredis' -const DEFAULT_URL = process.env['REDIS_URL'] || 'redis://localhost:6379' - -const defaultLogger = (err: unknown) => { - console.error('Redbase backend error', err) -} - -export function initRedis(url = DEFAULT_URL, errorLogger = defaultLogger) { - const redis = new Redis(url, { - enableAutoPipelining: true, - }) - - if (errorLogger) { - redis.on('error', errorLogger) - } - - return redis -} - -export type ExecT = [error: Error | null, result: unknown][] | null diff --git a/src/index.ts b/src/index.ts index 549955a..0b9ea2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ export { Redbase } from './redbase' -export { initRedis } from './backend' +export { IORedis } from './adapters/ioredis' diff --git a/src/redbase.ts b/src/redbase.ts index 09cf1b8..210d24c 100644 --- a/src/redbase.ts +++ b/src/redbase.ts @@ -1,13 +1,13 @@ -import { initRedis, ExecT } from './backend' -import { ChainableCommander, Redis } from 'ioredis' +import { IORedis } from './adapters/ioredis' +import { RedisAdapter, RedisMultiAdapter } from './adapters/base' import { Tag } from './tag' const GLOBAL_PREFIX = process.env['REDIS_PREFIX'] || '' const DEBUG = process.env['DEBUG'] === 'true' const AGG_TAG_TTL_BUFFER = 0.1 // seconds export interface Options { - redisInstance?: Redis // Redis instance to use. Defaults to undefined. - redisUrl?: string // Redis URL to use. Defaults to undefined. + redis?: RedisAdapter // Redis adapter to use. Defaults to IORedis. + redisUrl?: string // Redis URL to use. Defaults to REDIS_URL in the environment. defaultTTL?: number // Default expiration (in seconds) to use for each entry. Defaults to undefined. aggregateTagTTL?: number // TTL for computed query tags. Defaults to 10 seconds deletionPageSize?: number // Number of entries to delete at a time when calling "clear". Defaults to 2000. @@ -81,14 +81,14 @@ interface SaveParams { export class Redbase { public deletionPageSize: number - public redis: Redis + public redis: RedisAdapter private _name: string private _defaultTTL: number | undefined private _aggregateTagTTL: number constructor(name: string, opts: Options = {}) { - this.redis = opts.redisInstance || initRedis(opts.redisUrl) + this.redis = opts.redis || new IORedis(opts.redisUrl) this._defaultTTL = this._validateTTL(opts.defaultTTL) this._aggregateTagTTL = this._validateTTL(opts.aggregateTagTTL) || 10 // seconds this.deletionPageSize = opts.deletionPageSize || 2000 @@ -127,11 +127,31 @@ export class Redbase { return parsed } + /** + * Save an entry to the database, setting tags and expiration appropriately + * @param id caller-provided id of the entry + * @param value object value conforming to the database's type + * @param tags tags for this entry + */ async save( + id: string, + value: ValueT, + opts: SaveParams = {} + ): Promise { + let txn = this.redis.multi() + txn = this.multiSet(txn, id, value, opts) + await txn.exec() + } + + /** + * Similar to `save` but for setting an entry onto a Redis multi transaction + */ + multiSet( + multi: RedisMultiAdapter, id: string, value: ValueT, { tags, sortBy, ttl }: SaveParams = {} - ): Promise { + ): RedisMultiAdapter { if (!Array.isArray(tags)) { tags = [tags || ''] } @@ -139,7 +159,7 @@ export class Redbase { const score = sortBy ? sortBy(value) : new Date().getTime() const tagInstances = tags.map(p => Tag.fromPath(p)) - let txn = this.redis.multi().set(this._entryKey(id), JSON.stringify(value)) + let txn = multi.set(this._entryKey(id), JSON.stringify(value)) for (const tag of tagInstances) { txn = this._indexEntry(txn, tag, id, score) @@ -151,7 +171,7 @@ export class Redbase { if (ttl) { txn = txn.expire(this._entryKey(id), ttl) } - return txn.exec() + return txn } async clear({ where = '' }: ClearParams = {}): Promise { @@ -227,10 +247,7 @@ export class Redbase { offset, offset + limit - 1, // ZRANGE limits are inclusive ] - if (ordering === 'desc') { - args.push('REV') - } - return this.redis.zrange(...args) + return this.redis.zrange(...args, ordering === 'desc' ? 'DESC' : 'ASC') } async count({ @@ -245,7 +262,7 @@ export class Redbase { return this.redis.zcount(this._tagKey(computedTag), scoreMin, scoreMax) } - async delete(id: string): Promise { + async delete(id: string): Promise { const tagKey = this._entryTagsKey(id) if (DEBUG) { console.log( @@ -257,20 +274,20 @@ export class Redbase { // TODO Using unlink instead of del here doesn't seem to improve perf much let txn = this.redis.multi() - txn = txn.del(this._entryKey(id)) + txn = txn.del([this._entryKey(id)]) for (let tag of tags) { // Traverse child hierarchy while (tag.parent) { - txn = txn.zrem(this._tagKey(tag), id) + txn = txn.zrem(this._tagKey(tag), [id]) tag = tag.parent } // Root. Note that there might be duplicate zrem calls for shared parents, esp root - txn = txn.zrem(this._tagKey(tag), id) + txn = txn.zrem(this._tagKey(tag), [id]) } - txn = txn.del(tagKey) - return txn.exec() + txn = txn.del([tagKey]) + await txn.exec() } async ttl(id: string): Promise { @@ -278,7 +295,7 @@ export class Redbase { return ttl < 0 ? undefined : ttl } - async close(): Promise { + async close(): Promise { return this.redis.quit() } @@ -304,32 +321,29 @@ export class Redbase { offset, offset + limit - 1, // ZRANGE limits are inclusive ] - if (ordering === 'desc') { - args.push('REV') - } - return this.redis.zrange(...args) + return this.redis.zrange(...args, ordering === 'desc' ? 'DESC' : 'ASC') } _indexEntry( - txn: ChainableCommander, + txn: RedisMultiAdapter, tag: Tag, entryId: string, score: number ) { // Tag this tag under the entry - txn = txn.sadd(this._entryTagsKey(entryId), tag.name) + txn = txn.sadd(this._entryTagsKey(entryId), [tag.name]) // Traverse child hierarchy while (tag.parent) { // Tag the entry under this tag - txn = txn.zadd(this._tagKey(tag), score, entryId) + txn = txn.zadd(this._tagKey(tag), [score], [entryId]) // Register this tag under its parent - txn = txn.zadd(this._tagChildrenKey(tag.parent), 0, tag.name) + txn = txn.zadd(this._tagChildrenKey(tag.parent), [0], [tag.name]) // Move up the hierarchy tag = tag.parent } // We're at the root tag now - add the entry to it as well - txn = txn.zadd(this._tagKey(tag), score, entryId) + txn = txn.zadd(this._tagKey(tag), [score], [entryId]) return txn } @@ -437,31 +451,25 @@ export class Redbase { targetTagKey: string, tagKeys: string[], type: 'union' | 'intersection' - ): Promise { + ): Promise { let txn = this.redis.multi() if ((await this.redis.ttl(targetTagKey)) > AGG_TAG_TTL_BUFFER) { return txn } const methodName = type === 'union' ? 'zunionstore' : 'zinterstore' - txn = txn[methodName]( + txn = txn[methodName](targetTagKey, tagKeys, 'MIN').expire( targetTagKey, - tagKeys.length, - ...tagKeys, - 'AGGREGATE', - 'MIN' - ).expire(targetTagKey, this.aggregateTagTTL) + this.aggregateTagTTL + ) return txn } - _recursiveTagDeletion( - multi: ChainableCommander, - tag: Tag - ): ChainableCommander { - let ret = multi.del(this._tagKey(tag)) - const childtags = this.redis.zrange(this._tagChildrenKey(tag), 0, -1) + _recursiveTagDeletion(multi: RedisMultiAdapter, tag: Tag): RedisMultiAdapter { + let ret = multi.del([this._tagKey(tag)]) + const childtags = this.redis.zrange(this._tagChildrenKey(tag), 0, -1, 'ASC') for (const child in childtags) { ret = this._recursiveTagDeletion(ret, Tag.fromPath(child)) } - return ret.del(this._tagChildrenKey(tag)) + return ret.del([this._tagChildrenKey(tag)]) } } diff --git a/test/database.spec.ts b/test/database.spec.ts index 9f27ccb..cb69f2a 100644 --- a/test/database.spec.ts +++ b/test/database.spec.ts @@ -2,7 +2,7 @@ import { Redbase } from '../src' import { v4 as uuidv4 } from 'uuid' describe('Redbase', () => { - type ValueT = { answer: string; optional?: number[] } + type ValueT = { answer: string; optional?: number[]; id?: string } let db: Redbase let dbComplex: Redbase @@ -30,6 +30,11 @@ describe('Redbase', () => { // @ts-ignore expect(() => (db.name = 'dest')).toThrowError() }) + + it('should allow other clients to use the same Redis instance', () => { + const otherDb = new Redbase('Test-backup', { redis: db.redis }) + expect(db.redis).toStrictEqual(otherDb.redis) + }) }) describe('simple get/save/delete/clear', () => { @@ -83,6 +88,41 @@ describe('Redbase', () => { }) }) + describe('Foreign relations between databases', () => { + afterAll(async () => { + await db.clear() + await dbComplex.clear() + }) + + it('should query one-to-many relationships', async () => { + const userTag = 'user-1' + const obj = { answer: 'hello' } + await Promise.all([ + db.save(userTag, 'new user'), + dbComplex.save(uuidv4(), obj, { tags: [userTag] }), + ]) + const objs = await dbComplex.filter({ where: userTag }) + expect(objs[0].value).toEqual(obj) + }) + + it('should atomically save many-to-many relationships', async () => { + const categories = ['category1', 'category2'] + const obj = { answer: 'hello', id: uuidv4() } + let multi = db.redis.multi() + multi = dbComplex.multiSet(multi, obj.id, obj, { tags: categories }) + for (const name of categories) { + multi = db.multiSet(multi, name, name, { tags: [`obj-${obj.id}`] }) + } + await multi.exec() + + let results = await dbComplex.filter({ where: 'category1' }) + expect(results[0].value).toEqual(obj) + + results = await dbComplex.filter({ where: 'category2' }) + expect(results[0].value).toEqual(obj) + }) + }) + describe('expire entries', () => { afterAll(async () => { await db.clear()