diff --git a/packages/time-series/.nycrc.json b/packages/time-series/.nycrc.json new file mode 100644 index 0000000000..b4e671e178 --- /dev/null +++ b/packages/time-series/.nycrc.json @@ -0,0 +1,4 @@ +{ + "extends": "@istanbuljs/nyc-config-typescript", + "exclude": ["**/*.spec.ts", "lib/test-utils.ts"] +} diff --git a/packages/time-series/.release-it.json b/packages/time-series/.release-it.json new file mode 100644 index 0000000000..72cb1016ef --- /dev/null +++ b/packages/time-series/.release-it.json @@ -0,0 +1,10 @@ +{ + "git": { + "tagName": "search@${version}", + "commitMessage": "Release ${tagName}", + "tagAnnotation": "Release ${tagName}" + }, + "npm": { + "publishArgs": ["--access", "public"] + } +} diff --git a/packages/time-series/README.md b/packages/time-series/README.md new file mode 100644 index 0000000000..856a75fbb5 --- /dev/null +++ b/packages/time-series/README.md @@ -0,0 +1,2 @@ +# @node-redis/search +The sources and docs for this package are in the main [node-redis](https://github.com/redis/node-redis) repo. diff --git a/packages/time-series/lib/commands/ADD.spec.ts b/packages/time-series/lib/commands/ADD.spec.ts new file mode 100644 index 0000000000..94ad30627f --- /dev/null +++ b/packages/time-series/lib/commands/ADD.spec.ts @@ -0,0 +1,80 @@ +import { strict as assert } from 'assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import { transformArguments } from './ADD'; +import { TimeSeriesDuplicatePolicies, TimeSeriesEncoding } from '.'; + +describe('ADD', () => { + describe('transformArguments', () => { + it('without options', () => { + assert.deepEqual( + transformArguments('key', '*', 1), + ['TS.ADD', 'key', '*', '1'] + ); + }); + + it('with RETENTION', () => { + assert.deepEqual( + transformArguments('key', '*', 1, { + RETENTION: 1 + }), + ['TS.ADD', 'key', '*', '1', 'RETENTION', '1'] + ); + }); + + it('with ENCODING', () => { + assert.deepEqual( + transformArguments('key', '*', 1, { + ENCODING: TimeSeriesEncoding.UNCOMPRESSED + }), + ['TS.ADD', 'key', '*', '1', 'ENCODING', 'UNCOMPRESSED'] + ); + }); + + it('with CHUNK_SIZE', () => { + assert.deepEqual( + transformArguments('key', '*', 1, { + CHUNK_SIZE: 1 + }), + ['TS.ADD', 'key', '*', '1', 'CHUNK_SIZE', '1'] + ); + }); + + it('with ON_DUPLICATE', () => { + assert.deepEqual( + transformArguments('key', '*', 1, { + ON_DUPLICATE: TimeSeriesDuplicatePolicies.BLOCK + }), + ['TS.ADD', 'key', '*', '1', 'ON_DUPLICATE', 'BLOCK'] + ); + }); + + it('with LABELS', () => { + assert.deepEqual( + transformArguments('key', '*', 1, { + LABELS: { label: 'value' } + }), + ['TS.ADD', 'key', '*', '1', 'LABELS', 'label', 'value'] + ); + }); + + it('with RETENTION, ENCODING, CHUNK_SIZE, ON_DUPLICATE, LABELS', () => { + assert.deepEqual( + transformArguments('key', '*', 1, { + RETENTION: 1, + ENCODING: TimeSeriesEncoding.UNCOMPRESSED, + CHUNK_SIZE: 1, + ON_DUPLICATE: TimeSeriesDuplicatePolicies.BLOCK, + LABELS: { label: 'value' } + }), + ['TS.ADD', 'key', '*', '1', 'RETENTION', '1', 'ENCODING', 'UNCOMPRESSED', 'CHUNK_SIZE', '1', 'ON_DUPLICATE', 'BLOCK', 'LABELS', 'label', 'value'] + ); + }); + }); + + testUtils.testWithClient('client.ts.add', async client => { + assert.equal( + await client.ts.add('key', 0, 1), + 0 + ); + }, GLOBAL.SERVERS.OPEN); +}); diff --git a/packages/time-series/lib/commands/ADD.ts b/packages/time-series/lib/commands/ADD.ts new file mode 100644 index 0000000000..1988a96451 --- /dev/null +++ b/packages/time-series/lib/commands/ADD.ts @@ -0,0 +1,46 @@ +import { + transformTimestampArgument, + pushRetentionArgument, + TimeSeriesEncoding, + pushEncodingArgument, + pushChunkSizeArgument, + TimeSeriesDuplicatePolicies, + Labels, + pushLabelsArgument, + Timestamp, +} from '.'; + +interface AddOptions { + RETENTION?: number; + ENCODING?: TimeSeriesEncoding; + CHUNK_SIZE?: number; + ON_DUPLICATE?: TimeSeriesDuplicatePolicies; + LABELS?: Labels; +} + +export const FIRST_KEY_INDEX = 1; + +export function transformArguments(key: string, timestamp: Timestamp, value: number, options?: AddOptions): Array { + const args = [ + 'TS.ADD', + key, + transformTimestampArgument(timestamp), + value.toString() + ]; + + pushRetentionArgument(args, options?.RETENTION); + + pushEncodingArgument(args, options?.ENCODING); + + pushChunkSizeArgument(args, options?.CHUNK_SIZE); + + if (options?.ON_DUPLICATE) { + args.push('ON_DUPLICATE', options.ON_DUPLICATE); + } + + pushLabelsArgument(args, options?.LABELS); + + return args; +} + +export declare function transformReply(): number; diff --git a/packages/time-series/lib/commands/ALTER.spec.ts b/packages/time-series/lib/commands/ALTER.spec.ts new file mode 100644 index 0000000000..868d4a9c64 --- /dev/null +++ b/packages/time-series/lib/commands/ALTER.spec.ts @@ -0,0 +1,51 @@ +import { strict as assert } from 'assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import { transformArguments } from './ALTER'; + +describe('ALTER', () => { + describe('transformArguments', () => { + it('without options', () => { + assert.deepEqual( + transformArguments('key'), + ['TS.ALTER', 'key'] + ); + }); + + it('with RETENTION', () => { + assert.deepEqual( + transformArguments('key', { + RETENTION: 1 + }), + ['TS.ALTER', 'key', 'RETENTION', '1'] + ); + }); + + it('with LABELS', () => { + assert.deepEqual( + transformArguments('key', { + LABELS: { label: 'value' } + }), + ['TS.ALTER', 'key', 'LABELS', 'label', 'value'] + ); + }); + + it('with RETENTION, LABELS', () => { + assert.deepEqual( + transformArguments('key', { + RETENTION: 1, + LABELS: { label: 'value' } + }), + ['TS.ALTER', 'key', 'RETENTION', '1', 'LABELS', 'label', 'value'] + ); + }); + }); + + testUtils.testWithClient('client.ts.alter', async client => { + await client.ts.create('key'); + + assert.equal( + await client.ts.alter('key', { RETENTION: 1 }), + 'OK' + ); + }, GLOBAL.SERVERS.OPEN); +}); diff --git a/packages/time-series/lib/commands/ALTER.ts b/packages/time-series/lib/commands/ALTER.ts new file mode 100644 index 0000000000..c2c6b35052 --- /dev/null +++ b/packages/time-series/lib/commands/ALTER.ts @@ -0,0 +1,20 @@ +import { pushRetentionArgument, Labels, pushLabelsArgument } from '.'; + +export const FIRST_KEY_INDEX = 1; + +interface AlterOptions { + RETENTION?: number; + LABELS?: Labels; +} + +export function transformArguments(key: string, options?: AlterOptions): Array { + const args = ['TS.ALTER', key]; + + pushRetentionArgument(args, options?.RETENTION); + + pushLabelsArgument(args, options?.LABELS); + + return args; +} + +export declare function transformReply(): 'OK'; diff --git a/packages/time-series/lib/commands/CREATE.spec.ts b/packages/time-series/lib/commands/CREATE.spec.ts new file mode 100644 index 0000000000..fe1da99b09 --- /dev/null +++ b/packages/time-series/lib/commands/CREATE.spec.ts @@ -0,0 +1,80 @@ +import { strict as assert } from 'assert'; +import { TimeSeriesDuplicatePolicies, TimeSeriesEncoding } from '.'; +import testUtils, { GLOBAL } from '../test-utils'; +import { transformArguments } from './CREATE'; + +describe('CREATE', () => { + describe('transformArguments', () => { + it('without options', () => { + assert.deepEqual( + transformArguments('key'), + ['TS.CREATE', 'key'] + ); + }); + + it('with RETENTION', () => { + assert.deepEqual( + transformArguments('key', { + RETENTION: 1 + }), + ['TS.CREATE', 'key', 'RETENTION', '1'] + ); + }); + + it('with ENCODING', () => { + assert.deepEqual( + transformArguments('key', { + ENCODING: TimeSeriesEncoding.UNCOMPRESSED + }), + ['TS.CREATE', 'key', 'ENCODING', 'UNCOMPRESSED'] + ); + }); + + it('with CHUNK_SIZE', () => { + assert.deepEqual( + transformArguments('key', { + CHUNK_SIZE: 1 + }), + ['TS.CREATE', 'key', 'CHUNK_SIZE', '1'] + ); + }); + + it('with DUPLICATE_POLICY', () => { + assert.deepEqual( + transformArguments('key', { + DUPLICATE_POLICY: TimeSeriesDuplicatePolicies.BLOCK + }), + ['TS.CREATE', 'key', 'DUPLICATE_POLICY', 'BLOCK'] + ); + }); + + it('with LABELS', () => { + assert.deepEqual( + transformArguments('key', { + LABELS: { label: 'value' } + }), + ['TS.CREATE', 'key', 'LABELS', 'label', 'value'] + ); + }); + + it('with RETENTION, ENCODING, CHUNK_SIZE, DUPLICATE_POLICY, LABELS', () => { + assert.deepEqual( + transformArguments('key', { + RETENTION: 1, + ENCODING: TimeSeriesEncoding.UNCOMPRESSED, + CHUNK_SIZE: 1, + DUPLICATE_POLICY: TimeSeriesDuplicatePolicies.BLOCK, + LABELS: { label: 'value' } + }), + ['TS.CREATE', 'key', 'RETENTION', '1', 'ENCODING', 'UNCOMPRESSED', 'CHUNK_SIZE', '1', 'DUPLICATE_POLICY', 'BLOCK', 'LABELS', 'label', 'value'] + ); + }); + }); + + testUtils.testWithClient('client.ts.create', async client => { + assert.equal( + await client.ts.create('key'), + 'OK' + ); + }, GLOBAL.SERVERS.OPEN); +}); diff --git a/packages/time-series/lib/commands/CREATE.ts b/packages/time-series/lib/commands/CREATE.ts new file mode 100644 index 0000000000..35c0b84873 --- /dev/null +++ b/packages/time-series/lib/commands/CREATE.ts @@ -0,0 +1,42 @@ +import { + pushRetentionArgument, + TimeSeriesEncoding, + pushEncodingArgument, + pushChunkSizeArgument, + TimeSeriesDuplicatePolicies, + Labels, + pushLabelsArgument +} from '.'; + +export const FIRST_KEY_INDEX = 1; + +interface CreateOptions { + RETENTION?: number; + ENCODING?: TimeSeriesEncoding; + CHUNK_SIZE?: number; + DUPLICATE_POLICY?: TimeSeriesDuplicatePolicies; + LABELS?: Labels; +} + +export function transformArguments(key: string, options?: CreateOptions): Array { + const args = ['TS.CREATE', key]; + + pushRetentionArgument(args, options?.RETENTION); + + pushEncodingArgument(args, options?.ENCODING); + + pushChunkSizeArgument(args, options?.CHUNK_SIZE); + + if (options?.DUPLICATE_POLICY) { + args.push( + 'DUPLICATE_POLICY', + options.DUPLICATE_POLICY + ); + } + + pushLabelsArgument(args, options?.LABELS); + + return args; +} + +export declare function transformReply(): 'OK'; diff --git a/packages/time-series/lib/commands/CREATERULE.spec.ts b/packages/time-series/lib/commands/CREATERULE.spec.ts new file mode 100644 index 0000000000..a46be35b37 --- /dev/null +++ b/packages/time-series/lib/commands/CREATERULE.spec.ts @@ -0,0 +1,25 @@ +import { strict as assert } from 'assert'; +import { TimeSeriesAggregationType } from '.'; +import testUtils, { GLOBAL } from '../test-utils'; +import { transformArguments } from './CREATERULE'; + +describe('CREATERULE', () => { + it('transformArguments', () => { + assert.deepEqual( + transformArguments('source', 'destination', TimeSeriesAggregationType.AVARAGE, 1), + ['TS.CREATERULE', 'source', 'destination', 'avg', 1] + ); + }); + + testUtils.testWithClient('client.ts.createRule', async client => { + await Promise.all([ + client.ts.create('source'), + client.ts.create('destination') + ]); + + assert.equal( + await client.ts.createRule('source', 'destination', TimeSeriesAggregationType.AVARAGE, 1), + 'OK' + ); + }, GLOBAL.SERVERS.OPEN); +}); diff --git a/packages/time-series/lib/commands/CREATERULE.ts b/packages/time-series/lib/commands/CREATERULE.ts new file mode 100644 index 0000000000..e8f14f880f --- /dev/null +++ b/packages/time-series/lib/commands/CREATERULE.ts @@ -0,0 +1,20 @@ +import { TimeSeriesAggregationType } from '.'; + +export const FIRST_KEY_INDEX = 1; + +export function transformArguments( + sourceKey: string, + destinationKey: string, + aggregationType: TimeSeriesAggregationType, + timeBucket: number +): Array { + return [ + 'TS.CREATERULE', + sourceKey, + destinationKey, + aggregationType, + timeBucket.toString() + ]; +} + +export declare function transfromReply(): 'OK'; diff --git a/packages/time-series/lib/commands/DECRBY.ts b/packages/time-series/lib/commands/DECRBY.ts new file mode 100644 index 0000000000..b7fab3702d --- /dev/null +++ b/packages/time-series/lib/commands/DECRBY.ts @@ -0,0 +1,10 @@ +import { RedisCommandArguments } from '@node-redis/client/dist/lib/commands'; +import { IncrDecrOptions, transformIncrDecrArguments } from '.'; + +export const FIRST_KEY_INDEX = 1; + +export function transformArguments(key: string, value: number, options?: IncrDecrOptions): RedisCommandArguments { + return transformIncrDecrArguments('TS.DECRBY', key, value, options); +} + +export declare function transformReply(): number; diff --git a/packages/time-series/lib/commands/DEL.ts b/packages/time-series/lib/commands/DEL.ts new file mode 100644 index 0000000000..ae9a1e9fef --- /dev/null +++ b/packages/time-series/lib/commands/DEL.ts @@ -0,0 +1,15 @@ +import { RedisCommandArguments } from '@node-redis/client/dist/lib/commands'; +import { Timestamp, transformTimestampArgument } from '.'; + +export const FIRTS_KEY_INDEX = 1; + +export function transformArguments(key: string, fromTimestamp: Timestamp, toTimestamp: Timestamp): RedisCommandArguments { + return [ + 'TS.DEL', + key, + transformTimestampArgument(fromTimestamp), + transformTimestampArgument(toTimestamp) + ]; +} + +export declare function transformReply(): number; diff --git a/packages/time-series/lib/commands/DELETERULE.ts b/packages/time-series/lib/commands/DELETERULE.ts new file mode 100644 index 0000000000..b9ef7574c8 --- /dev/null +++ b/packages/time-series/lib/commands/DELETERULE.ts @@ -0,0 +1,9 @@ +export function transformArguments(sourceKey: string,destinationKey: string,): Array { + return [ + 'TS.DELETERULE', + sourceKey, + destinationKey, + ]; +} + +export declare function transfromReply(): 'OK'; diff --git a/packages/time-series/lib/commands/GET.ts b/packages/time-series/lib/commands/GET.ts new file mode 100644 index 0000000000..ec3b1f5f80 --- /dev/null +++ b/packages/time-series/lib/commands/GET.ts @@ -0,0 +1,15 @@ +import { SampleRawReply, SampleReply, transformSampleReply } from '.'; + +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +export function transformArguments(key: string): Array { + return ['TS.GET', key]; +} + +export function transformReply(reply: [] | SampleRawReply): null | SampleReply { + if (reply.length === 0) return null; + + return transformSampleReply(reply); +} diff --git a/packages/time-series/lib/commands/INCRBY.ts b/packages/time-series/lib/commands/INCRBY.ts new file mode 100644 index 0000000000..28267c57cc --- /dev/null +++ b/packages/time-series/lib/commands/INCRBY.ts @@ -0,0 +1,10 @@ +import { RedisCommandArguments } from '@node-redis/client/dist/lib/commands'; +import { IncrDecrOptions, transformIncrDecrArguments } from '.'; + +export const FIRST_KEY_INDEX = 1; + +export function transformArguments(key: string, value: number, options?: IncrDecrOptions): RedisCommandArguments { + return transformIncrDecrArguments('TS.INCRBY', key, value, options); +} + +export declare function transformReply(): number; diff --git a/packages/time-series/lib/commands/INFO.ts b/packages/time-series/lib/commands/INFO.ts new file mode 100644 index 0000000000..00e04a1985 --- /dev/null +++ b/packages/time-series/lib/commands/INFO.ts @@ -0,0 +1,82 @@ +import { TimeSeriesAggregationType, TimeSeriesDuplicatePolicies } from '.'; + +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +export function transformArguments(key: string): Array { + return ['TS.INFO', key]; +} + +export type InfoRawReply = [ + _: string, + totalSamples: number, + _: string, + memoryUsage: number, + _: string, + firstTimestamp: number, + _: string, + lastTimestamp: number, + _: string, + retentionTime: number, + _: string, + chunkCount: number, + _: string, + chunkSize: number, + _: string, + chunkType: string, + _: string, + duplicatePolicy: TimeSeriesDuplicatePolicies | null, + _: string, + labels: Array<[name: string, value: string]>, + _: string, + sourceKey: string | null, + _: string, + rules: Array<[key: string, timeBucket: number, aggregationType: TimeSeriesAggregationType]> +]; + +export interface InfoReply { + totalSamples: number; + memoryUsage: number; + firstTimestamp: number; + lastTimestamp: number; + retentionTime: number; + chunkCount: number; + chunkSize: number; + chunkType: string; + duplicatePolicy: TimeSeriesDuplicatePolicies | null; + labels: Array<{ + name: string; + value: string; + }>; + sourceKey: string | null; + rules: Array<{ + key: string; + timeBucket: number; + aggregationType: TimeSeriesAggregationType + }>; +} + +export function transformReply(reply: InfoRawReply): InfoReply { + return { + totalSamples: reply[1], + memoryUsage: reply[3], + firstTimestamp: reply[5], + lastTimestamp: reply[7], + retentionTime: reply[9], + chunkCount: reply[11], + chunkSize: reply[13], + chunkType: reply[15], + duplicatePolicy: reply[17], + labels: reply[19].map(([name, value]) => ({ + name, + value + })), + sourceKey: reply[21], + rules: reply[23].map(([key, timeBucket, aggregationType]) => ({ + key, + timeBucket, + aggregationType + })) + }; +} diff --git a/packages/time-series/lib/commands/INFO_DEBUG.ts b/packages/time-series/lib/commands/INFO_DEBUG.ts new file mode 100644 index 0000000000..6680a2044b --- /dev/null +++ b/packages/time-series/lib/commands/INFO_DEBUG.ts @@ -0,0 +1,53 @@ +import { + transformArguments as transformInfoArguments, + InfoRawReply, + InfoReply, + transformReply as transformInfoReply +} from './INFO'; + +export { IS_READ_ONLY, FIRST_KEY_INDEX } from './INFO'; + +export function transformArguments(key: string): Array { + const args = transformInfoArguments(key); + args.push('DEBUG'); + return args; +} + +type InfoDebugRawReply = [ + ...infoArgs: InfoRawReply, + _: string, + chunks: Array<[ + _: string, + startTimestamp: number, + _: string, + endTimestamp: number, + _: string, + samples: number, + _: string, + size: number, + _: string, + bytesPerSample: string + ]> +] + +interface InfoDebugReply extends InfoReply { + chunks: Array<{ + startTimestamp: number; + endTimestamp: number; + samples: number; + size: number; + bytesPerSample: string; + }>; +} + +export function transformReply(rawReply: InfoDebugRawReply): InfoDebugReply { + const reply = transformInfoReply(rawReply as unknown as InfoRawReply); + (reply as InfoDebugReply).chunks = rawReply[25].map(chunk => ({ + startTimestamp: chunk[1], + endTimestamp: chunk[3], + samples: chunk[5], + size: chunk[7], + bytesPerSample: chunk[9] + })); + return reply as InfoDebugReply; +} diff --git a/packages/time-series/lib/commands/MADD.ts b/packages/time-series/lib/commands/MADD.ts new file mode 100644 index 0000000000..8970cac06a --- /dev/null +++ b/packages/time-series/lib/commands/MADD.ts @@ -0,0 +1,23 @@ +import { Timestamp, transformTimestampArgument } from '.'; + +interface MAddSample { + key: string; + timestamp: Timestamp; + value: number; +} + +export function transformArguments(toAdd: Array): Array { + const args = ['TS.MADD']; + + for (const { key, timestamp, value } of toAdd) { + args.push( + key, + transformTimestampArgument(timestamp), + value.toString() + ); + } + + return args; +} + +export declare function transformReply(): Array; diff --git a/packages/time-series/lib/commands/MGET.ts b/packages/time-series/lib/commands/MGET.ts new file mode 100644 index 0000000000..94fc45e03c --- /dev/null +++ b/packages/time-series/lib/commands/MGET.ts @@ -0,0 +1,31 @@ +import { pushVerdictArgument, pushVerdictArguments } from '@node-redis/client/lib/commands/generic-transformers'; + +export const IS_READ_ONLY = true; + +interface WithLabels { + WITHLABELS: true; +} + +interface SelectedLabels { + SELECTED_LABELS: string | Array; +} + +type MGetOptions = WithLabels & SelectedLabels; + +export function transformArguments(filter: string, options?: MGetOptions): Array { + const args = ['TS.MGET']; + + if (options?.WITHLABELS) { + args.push('WITHLABELS'); + } else if (options?.SELECTED_LABELS) { + pushVerdictArguments(args, options.SELECTED_LABELS); + } + + args.push('FILTER', filter); + + return args; +} + +export function transformReply() { + +} diff --git a/packages/time-series/lib/commands/QUERYINDEX.ts b/packages/time-series/lib/commands/QUERYINDEX.ts new file mode 100644 index 0000000000..c3970675ea --- /dev/null +++ b/packages/time-series/lib/commands/QUERYINDEX.ts @@ -0,0 +1,7 @@ +export const IS_READ_ONLY = true; + +export function transformArguments(query: string): Array { + return ['TS.QUERYINDEX', query]; +} + +export declare function transformReply(): Array; diff --git a/packages/time-series/lib/commands/RANGE.ts b/packages/time-series/lib/commands/RANGE.ts new file mode 100644 index 0000000000..b22d2e33b2 --- /dev/null +++ b/packages/time-series/lib/commands/RANGE.ts @@ -0,0 +1,21 @@ +import { RedisCommandArguments } from '@node-redis/client/dist/lib/commands'; +import { RangeOptions, Timestamp, pushRangeArguments } from '.'; + +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +export function transformArguments( + fromTimestamp: Timestamp, + toTimestamp: Timestamp, + options?: RangeOptions +): RedisCommandArguments { + return pushRangeArguments( + ['TS.RANGE'], + fromTimestamp, + toTimestamp, + options + ); +} + +export { transformRangeReply } from '.'; diff --git a/packages/time-series/lib/commands/REVRANGE.ts b/packages/time-series/lib/commands/REVRANGE.ts new file mode 100644 index 0000000000..ba961265ac --- /dev/null +++ b/packages/time-series/lib/commands/REVRANGE.ts @@ -0,0 +1,21 @@ +import { RedisCommandArguments } from '@node-redis/client/dist/lib/commands'; +import { RangeOptions, Timestamp, pushRangeArguments } from '.'; + +export const FIRST_KEY_INDEX = 1; + +export const IS_READ_ONLY = true; + +export function transformArguments( + fromTimestamp: Timestamp, + toTimestamp: Timestamp, + options?: RangeOptions +): RedisCommandArguments { + return pushRangeArguments( + ['TS.REVRANGE'], + fromTimestamp, + toTimestamp, + options + ); +} + +export { transformRangeReply } from '.'; diff --git a/packages/time-series/lib/commands/index.spec.ts b/packages/time-series/lib/commands/index.spec.ts new file mode 100644 index 0000000000..d02d259eb7 --- /dev/null +++ b/packages/time-series/lib/commands/index.spec.ts @@ -0,0 +1,231 @@ +import { RedisCommandArguments } from '@node-redis/client/dist/lib/commands'; +import { strict as assert } from 'assert'; +import { + transformTimestampArgument, + pushRetentionArgument, + TimeSeriesEncoding, + pushEncodingArgument, + pushChunkSizeArgument, + pushLabelsArgument, + transformIncrDecrArguments, + transformSampleReply, + pushRangeArguments, + transformRangeReply, + TimeSeriesAggregationType +} from '.'; + +describe('transformTimestampArgument', () => { + it('number', () => { + assert.equal( + transformTimestampArgument(0), + '0' + ); + }); + + it('Date', () => { + assert.equal( + transformTimestampArgument(new Date(0)), + '0' + ); + }); + + it('string', () => { + assert.equal( + transformTimestampArgument('*'), + '*' + ); + }); +}); + +function testOptionalArgument(fn: (args: RedisCommandArguments) => unknown): void { + it('undefined', () => { + assert.deepEqual( + fn([]), + [] + ); + }); +} + +describe('pushRetentionArgument', () => { + testOptionalArgument(pushRetentionArgument); + + it('number', () => { + assert.deepEqual( + pushRetentionArgument([], 1), + ['RETENTION', '1'] + ); + }); +}); + +describe('pushEncodingArgument', () => { + testOptionalArgument(pushEncodingArgument); + + it('UNCOMPRESSED', () => { + assert.deepEqual( + pushEncodingArgument([], TimeSeriesEncoding.UNCOMPRESSED), + ['ENCODING', 'UNCOMPRESSED'] + ); + }); +}); + +describe('pushChunkSizeArgument', () => { + testOptionalArgument(pushChunkSizeArgument); + + it('number', () => { + assert.deepEqual( + pushChunkSizeArgument([], 1), + ['CHUNK_SIZE', '1'] + ); + }); +}); + +describe('pushLabelsArgument', () => { + testOptionalArgument(pushLabelsArgument); + + it("{ label: 'value' }", () => { + assert.deepEqual( + pushLabelsArgument([], { label: 'value' }), + ['LABELS', 'label', 'value'] + ); + }); +}); + +describe('transformIncrDecrArguments', () => { + it('without options', () => { + assert.deepEqual( + transformIncrDecrArguments('TS.INCRBY', 'key', 1), + ['TS.INCRBY', 'key', '1'] + ); + }); + + it('with TIMESTAMP', () => { + assert.deepEqual( + transformIncrDecrArguments('TS.INCRBY', 'key', 1, { + TIMESTAMP: '*' + }), + ['TS.INCRBY', 'key', '1', 'TIMESTAMP', '*'] + ); + }); + + it('with UNCOMPRESSED', () => { + assert.deepEqual( + transformIncrDecrArguments('TS.INCRBY', 'key', 1, { + UNCOMPRESSED: true + }), + ['TS.INCRBY', 'key', '1', 'UNCOMPRESSED'] + ); + }); +}); + +it('transformSampleReply', () => { + assert.deepEqual( + transformSampleReply([1, '1.1']), + { + timestamp: 1, + value: 1.1 + } + ); +}); + +describe('pushRangeArguments', () => { + it('without options', () => { + assert.deepEqual( + pushRangeArguments([], '-', '+'), + ['-', '+'] + ); + }); + + describe('with FILTER_BY_TS', () => { + it('string', () => { + assert.deepEqual( + pushRangeArguments([], '-', '+', { + FILTER_BY_TS: 'ts' + }), + ['-', '+', 'FILTER_BY_TS', 'ts'] + ); + }); + + it('Array', () => { + assert.deepEqual( + pushRangeArguments([], '-', '+', { + FILTER_BY_TS: ['1', '2'] + }), + ['-', '+', 'FILTER_BY_TS', '1', '2'] + ); + }); + }); + + it('with FILTER_BY_VALUE', () => { + assert.deepEqual( + pushRangeArguments([], '-', '+', { + FILTER_BY_VALUE: { + min: 1, + max: 2 + } + }), + ['-', '+', 'FILTER_BY_VALUE', '1', '2'] + ); + }); + + it('with COUNT', () => { + assert.deepEqual( + pushRangeArguments([], '-', '+', { + COUNT: 1 + }), + ['-', '+', 'COUNT', '1'] + ); + }); + + it('with ALIGN', () => { + assert.deepEqual( + pushRangeArguments([], '-', '+', { + ALIGN: 1 + }), + ['-', '+', 'ALIGN', '1'] + ); + }); + + it('with AGGREGATION', () => { + assert.deepEqual( + pushRangeArguments([], '-', '+', { + AGGREGATION: { + type: TimeSeriesAggregationType.FIRST, + timeBucket: 1 + } + }), + ['-', '+', 'AGGREGATION', 'first', '1'] + ); + }); + + it('with FILTER_BY_TS, FILTER_BY_VALUE, COUNT, ALIGN, AGGREGATION', () => { + assert.deepEqual( + pushRangeArguments([], '-', '+', { + FILTER_BY_TS: 'ts', + FILTER_BY_VALUE: { + min: 1, + max: 2 + }, + COUNT: 1, + ALIGN: 1, + AGGREGATION: { + type: TimeSeriesAggregationType.FIRST, + timeBucket: 1 + } + }), + ['-', '+', 'FILTER_BY_TS', 'ts', 'FILTER_BY_VALUE', '1', '2', 'COUNT', '1', 'ALIGN', '1', 'AGGREGATION', 'first', '1'] + ); + }); +}); + +it('transformRangeReply', () => { + assert.deepEqual( + transformRangeReply([[1, '1.1'], [2, '2.2']]), + [{ + timestamp: 1, + value: 1.1 + }, { + timestamp: 2, + value: 2.2 + }] + ); +}); diff --git a/packages/time-series/lib/commands/index.ts b/packages/time-series/lib/commands/index.ts new file mode 100644 index 0000000000..07034c6137 --- /dev/null +++ b/packages/time-series/lib/commands/index.ts @@ -0,0 +1,261 @@ +import * as ADD from './ADD'; +import * as ALTER from './ALTER'; +import * as CREATE from './CREATE'; +import * as CREATERULE from './CREATERULE'; +import * as DECRBY from './DECRBY'; +import * as DEL from './DEL'; +import * as DELETERULE from './DELETERULE'; +import * as GET from './GET'; +import * as INCRBY from './INCRBY'; +import * as INFO_DEBUG from './INFO_DEBUG'; +import * as INFO from './INFO'; +import * as MADD from './MADD'; +import * as MGET from './MGET'; +import * as QUERYINDEX from './QUERYINDEX'; +import * as RANGE from './RANGE'; +import * as REVRANGE from './REVRANGE'; +import { RedisCommandArguments } from '@node-redis/client/dist/lib/commands'; +import { pushVerdictArguments } from '@node-redis/client/lib/commands/generic-transformers'; + +export default { + ADD, + add: ADD, + ALTER, + alter: ALTER, + CREATE, + create: CREATE, + CREATERULE, + createRule: CREATERULE, + DECRBY, + decrBy: DECRBY, + DEL, + del: DEL, + DELETERULE, + deleteRule: DELETERULE, + GET, + get: GET, + INCRBY, + incrBy: INCRBY, + INFO_DEBUG, + infoDebug: INFO_DEBUG, + INFO, + info: INFO, + MADD, + mAdd: MADD, + MGET, + mGet: MGET, + QUERYINDEX, + queryIndex: QUERYINDEX, + RANGE, + range: RANGE, + REVRANGE, + revRange: REVRANGE +}; + +export enum TimeSeriesAggregationType { + AVARAGE = 'avg', + SUM = 'sum', + MINIMUM = 'min', + MAXIMUM = 'max', + RANGE = 'range', + COUNT = 'count', + FIRST = 'first', + LAST = 'last', + STD_P = 'std.p', + STD_S = 'std.s', + VAR_P = 'var.p', + VAR_S = 'var.s' +} + +export type Timestamp = number | Date | string; + +export function transformTimestampArgument(timestamp: Timestamp): string { + if (typeof timestamp === 'string') return timestamp; + + return ( + typeof timestamp === 'number' ? + timestamp : + timestamp.getTime() + ).toString(); +} + +export function pushRetentionArgument(args: RedisCommandArguments, retention?: number): RedisCommandArguments { + if (retention) { + args.push( + 'RETENTION', + retention.toString() + ); + } + + return args; +} + +export enum TimeSeriesEncoding { + COMPRESSED = 'COMPRESSED', + UNCOMPRESSED = 'UNCOMPRESSED' +} + +export function pushEncodingArgument(args: RedisCommandArguments, encoding?: TimeSeriesEncoding): RedisCommandArguments { + if (encoding) { + args.push( + 'ENCODING', + encoding + ); + } + + return args; +} + +export function pushChunkSizeArgument(args: RedisCommandArguments, chunkSize?: number): RedisCommandArguments { + if (chunkSize) { + args.push( + 'CHUNK_SIZE', + chunkSize.toString() + ); + } + + return args; +} + +export enum TimeSeriesDuplicatePolicies { + BLOCK = 'BLOCK', + FIRST = 'FIRST', + LAST = 'LAST', + MIN = 'MIN', + MAX = 'MAX', + SUM = 'SUM' +} + +export type Labels = { + [label: string]: string; +}; + +export function pushLabelsArgument(args: RedisCommandArguments, labels?: Labels): RedisCommandArguments { + if (labels) { + args.push('LABELS'); + + for (const [label, value] of Object.entries(labels)) { + args.push(label, value); + } + } + + return args; +} + +export interface IncrDecrOptions { + TIMESTAMP?: Timestamp; + RETENTION?: number; + UNCOMPRESSED?: boolean; + CHUNK_SIZE?: number; + LABELS?: Labels; +} + +export function transformIncrDecrArguments( + command: 'TS.INCRBY' | 'TS.DECRBY', + key: string, + value: number, + options?: IncrDecrOptions +): RedisCommandArguments { + const args = [ + command, + key, + value.toString() + ]; + + if (options?.TIMESTAMP) { + args.push('TIMESTAMP', transformTimestampArgument(options.TIMESTAMP)); + } + + pushRetentionArgument(args, options?.RETENTION); + + if (options?.UNCOMPRESSED) { + args.push('UNCOMPRESSED'); + } + + pushChunkSizeArgument(args, options?.CHUNK_SIZE); + + pushLabelsArgument(args, options?.LABELS); + + return args; +} + +export type SampleRawReply = [timestamp: number, value: string]; + +export interface SampleReply { + timestamp: number; + value: number; +} + +export function transformSampleReply(reply: SampleRawReply): SampleReply { + return { + timestamp: reply[0], + value: Number(reply[1]) + }; +} + +export interface RangeOptions { + FILTER_BY_TS?: string | Array; + FILTER_BY_VALUE?: { + min: number; + max: number; + }; + COUNT?: number; + ALIGN?: Timestamp; + AGGREGATION?: { + type: TimeSeriesAggregationType; + timeBucket: Timestamp; + }; +} + +export function pushRangeArguments( + args: RedisCommandArguments, + fromTimestamp: Timestamp, + toTimestamp: Timestamp, + options?: RangeOptions +): RedisCommandArguments { + args.push( + transformTimestampArgument(fromTimestamp), + transformTimestampArgument(toTimestamp) + ); + + if (options?.FILTER_BY_TS) { + args.push('FILTER_BY_TS'); + pushVerdictArguments(args, options.FILTER_BY_TS); + } + + if (options?.FILTER_BY_VALUE) { + args.push( + 'FILTER_BY_VALUE', + options.FILTER_BY_VALUE.min.toString(), + options.FILTER_BY_VALUE.max.toString() + ); + } + + if (options?.COUNT) { + args.push( + 'COUNT', + options.COUNT.toString() + ); + } + + if (options?.ALIGN) { + args.push( + 'ALIGN', + transformTimestampArgument(options.ALIGN) + ); + } + + if (options?.AGGREGATION) { + args.push( + 'AGGREGATION', + options.AGGREGATION.type, + transformTimestampArgument(options.AGGREGATION.timeBucket) + ); + } + + return args; +} + +export function transformRangeReply(reply: Array): Array { + return reply.map(transformSampleReply); +} diff --git a/packages/time-series/lib/index.ts b/packages/time-series/lib/index.ts new file mode 100644 index 0000000000..567795c83c --- /dev/null +++ b/packages/time-series/lib/index.ts @@ -0,0 +1,3 @@ +export { default } from './commands'; + +// TODO diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts new file mode 100644 index 0000000000..7beb04e298 --- /dev/null +++ b/packages/time-series/lib/test-utils.ts @@ -0,0 +1,21 @@ +import TestUtils from '@node-redis/test-utils'; +import TimeSeries from '.'; + +export default new TestUtils({ + dockerImageName: 'redislabs/redistimeseries', + dockerImageVersionArgument: 'timeseries-version', + defaultDockerVersion: '1.6.0' +}); + +export const GLOBAL = { + SERVERS: { + OPEN: { + serverArguments: ['--loadmodule /usr/lib/redis/modules/redistimeseries.so'], + clientOptions: { + modules: { + ts: TimeSeries + } + } + } + } +}; diff --git a/packages/time-series/package.json b/packages/time-series/package.json new file mode 100644 index 0000000000..e58fd79da5 --- /dev/null +++ b/packages/time-series/package.json @@ -0,0 +1,24 @@ +{ + "name": "@node-redis/time-series", + "version": "1.0.0-rc.0", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "test": "nyc -r text-summary -r lcov mocha -r source-map-support/register -r ts-node/register './lib/**/*.spec.ts'", + "build": "tsc" + }, + "peerDependencies": { + "@node-redis/client": "^1.0.0" + }, + "devDependencies": { + "@istanbuljs/nyc-config-typescript": "^1.0.1", + "@node-redis/test-utils": "*", + "@types/node": "^16.11.7", + "nyc": "^15.1.0", + "release-it": "^14.11.7", + "source-map-support": "^0.5.20", + "ts-node": "^10.4.0", + "typescript": "^4.4.4" + } +} diff --git a/packages/time-series/tsconfig.json b/packages/time-series/tsconfig.json new file mode 100644 index 0000000000..14fda1d871 --- /dev/null +++ b/packages/time-series/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": [ + "./lib/**/*.ts" + ] +}