diff --git a/clients/ember-ts/.gitignore b/clients/ember-ts/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/clients/ember-ts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/clients/ember-ts/Makefile b/clients/ember-ts/Makefile index 1d9af69e..ca8cb0d4 100644 --- a/clients/ember-ts/Makefile +++ b/clients/ember-ts/Makefile @@ -1,13 +1,18 @@ -.PHONY: proto-gen test clean +.PHONY: proto-gen build test clean proto-gen: npx --yes --package=@grpc/proto-loader proto-loader-gen-types \ --proto_path=../../proto \ --outDir=src/generated \ ../../proto/ember/v1/ember.proto + mkdir -p proto/ember/v1 + cp ../../proto/ember/v1/ember.proto proto/ember/v1/ember.proto + +build: + npm run build test: npm test clean: - rm -rf src/generated node_modules + rm -rf dist src/generated node_modules diff --git a/clients/ember-ts/README.md b/clients/ember-ts/README.md new file mode 100644 index 00000000..70f3422f --- /dev/null +++ b/clients/ember-ts/README.md @@ -0,0 +1,443 @@ +# ember-ts + +TypeScript client for [ember](https://github.com/kacy/ember) — a high-performance, Redis-compatible cache built in Rust. + +Communicates over gRPC (default port `6380`). Full IDE autocomplete, Buffer-based API, and async iterables for pub/sub. + +## install + +```bash +npm install ember-ts +``` + +## quickstart + +```ts +import { EmberClient } from 'ember-ts'; + +const client = new EmberClient('localhost:6380'); + +await client.set('greeting', 'hello'); +const val = await client.get('greeting'); +console.log(val?.toString()); // "hello" + +await client.set('counter', '0'); +await client.incr('counter'); +console.log(await client.get('counter')); // ("1") + +client.close(); +``` + +## authentication + +```ts +const client = new EmberClient('localhost:6380', { password: 'secret' }); +``` + +The password is sent as the `authorization` gRPC metadata header on every call. + +## api + +All methods return `Promise` and throw on error. + +### strings + +| method | args | returns | +|--------|------|---------| +| `get(key)` | `key: string` | `Buffer \| null` | +| `set(key, value, opts?)` | `key`, `value: Buffer \| string`, [`SetOptions`](#setoptions) | `boolean` | +| `del(...keys)` | `...string` | `number` | +| `mGet(keys)` | `string[]` | `(Buffer \| null)[]` | +| `mSet(entries)` | `Record` | `void` | +| `incr(key)` | `key: string` | `number` | +| `incrBy(key, delta)` | `key`, `delta: number` | `number` | +| `decrBy(key, delta)` | `key`, `delta: number` | `number` | +| `decr(key)` | `key: string` | `number` | +| `incrByFloat(key, delta)` | `key`, `delta: number` | `number` | +| `append(key, value)` | `key`, `value: Buffer \| string` | `number` | +| `strlen(key)` | `key: string` | `number` | +| `getDel(key)` | `key: string` | `Buffer \| null` | +| `getEx(key, opts?)` | `key`, [`GetExOptions`](#getexoptions) | `Buffer \| null` | +| `getRange(key, start, end)` | `key`, `start`, `end: number` | `Buffer` | +| `setRange(key, offset, value)` | `key`, `offset: number`, `value: Buffer \| string` | `number` | + +### keys + +| method | args | returns | +|--------|------|---------| +| `exists(...keys)` | `...string` | `number` | +| `expire(key, seconds)` | `key`, `seconds: number` | `boolean` | +| `pExpire(key, ms)` | `key`, `ms: number` | `boolean` | +| `persist(key)` | `key: string` | `boolean` | +| `ttl(key)` | `key: string` | `number` | +| `pTtl(key)` | `key: string` | `number` | +| `type(key)` | `key: string` | `string` | +| `keys(pattern)` | `pattern: string` | `string[]` | +| `rename(key, newKey)` | `key`, `newKey: string` | `void` | +| `scan(cursor, opts?)` | `cursor: number`, [`ScanOptions`](#scanoptions) | [`ScanPage`](#scanpage) | +| `copy(src, dst, replace?)` | `src`, `dst: string`, `replace?: boolean` | `boolean` | +| `randomKey()` | — | `string \| null` | +| `touch(...keys)` | `...string` | `number` | +| `unlink(...keys)` | `...string` | `number` | + +### lists + +| method | args | returns | +|--------|------|---------| +| `lPush(key, ...values)` | `key`, `...Buffer \| string` | `number` | +| `rPush(key, ...values)` | `key`, `...Buffer \| string` | `number` | +| `lPop(key)` | `key: string` | `Buffer \| null` | +| `rPop(key)` | `key: string` | `Buffer \| null` | +| `lRange(key, start, stop)` | `key`, `start`, `stop: number` | `Buffer[]` | +| `lLen(key)` | `key: string` | `number` | +| `lIndex(key, index)` | `key`, `index: number` | `Buffer \| null` | +| `lSet(key, index, value)` | `key`, `index: number`, `value: Buffer \| string` | `void` | +| `lTrim(key, start, stop)` | `key`, `start`, `stop: number` | `void` | +| `lInsert(key, before, pivot, value)` | `key`, `before: boolean`, `pivot`, `value: Buffer \| string` | `number` | +| `lRem(key, count, value)` | `key`, `count: number`, `value: Buffer \| string` | `number` | +| `lPos(key, value, count?)` | `key`, `value: Buffer \| string`, `count?: number` | `number \| null` | +| `lMove(src, dst, srcLeft, dstLeft)` | `src`, `dst: string`, `srcLeft`, `dstLeft: boolean` | `Buffer \| null` | + +### hashes + +| method | args | returns | +|--------|------|---------| +| `hSet(key, fields)` | `key`, `Record` | `number` | +| `hGet(key, field)` | `key`, `field: string` | `Buffer \| null` | +| `hGetAll(key)` | `key: string` | `Record` | +| `hDel(key, ...fields)` | `key`, `...string` | `number` | +| `hExists(key, field)` | `key`, `field: string` | `boolean` | +| `hLen(key)` | `key: string` | `number` | +| `hKeys(key)` | `key: string` | `string[]` | +| `hVals(key)` | `key: string` | `Buffer[]` | +| `hmGet(key, fields)` | `key`, `string[]` | `(Buffer \| null)[]` | +| `hIncrBy(key, field, delta)` | `key`, `field: string`, `delta: number` | `number` | +| `hScan(key, cursor, opts?)` | `key`, `cursor: number`, [`ScanOptions`](#scanoptions) | [`HScanPage`](#hscanpage) | + +### sets + +| method | args | returns | +|--------|------|---------| +| `sAdd(key, ...members)` | `key`, `...string` | `number` | +| `sRem(key, ...members)` | `key`, `...string` | `number` | +| `sMembers(key)` | `key: string` | `string[]` | +| `sIsMember(key, member)` | `key`, `member: string` | `boolean` | +| `sCard(key)` | `key: string` | `number` | +| `sUnion(...keys)` | `...string` | `string[]` | +| `sInter(...keys)` | `...string` | `string[]` | +| `sDiff(...keys)` | `...string` | `string[]` | +| `sUnionStore(dst, ...keys)` | `dst: string`, `...string` | `number` | +| `sInterStore(dst, ...keys)` | `dst: string`, `...string` | `number` | +| `sDiffStore(dst, ...keys)` | `dst: string`, `...string` | `number` | +| `sRandMember(key, count)` | `key`, `count: number` | `string[]` | +| `sPop(key, count)` | `key`, `count: number` | `string[]` | +| `sMisMember(key, ...members)` | `key`, `...string` | `boolean[]` | +| `sScan(key, cursor, opts?)` | `key`, `cursor: number`, [`ScanOptions`](#scanoptions) | [`SScanPage`](#sscanpage) | + +### sorted sets + +| method | args | returns | +|--------|------|---------| +| `zAdd(key, members, opts?)` | `key`, [`ScoreMember[]`](#scoremember), [`ZAddOptions`](#zaddoptions) | `number` | +| `zRem(key, ...members)` | `key`, `...string` | `number` | +| `zScore(key, member)` | `key`, `member: string` | `number \| null` | +| `zRank(key, member)` | `key`, `member: string` | `number \| null` | +| `zRevRank(key, member)` | `key`, `member: string` | `number \| null` | +| `zCard(key)` | `key: string` | `number` | +| `zRange(key, start, stop, withScores?)` | `key`, `start`, `stop: number`, `withScores?: boolean` | [`ScoreMember[]`](#scoremember) | +| `zRevRange(key, start, stop, withScores?)` | `key`, `start`, `stop: number`, `withScores?: boolean` | [`ScoreMember[]`](#scoremember) | +| `zCount(key, min, max)` | `key`, `min`, `max: string` | `number` | +| `zIncrBy(key, delta, member)` | `key`, `delta: number`, `member: string` | `number` | +| `zRangeByScore(key, min, max, opts?)` | `key`, `min`, `max: string`, [`ZRangeByScoreOptions`](#zrangebyscoreoptions) | [`ScoreMember[]`](#scoremember) | +| `zRevRangeByScore(key, max, min, opts?)` | `key`, `max`, `min: string`, [`ZRangeByScoreOptions`](#zrangebyscoreoptions) | [`ScoreMember[]`](#scoremember) | +| `zPopMin(key, count?)` | `key`, `count?: number` | [`ScoreMember[]`](#scoremember) | +| `zPopMax(key, count?)` | `key`, `count?: number` | [`ScoreMember[]`](#scoremember) | +| `zDiff(keys, withScores?)` | `string[]`, `withScores?: boolean` | [`ScoreMember[]`](#scoremember) | +| `zInter(keys, withScores?)` | `string[]`, `withScores?: boolean` | [`ScoreMember[]`](#scoremember) | +| `zUnion(keys, withScores?)` | `string[]`, `withScores?: boolean` | [`ScoreMember[]`](#scoremember) | +| `zScan(key, cursor, opts?)` | `key`, `cursor: number`, [`ScanOptions`](#scanoptions) | [`ZScanPage`](#zscanpage) | + +### vectors + +Requires the server to be built with the `vector` feature. + +| method | args | returns | +|--------|------|---------| +| `vAdd(key, element, vector, opts?)` | `key`, `element: string`, `number[]`, [`VAddOptions`](#vaddoptions) | `boolean` | +| `vAddBatch(key, entries, opts?)` | `key`, [`VAddBatchEntry[]`](#vaddbatchentry), [`VAddBatchOptions`](#vaddbatchoptions) | `number` | +| `vSim(key, query, count, opts?)` | `key`, `number[]`, `count: number`, [`VSimOptions`](#vsimoptions) | [`VSimResult[]`](#vsimresult) | +| `vRem(key, element)` | `key`, `element: string` | `boolean` | +| `vGet(key, element)` | `key`, `element: string` | [`VGetResult`](#vgetresult) | +| `vCard(key)` | `key: string` | `number` | +| `vDim(key)` | `key: string` | `number` | +| `vInfo(key)` | `key: string` | [`VInfoResult`](#vinforesult) | + +### pub/sub + +```ts +// publish +await client.publish('news', 'breaking story'); + +// subscribe (async iterable) +for await (const evt of client.subscribe(['news', 'alerts'])) { + console.log(evt.kind, evt.channel, evt.data?.toString()); + if (shouldStop) break; // cancels the stream cleanly +} + +// pattern subscriptions work the same way +for await (const evt of client.subscribe([], ['user:*'])) { + console.log(`pattern match: ${evt.pattern}, channel: ${evt.channel}`); +} +``` + +| method | args | returns | +|--------|------|---------| +| `publish(channel, message)` | `channel: string`, `message: Buffer \| string` | `number` | +| `subscribe(channels, patterns?)` | `string[]`, `string[]` | `AsyncIterable` | +| `pubSubChannels(pattern?)` | `pattern?: string` | `string[]` | +| `pubSubNumSub(...channels)` | `...string` | `Map` | +| `pubSubNumPat()` | — | `number` | + +### server + +| method | args | returns | +|--------|------|---------| +| `ping(message?)` | `message?: string` | `string` | +| `echo(message)` | `message: string` | `string` | +| `flushDb(async?)` | `async?: boolean` | `void` | +| `dbSize()` | — | `number` | +| `info(section?)` | `section?: string` | `string` | +| `bgSave()` | — | `string` | +| `bgRewriteAof()` | — | `string` | +| `time()` | — | [`TimeResult`](#timeresult) | +| `lastSave()` | — | `number` | + +### slowlog + +| method | args | returns | +|--------|------|---------| +| `slowLogGet(count?)` | `count?: number` | [`SlowLogEntry[]`](#slowlogentry) | +| `slowLogLen()` | — | `number` | +| `slowLogReset()` | — | `void` | + +## types + +### SetOptions + +```ts +interface SetOptions { + ex?: number; // TTL in seconds + px?: number; // TTL in milliseconds + nx?: boolean; // only set if the key does not exist + xx?: boolean; // only set if the key already exists +} +``` + +### GetExOptions + +```ts +interface GetExOptions { + ex?: number; // set expiry in seconds + px?: number; // set expiry in milliseconds + persist?: boolean; // remove expiry, making the key permanent +} +``` + +### ScanOptions + +```ts +interface ScanOptions { + pattern?: string; // glob pattern to filter results + count?: number; // hint for page size (server may return more or fewer) +} +``` + +### ZAddOptions + +```ts +interface ZAddOptions { + nx?: boolean; // only add new members + xx?: boolean; // only update existing members + gt?: boolean; // only update if new score > current score + lt?: boolean; // only update if new score < current score + ch?: boolean; // count changed elements, not just added ones +} +``` + +### ZRangeByScoreOptions + +```ts +interface ZRangeByScoreOptions { + offset?: number; // pagination offset + count?: number; // max results + withScores?: boolean; // include scores in response +} +``` + +### VAddOptions + +```ts +interface VAddOptions { + metric?: VectorMetric; // distance metric (default: cosine) + quantization?: VectorQuantization; // storage quantization + connectivity?: number; // HNSW M parameter + efConstruction?: number; // HNSW ef_construction parameter +} +``` + +### VAddBatchEntry + +```ts +interface VAddBatchEntry { + element: string; + vector: number[]; +} +``` + +### VSimOptions + +```ts +interface VSimOptions { + efSearch?: number; // recall vs. latency trade-off +} +``` + +### ScoreMember + +```ts +interface ScoreMember { + member: string; + score: number; +} +``` + +### VSimResult + +```ts +interface VSimResult { + element: string; + distance: number; +} +``` + +### SlowLogEntry + +```ts +interface SlowLogEntry { + id: number; + timestamp: number; // unix timestamp + durationMicros: number; // execution time in microseconds + command: string; +} +``` + +### SubscribeEvent + +```ts +interface SubscribeEvent { + kind: string; // "message" or "pmessage" + channel: string; + data: Buffer | null; + pattern?: string; // set for pmessage events +} +``` + +### ScanPage + +```ts +interface ScanPage { + cursor: number; // 0 = scan complete + keys: string[]; +} +``` + +### HScanPage + +```ts +interface HScanPage { + cursor: number; + fields: Record; +} +``` + +### ZScanPage + +```ts +interface ZScanPage { + cursor: number; + members: ScoreMember[]; +} +``` + +### SScanPage + +```ts +interface SScanPage { + cursor: number; + members: string[]; +} +``` + +### TimeResult + +```ts +interface TimeResult { + seconds: number; // unix timestamp (whole seconds) + microseconds: number; // offset within the current second +} +``` + +### VGetResult + +```ts +interface VGetResult { + exists: boolean; + vector: number[]; +} +``` + +### VInfoResult + +```ts +interface VInfoResult { + exists: boolean; + info: Record; // metric, dimensions, capacity, etc. +} +``` + +## scan example + +```ts +let cursor = 0; +do { + const page = await client.scan(cursor, { pattern: 'user:*', count: 100 }); + cursor = page.cursor; + for (const key of page.keys) { + console.log(key); + } +} while (cursor !== 0); +``` + +## leaderboard example + +```ts +// add scores +await client.zAdd('scores', [ + { member: 'alice', score: 9500 }, + { member: 'bob', score: 8200 }, + { member: 'carol', score: 9800 }, +]); + +// top 3 +const top = await client.zRange('scores', 0, 2, true); +top.forEach(({ member, score }) => console.log(member, score)); + +// carol's rank (0-indexed from highest) +const rank = await client.zRevRank('scores', 'carol'); // 0 +``` + +## connection details + +- default address: `localhost:6380` +- transport: gRPC over plaintext (TLS support coming) +- the proto file is bundled in `proto/ember/v1/ember.proto` diff --git a/clients/ember-ts/package-lock.json b/clients/ember-ts/package-lock.json new file mode 100644 index 00000000..bc48ee84 --- /dev/null +++ b/clients/ember-ts/package-lock.json @@ -0,0 +1,388 @@ +{ + "name": "ember-ts", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ember-ts", + "version": "0.1.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.0", + "@grpc/proto-loader": "^0.7.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/clients/ember-ts/package.json b/clients/ember-ts/package.json index 84c12e0d..8423518f 100644 --- a/clients/ember-ts/package.json +++ b/clients/ember-ts/package.json @@ -1,19 +1,41 @@ { "name": "ember-ts", "version": "0.1.0", - "description": "TypeScript client for the ember cache server over gRPC", + "description": "TypeScript client for ember — a high-performance gRPC cache", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist/", + "proto/" + ], + "engines": { + "node": ">=18" + }, + "keywords": [ + "ember", + "emberkv", + "cache", + "grpc", + "key-value", + "redis-compatible" + ], "scripts": { "build": "tsc", - "test": "echo \"no tests yet\"", - "proto-gen": "npx --package=@grpc/proto-loader proto-loader-gen-types --proto_path=../../proto --outDir=src/generated ../../proto/ember/v1/ember.proto" + "proto-gen": "npx --yes --package=@grpc/proto-loader proto-loader-gen-types --proto_path=../../proto --outDir=src/generated ../../proto/ember/v1/ember.proto && mkdir -p proto/ember/v1 && cp ../../proto/ember/v1/ember.proto proto/ember/v1/ember.proto", + "test": "echo \"no tests yet\"" }, "dependencies": { "@grpc/grpc-js": "^1.12.0", "@grpc/proto-loader": "^0.7.0" }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.0.0" } } diff --git a/clients/ember-ts/proto/ember/v1/ember.proto b/clients/ember-ts/proto/ember/v1/ember.proto new file mode 100644 index 00000000..cd0a7a35 --- /dev/null +++ b/clients/ember-ts/proto/ember/v1/ember.proto @@ -0,0 +1,1180 @@ +syntax = "proto3"; +package ember.v1; + +option go_package = "github.com/kacy/ember-go/proto/ember/v1;emberv1"; + +// EmberCache provides a gRPC interface to ember's key-value store. +// all commands route through the same engine as RESP3, so behavior +// is identical regardless of protocol. +service EmberCache { + // --- strings --- + + rpc Get(GetRequest) returns (GetResponse); + rpc Set(SetRequest) returns (SetResponse); + rpc Del(DelRequest) returns (DelResponse); + rpc MGet(MGetRequest) returns (MGetResponse); + rpc MSet(MSetRequest) returns (MSetResponse); + rpc Incr(IncrRequest) returns (IntResponse); + rpc IncrBy(IncrByRequest) returns (IntResponse); + rpc DecrBy(DecrByRequest) returns (IntResponse); + rpc IncrByFloat(IncrByFloatRequest) returns (FloatResponse); + rpc Append(AppendRequest) returns (IntResponse); + rpc Strlen(StrlenRequest) returns (IntResponse); + + // --- keys --- + + rpc Exists(ExistsRequest) returns (IntResponse); + rpc Expire(ExpireRequest) returns (BoolResponse); + rpc PExpire(PExpireRequest) returns (BoolResponse); + rpc Persist(PersistRequest) returns (BoolResponse); + rpc Ttl(TtlRequest) returns (TtlResponse); + rpc PTtl(PTtlRequest) returns (TtlResponse); + rpc Type(TypeRequest) returns (TypeResponse); + rpc Keys(KeysRequest) returns (KeysResponse); + rpc Rename(RenameRequest) returns (StatusResponse); + rpc Scan(ScanRequest) returns (ScanResponse); + + // --- lists --- + + rpc LPush(LPushRequest) returns (IntResponse); + rpc RPush(RPushRequest) returns (IntResponse); + rpc LPop(LPopRequest) returns (GetResponse); + rpc RPop(RPopRequest) returns (GetResponse); + rpc LRange(LRangeRequest) returns (ArrayResponse); + rpc LLen(LLenRequest) returns (IntResponse); + + // --- hashes --- + + rpc HSet(HSetRequest) returns (IntResponse); + rpc HGet(HGetRequest) returns (GetResponse); + rpc HGetAll(HGetAllRequest) returns (HashResponse); + rpc HDel(HDelRequest) returns (IntResponse); + rpc HExists(HExistsRequest) returns (BoolResponse); + rpc HLen(HLenRequest) returns (IntResponse); + rpc HIncrBy(HIncrByRequest) returns (IntResponse); + rpc HKeys(HKeysRequest) returns (KeysResponse); + rpc HVals(HValsRequest) returns (ArrayResponse); + rpc HMGet(HMGetRequest) returns (OptionalArrayResponse); + + // --- sets --- + + rpc SAdd(SAddRequest) returns (IntResponse); + rpc SRem(SRemRequest) returns (IntResponse); + rpc SMembers(SMembersRequest) returns (KeysResponse); + rpc SIsMember(SIsMemberRequest) returns (BoolResponse); + rpc SCard(SCardRequest) returns (IntResponse); + + // --- sorted sets --- + + rpc ZAdd(ZAddRequest) returns (IntResponse); + rpc ZRem(ZRemRequest) returns (IntResponse); + rpc ZScore(ZScoreRequest) returns (OptionalFloatResponse); + rpc ZRank(ZRankRequest) returns (OptionalIntResponse); + rpc ZCard(ZCardRequest) returns (IntResponse); + rpc ZRange(ZRangeRequest) returns (ZRangeResponse); + + // --- vectors --- + // only available when the server is built with the vector feature. + + rpc VAdd(VAddRequest) returns (BoolResponse); + rpc VAddBatch(VAddBatchRequest) returns (IntResponse); + rpc VSim(VSimRequest) returns (VSimResponse); + rpc VRem(VRemRequest) returns (BoolResponse); + rpc VGet(VGetRequest) returns (VGetResponse); + rpc VCard(VCardRequest) returns (IntResponse); + rpc VDim(VDimRequest) returns (IntResponse); + rpc VInfo(VInfoRequest) returns (VInfoResponse); + + // --- server --- + + rpc Ping(PingRequest) returns (PingResponse); + rpc Echo(EchoRequest) returns (EchoResponse); + rpc Decr(DecrRequest) returns (IntResponse); + rpc Unlink(UnlinkRequest) returns (DelResponse); + rpc FlushDb(FlushDbRequest) returns (StatusResponse); + rpc DbSize(DbSizeRequest) returns (IntResponse); + rpc Info(InfoRequest) returns (InfoResponse); + rpc BgSave(BgSaveRequest) returns (StatusResponse); + rpc BgRewriteAof(BgRewriteAofRequest) returns (StatusResponse); + + // --- slowlog --- + + rpc SlowLogGet(SlowLogGetRequest) returns (SlowLogGetResponse); + rpc SlowLogLen(SlowLogLenRequest) returns (IntResponse); + rpc SlowLogReset(SlowLogResetRequest) returns (StatusResponse); + + // --- pub/sub --- + + rpc Publish(PublishRequest) returns (IntResponse); + rpc Subscribe(SubscribeRequest) returns (stream SubscribeEvent); + rpc PubSubChannels(PubSubChannelsRequest) returns (KeysResponse); + rpc PubSubNumSub(PubSubNumSubRequest) returns (PubSubNumSubResponse); + rpc PubSubNumPat(PubSubNumPatRequest) returns (IntResponse); + + // --- strings (extended) --- + + rpc GetDel(GetDelRequest) returns (GetResponse); + rpc GetEx(GetExRequest) returns (GetResponse); + rpc GetRange(GetRangeRequest) returns (GetResponse); + rpc SetRange(SetRangeRequest) returns (IntResponse); + + // --- keys (extended) --- + + rpc Copy(CopyRequest) returns (BoolResponse); + rpc RandomKey(RandomKeyRequest) returns (GetResponse); + rpc Touch(TouchRequest) returns (IntResponse); + + // --- lists (extended) --- + + rpc LIndex(LIndexRequest) returns (GetResponse); + rpc LSet(LSetRequest) returns (StatusResponse); + rpc LTrim(LTrimRequest) returns (StatusResponse); + rpc LInsert(LInsertRequest) returns (IntResponse); + rpc LRem(LRemRequest) returns (IntResponse); + rpc LPos(LPosRequest) returns (OptionalIntResponse); + rpc LMove(LMoveRequest) returns (GetResponse); + + // --- sets (extended) --- + + rpc SUnion(SUnionRequest) returns (KeysResponse); + rpc SInter(SInterRequest) returns (KeysResponse); + rpc SDiff(SDiffRequest) returns (KeysResponse); + rpc SUnionStore(SUnionStoreRequest) returns (IntResponse); + rpc SInterStore(SInterStoreRequest) returns (IntResponse); + rpc SDiffStore(SDiffStoreRequest) returns (IntResponse); + rpc SRandMember(SRandMemberRequest) returns (ArrayResponse); + rpc SPop(SPopRequest) returns (ArrayResponse); + rpc SMisMember(SMisMemberRequest) returns (BoolArrayResponse); + + // --- hashes (extended) --- + + rpc HScan(HScanRequest) returns (HScanResponse); + + // --- sorted sets (extended) --- + + rpc ZRevRank(ZRevRankRequest) returns (OptionalIntResponse); + rpc ZRevRange(ZRevRangeRequest) returns (ZRangeResponse); + rpc ZCount(ZCountRequest) returns (IntResponse); + rpc ZIncrBy(ZIncrByRequest) returns (FloatResponse); + rpc ZRangeByScore(ZRangeByScoreRequest) returns (ZRangeResponse); + rpc ZRevRangeByScore(ZRevRangeByScoreRequest) returns (ZRangeResponse); + rpc ZPopMin(ZPopMinRequest) returns (ZRangeResponse); + rpc ZPopMax(ZPopMaxRequest) returns (ZRangeResponse); + rpc ZDiff(ZDiffRequest) returns (ZRangeResponse); + rpc ZInter(ZInterRequest) returns (ZRangeResponse); + rpc ZUnion(ZUnionRequest) returns (ZRangeResponse); + rpc ZScan(ZScanRequest) returns (ZScanResponse); + + // --- scans --- + + rpc SScan(SScanRequest) returns (SScanResponse); + + // --- server (extended) --- + + rpc Time(TimeRequest) returns (TimeResponse); + rpc LastSave(LastSaveRequest) returns (IntResponse); + + // --- streaming --- + // bidirectional streaming for batch operations, matching RESP3 pipelining. + + rpc Pipeline(stream PipelineRequest) returns (stream PipelineResponse); +} + +// --------------------------------------------------------------------------- +// shared response types +// --------------------------------------------------------------------------- + +message IntResponse { + int64 value = 1; +} + +message BoolResponse { + bool value = 1; +} + +message FloatResponse { + string value = 1; +} + +message StatusResponse { + string status = 1; +} + +message BoolArrayResponse { + repeated bool values = 1; +} + +// --------------------------------------------------------------------------- +// strings +// --------------------------------------------------------------------------- + +message GetRequest { + string key = 1; +} + +message GetResponse { + optional bytes value = 1; +} + +message SetRequest { + string key = 1; + bytes value = 2; + // expire time in seconds. 0 means no expiration. + uint64 expire_seconds = 3; + // expire time in milliseconds. takes precedence over expire_seconds. + uint64 expire_millis = 4; + // NX: only set if key does not exist. + bool nx = 5; + // XX: only set if key already exists. + bool xx = 6; +} + +message SetResponse { + // true if the key was set, false if NX/XX condition prevented it. + bool ok = 1; +} + +message DelRequest { + repeated string keys = 1; +} + +message DelResponse { + int64 deleted = 1; +} + +message MGetRequest { + repeated string keys = 1; +} + +message MGetResponse { + // one entry per requested key. missing keys have value unset. + repeated OptionalValue values = 1; +} + +message OptionalValue { + optional bytes value = 1; +} + +message MSetRequest { + repeated KeyValue pairs = 1; +} + +message KeyValue { + string key = 1; + bytes value = 2; +} + +message MSetResponse {} + +message IncrRequest { + string key = 1; +} + +message IncrByRequest { + string key = 1; + int64 delta = 2; +} + +message DecrByRequest { + string key = 1; + int64 delta = 2; +} + +message IncrByFloatRequest { + string key = 1; + double delta = 2; +} + +message AppendRequest { + string key = 1; + bytes value = 2; +} + +message StrlenRequest { + string key = 1; +} + +message GetDelRequest { + string key = 1; +} + +message GetExRequest { + string key = 1; + // set expiry in seconds (ignored if expire_millis > 0). + uint64 expire_seconds = 2; + // set expiry in milliseconds (takes precedence over expire_seconds). + uint64 expire_millis = 3; + // remove the existing expiry, making the key persistent. + bool persist = 4; +} + +message GetRangeRequest { + string key = 1; + int64 start = 2; + int64 end = 3; +} + +message SetRangeRequest { + string key = 1; + int64 offset = 2; + bytes value = 3; +} + +// --------------------------------------------------------------------------- +// keys +// --------------------------------------------------------------------------- + +message ExistsRequest { + repeated string keys = 1; +} + +message ExpireRequest { + string key = 1; + uint64 seconds = 2; +} + +message PExpireRequest { + string key = 1; + uint64 milliseconds = 2; +} + +message PersistRequest { + string key = 1; +} + +message TtlRequest { + string key = 1; +} + +message PTtlRequest { + string key = 1; +} + +message TtlResponse { + // -2 = key does not exist, -1 = no expiry, >= 0 = remaining time. + int64 value = 1; +} + +message TypeRequest { + string key = 1; +} + +message TypeResponse { + string type_name = 1; +} + +message KeysRequest { + string pattern = 1; +} + +message KeysResponse { + repeated string keys = 1; +} + +message RenameRequest { + string key = 1; + string new_key = 2; +} + +message ScanRequest { + uint64 cursor = 1; + uint32 count = 2; + optional string pattern = 3; +} + +message ScanResponse { + uint64 cursor = 1; + repeated string keys = 2; +} + +message CopyRequest { + string source = 1; + string destination = 2; + // overwrite the destination key if it already exists. + bool replace = 3; +} + +message RandomKeyRequest {} + +message TouchRequest { + repeated string keys = 1; +} + +// --------------------------------------------------------------------------- +// lists +// --------------------------------------------------------------------------- + +message LPushRequest { + string key = 1; + repeated bytes values = 2; +} + +message RPushRequest { + string key = 1; + repeated bytes values = 2; +} + +message LPopRequest { + string key = 1; +} + +message RPopRequest { + string key = 1; +} + +message LRangeRequest { + string key = 1; + int64 start = 2; + int64 stop = 3; +} + +message ArrayResponse { + repeated bytes values = 1; +} + +message LLenRequest { + string key = 1; +} + +message LIndexRequest { + string key = 1; + int64 index = 2; +} + +message LSetRequest { + string key = 1; + int64 index = 2; + bytes value = 3; +} + +message LTrimRequest { + string key = 1; + int64 start = 2; + int64 stop = 3; +} + +message LInsertRequest { + string key = 1; + // insert before (true) or after (false) the pivot. + bool before = 2; + bytes pivot = 3; + bytes value = 4; +} + +message LRemRequest { + string key = 1; + // count > 0: remove from head; count < 0: remove from tail; 0: remove all. + int64 count = 2; + bytes value = 3; +} + +message LPosRequest { + string key = 1; + bytes value = 2; + // maximum number of positions to return. absent or 0 returns the first match. + optional uint32 count = 3; +} + +message LMoveRequest { + string source = 1; + string destination = 2; + // pop from the left (true) or right (false) of source. + bool src_left = 3; + // push to the left (true) or right (false) of destination. + bool dst_left = 4; +} + +// --------------------------------------------------------------------------- +// hashes +// --------------------------------------------------------------------------- + +message HSetRequest { + string key = 1; + repeated FieldValue fields = 2; +} + +message FieldValue { + string field = 1; + bytes value = 2; +} + +message HGetRequest { + string key = 1; + string field = 2; +} + +message HGetAllRequest { + string key = 1; +} + +message HashResponse { + repeated FieldValue fields = 1; +} + +message HDelRequest { + string key = 1; + repeated string fields = 2; +} + +message HExistsRequest { + string key = 1; + string field = 2; +} + +message HLenRequest { + string key = 1; +} + +message HIncrByRequest { + string key = 1; + string field = 2; + int64 delta = 3; +} + +message HKeysRequest { + string key = 1; +} + +message HValsRequest { + string key = 1; +} + +message HMGetRequest { + string key = 1; + repeated string fields = 2; +} + +message OptionalArrayResponse { + repeated OptionalValue values = 1; +} + +message HScanRequest { + string key = 1; + uint64 cursor = 2; + optional string pattern = 3; + // hint for how many fields to return per call. server may return more or fewer. + uint32 count = 4; +} + +message HScanResponse { + uint64 cursor = 1; + repeated FieldValue fields = 2; +} + +// --------------------------------------------------------------------------- +// sets +// --------------------------------------------------------------------------- + +message SAddRequest { + string key = 1; + repeated string members = 2; +} + +message SRemRequest { + string key = 1; + repeated string members = 2; +} + +message SMembersRequest { + string key = 1; +} + +message SIsMemberRequest { + string key = 1; + string member = 2; +} + +message SCardRequest { + string key = 1; +} + +message SUnionRequest { + repeated string keys = 1; +} + +message SInterRequest { + repeated string keys = 1; +} + +message SDiffRequest { + repeated string keys = 1; +} + +message SUnionStoreRequest { + string destination = 1; + repeated string keys = 2; +} + +message SInterStoreRequest { + string destination = 1; + repeated string keys = 2; +} + +message SDiffStoreRequest { + string destination = 1; + repeated string keys = 2; +} + +message SRandMemberRequest { + string key = 1; + // positive: return that many unique members; negative: allow repeats. + int32 count = 2; +} + +message SPopRequest { + string key = 1; + uint32 count = 2; +} + +message SMisMemberRequest { + string key = 1; + repeated string members = 2; +} + +message SScanRequest { + string key = 1; + uint64 cursor = 2; + optional string pattern = 3; + uint32 count = 4; +} + +message SScanResponse { + uint64 cursor = 1; + repeated string members = 2; +} + +// --------------------------------------------------------------------------- +// sorted sets +// --------------------------------------------------------------------------- + +message ZAddRequest { + string key = 1; + repeated ScoreMember members = 2; + bool nx = 3; + bool xx = 4; + bool gt = 5; + bool lt = 6; + bool ch = 7; +} + +message ScoreMember { + double score = 1; + string member = 2; +} + +message ZRemRequest { + string key = 1; + repeated string members = 2; +} + +message ZScoreRequest { + string key = 1; + string member = 2; +} + +message OptionalFloatResponse { + optional double value = 1; +} + +message ZRankRequest { + string key = 1; + string member = 2; +} + +message OptionalIntResponse { + optional int64 value = 1; +} + +message ZCardRequest { + string key = 1; +} + +message ZRangeRequest { + string key = 1; + int64 start = 2; + int64 stop = 3; + bool with_scores = 4; +} + +message ZRangeResponse { + // when with_scores is false, only member is populated. + repeated ScoreMember members = 1; +} + +message ZRevRankRequest { + string key = 1; + string member = 2; +} + +message ZRevRangeRequest { + string key = 1; + int64 start = 2; + int64 stop = 3; + bool with_scores = 4; +} + +message ZCountRequest { + string key = 1; + // min/max use redis score range syntax: "-inf", "+inf", "(5", "5". + string min = 2; + string max = 3; +} + +message ZIncrByRequest { + string key = 1; + double delta = 2; + string member = 3; +} + +message ZRangeByScoreRequest { + string key = 1; + string min = 2; + string max = 3; + optional int64 offset = 4; + optional int64 count = 5; + bool with_scores = 6; +} + +message ZRevRangeByScoreRequest { + string key = 1; + // note: max comes before min for reverse range queries. + string max = 2; + string min = 3; + optional int64 offset = 4; + optional int64 count = 5; + bool with_scores = 6; +} + +message ZPopMinRequest { + string key = 1; + uint32 count = 2; +} + +message ZPopMaxRequest { + string key = 1; + uint32 count = 2; +} + +message ZDiffRequest { + repeated string keys = 1; + bool with_scores = 2; +} + +message ZInterRequest { + repeated string keys = 1; + bool with_scores = 2; +} + +message ZUnionRequest { + repeated string keys = 1; + bool with_scores = 2; +} + +message ZScanRequest { + string key = 1; + uint64 cursor = 2; + optional string pattern = 3; + uint32 count = 4; +} + +message ZScanResponse { + uint64 cursor = 1; + repeated ScoreMember members = 2; +} + +// --------------------------------------------------------------------------- +// vectors +// --------------------------------------------------------------------------- + +enum VectorMetric { + VECTOR_METRIC_COSINE = 0; + VECTOR_METRIC_EUCLIDEAN = 1; + VECTOR_METRIC_INNER_PRODUCT = 2; +} + +enum VectorQuantization { + VECTOR_QUANTIZATION_NONE = 0; + VECTOR_QUANTIZATION_F16 = 1; + VECTOR_QUANTIZATION_I8 = 2; +} + +message VAddRequest { + string key = 1; + string element = 2; + // packed IEEE 754 floats — no parsing overhead. + repeated float vector = 3 [packed = true]; + VectorMetric metric = 4; + VectorQuantization quantization = 5; + optional uint32 connectivity = 6; + optional uint32 ef_construction = 7; +} + +message VAddBatchEntry { + string element = 1; + repeated float vector = 2 [packed = true]; +} + +message VAddBatchRequest { + string key = 1; + repeated VAddBatchEntry entries = 2; + VectorMetric metric = 3; + VectorQuantization quantization = 4; + optional uint32 connectivity = 5; + optional uint32 ef_construction = 6; +} + +message VSimRequest { + string key = 1; + repeated float query = 2 [packed = true]; + uint32 count = 3; + optional uint32 ef_search = 4; +} + +message VSimResponse { + repeated VSimResult results = 1; +} + +message VSimResult { + string element = 1; + float distance = 2; +} + +message VRemRequest { + string key = 1; + string element = 2; +} + +message VGetRequest { + string key = 1; + string element = 2; +} + +message VGetResponse { + optional bool exists = 1; + repeated float vector = 2 [packed = true]; +} + +message VCardRequest { + string key = 1; +} + +message VDimRequest { + string key = 1; +} + +message VInfoRequest { + string key = 1; +} + +message VInfoResponse { + bool exists = 1; + repeated FieldValue info = 2; +} + +// --------------------------------------------------------------------------- +// server +// --------------------------------------------------------------------------- + +message PingRequest { + optional string message = 1; +} + +message PingResponse { + string message = 1; +} + +message FlushDbRequest { + bool async = 1; +} + +message DbSizeRequest {} + +message InfoRequest { + optional string section = 1; +} + +message InfoResponse { + string info = 1; +} + +message EchoRequest { + string message = 1; +} + +message EchoResponse { + string message = 1; +} + +message DecrRequest { + string key = 1; +} + +message UnlinkRequest { + repeated string keys = 1; +} + +message BgSaveRequest {} + +message BgRewriteAofRequest {} + +message TimeRequest {} + +message TimeResponse { + int64 seconds = 1; + int64 microseconds = 2; +} + +message LastSaveRequest {} + +// --------------------------------------------------------------------------- +// slowlog +// --------------------------------------------------------------------------- + +message SlowLogGetRequest { + optional uint32 count = 1; +} + +message SlowLogGetResponse { + repeated SlowLogEntry entries = 1; +} + +message SlowLogEntry { + uint64 id = 1; + uint64 timestamp_unix = 2; + uint64 duration_micros = 3; + string command = 4; +} + +message SlowLogLenRequest {} + +message SlowLogResetRequest {} + +// --------------------------------------------------------------------------- +// pub/sub +// --------------------------------------------------------------------------- + +message PublishRequest { + string channel = 1; + bytes message = 2; +} + +message SubscribeRequest { + repeated string channels = 1; + repeated string patterns = 2; +} + +message SubscribeEvent { + // "message" for exact channel match, "pmessage" for pattern match. + string kind = 1; + string channel = 2; + bytes data = 3; + // the pattern that matched (only set for pmessage). + optional string pattern = 4; +} + +message PubSubChannelsRequest { + optional string pattern = 1; +} + +message PubSubNumSubRequest { + repeated string channels = 1; +} + +message PubSubNumSubResponse { + repeated ChannelCount counts = 1; +} + +message PubSubNumPatRequest {} + +message ChannelCount { + string channel = 1; + int64 count = 2; +} + +// --------------------------------------------------------------------------- +// pipeline (bidirectional streaming) +// --------------------------------------------------------------------------- + +message PipelineRequest { + uint64 id = 1; + oneof command { + GetRequest get = 2; + SetRequest set = 3; + DelRequest del = 4; + ExistsRequest exists = 5; + IncrRequest incr = 6; + IncrByRequest incr_by = 7; + DecrByRequest decr_by = 8; + IncrByFloatRequest incr_by_float = 9; + AppendRequest append = 10; + StrlenRequest strlen = 11; + ExpireRequest expire = 12; + PExpireRequest pexpire = 13; + PersistRequest persist = 14; + TtlRequest ttl = 15; + PTtlRequest pttl = 16; + TypeRequest type = 17; + LPushRequest lpush = 18; + RPushRequest rpush = 19; + LPopRequest lpop = 20; + RPopRequest rpop = 21; + LRangeRequest lrange = 22; + LLenRequest llen = 23; + HSetRequest hset = 24; + HGetRequest hget = 25; + HGetAllRequest hgetall = 26; + HDelRequest hdel = 27; + HExistsRequest hexists = 28; + HLenRequest hlen = 29; + HIncrByRequest hincr_by = 30; + HKeysRequest hkeys = 31; + HValsRequest hvals = 32; + HMGetRequest hmget = 33; + SAddRequest sadd = 34; + SRemRequest srem = 35; + SMembersRequest smembers = 36; + SIsMemberRequest sismember = 37; + SCardRequest scard = 38; + ZAddRequest zadd = 39; + ZRemRequest zrem = 40; + ZScoreRequest zscore = 41; + ZRankRequest zrank = 42; + ZCardRequest zcard = 43; + ZRangeRequest zrange = 44; + VAddRequest vadd = 45; + VSimRequest vsim = 46; + VRemRequest vrem = 47; + VGetRequest vget = 48; + VCardRequest vcard = 49; + VDimRequest vdim = 50; + VInfoRequest vinfo = 51; + PingRequest ping = 52; + FlushDbRequest flushdb = 53; + DbSizeRequest dbsize = 54; + MGetRequest mget = 55; + MSetRequest mset = 56; + KeysRequest keys = 57; + RenameRequest rename = 58; + ScanRequest scan = 59; + EchoRequest echo = 60; + DecrRequest decr = 61; + UnlinkRequest unlink = 62; + BgSaveRequest bgsave = 63; + BgRewriteAofRequest bgrewriteaof = 64; + SlowLogGetRequest slowlog_get = 65; + SlowLogLenRequest slowlog_len = 66; + SlowLogResetRequest slowlog_reset = 67; + PublishRequest publish = 68; + PubSubChannelsRequest pubsub_channels = 69; + PubSubNumSubRequest pubsub_numsub = 70; + PubSubNumPatRequest pubsub_numpat = 71; + VAddBatchRequest vadd_batch = 72; + + // extended strings + GetDelRequest get_del = 73; + GetExRequest get_ex = 74; + GetRangeRequest get_range = 75; + SetRangeRequest set_range = 76; + + // extended keys + CopyRequest copy = 77; + RandomKeyRequest random_key = 78; + TouchRequest touch = 79; + + // extended lists + LIndexRequest lindex = 80; + LSetRequest lset = 81; + LTrimRequest ltrim = 82; + LInsertRequest linsert = 83; + LRemRequest lrem = 84; + LPosRequest lpos = 85; + LMoveRequest lmove = 86; + + // extended sets + SUnionRequest sunion = 87; + SInterRequest sinter = 88; + SDiffRequest sdiff = 89; + SUnionStoreRequest sunion_store = 90; + SInterStoreRequest sinter_store = 91; + SDiffStoreRequest sdiff_store = 92; + SRandMemberRequest srand_member = 93; + SPopRequest spop = 94; + SMisMemberRequest smismember = 95; + + // extended hashes + HScanRequest hscan = 96; + + // extended sorted sets + ZRevRankRequest zrev_rank = 97; + ZRevRangeRequest zrev_range = 98; + ZCountRequest zcount = 99; + ZIncrByRequest zincrby = 100; + ZRangeByScoreRequest zrange_by_score = 101; + ZRevRangeByScoreRequest zrev_range_by_score = 102; + ZPopMinRequest zpopmin = 103; + ZPopMaxRequest zpopmax = 104; + ZDiffRequest zdiff = 105; + ZInterRequest zinter = 106; + ZUnionRequest zunion = 107; + ZScanRequest zscan = 108; + + // scans + SScanRequest sscan = 109; + + // extended server + TimeRequest time = 110; + LastSaveRequest last_save = 111; + } +} + +message PipelineResponse { + uint64 id = 1; + oneof result { + GetResponse get = 2; + SetResponse set = 3; + DelResponse del = 4; + IntResponse int_val = 5; + BoolResponse bool_val = 6; + FloatResponse float_val = 7; + StatusResponse status = 8; + TtlResponse ttl = 9; + TypeResponse type = 10; + ArrayResponse array = 11; + HashResponse hash = 12; + OptionalArrayResponse optional_array = 13; + KeysResponse keys = 14; + ScanResponse scan = 15; + OptionalFloatResponse optional_float = 16; + OptionalIntResponse optional_int = 17; + ZRangeResponse zrange = 18; + VSimResponse vsim = 19; + VGetResponse vget = 20; + VInfoResponse vinfo = 21; + MGetResponse mget = 22; + MSetResponse mset = 23; + PingResponse ping = 24; + ErrorResponse error = 25; + InfoResponse info = 26; + EchoResponse echo = 27; + SlowLogGetResponse slowlog_get = 28; + PubSubNumSubResponse pubsub_numsub = 29; + BoolArrayResponse bool_array = 30; + HScanResponse hscan = 31; + ZScanResponse zscan = 32; + SScanResponse sscan = 33; + TimeResponse time_resp = 34; + } +} + +message ErrorResponse { + string message = 1; + ErrorKind kind = 2; +} + +enum ErrorKind { + ERROR_KIND_UNSPECIFIED = 0; + ERROR_KIND_WRONG_TYPE = 1; + ERROR_KIND_OUT_OF_MEMORY = 2; + ERROR_KIND_INTERNAL = 3; + ERROR_KIND_INVALID_ARGUMENT = 4; +} diff --git a/clients/ember-ts/src/client.ts b/clients/ember-ts/src/client.ts new file mode 100644 index 00000000..afb17733 --- /dev/null +++ b/clients/ember-ts/src/client.ts @@ -0,0 +1,1410 @@ +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; +import path from 'path'; + +import type { + ClientOptions, + SetOptions, + GetExOptions, + ScanOptions, + ZAddOptions, + ZRangeByScoreOptions, + VAddOptions, + VAddBatchOptions, + VAddBatchEntry, + VSimOptions, + ScoreMember, + VSimResult, + SlowLogEntry, + SubscribeEvent, + ScanPage, + HScanPage, + ZScanPage, + SScanPage, + TimeResult, + VGetResult, + VInfoResult, +} from './types'; + +// --------------------------------------------------------------------------- +// proto loading — done once, cached for the lifetime of the process +// --------------------------------------------------------------------------- + +let _stubCtor: grpc.ServiceClientConstructor | undefined; + +function loadStub(): grpc.ServiceClientConstructor { + if (_stubCtor) return _stubCtor; + + const protoPath = path.join(__dirname, '../proto/ember/v1/ember.proto'); + const pkgDef = protoLoader.loadSync(protoPath, { + keepCase: false, + longs: Number, + enums: String, + defaults: true, + oneofs: true, + }); + + const pkg = grpc.loadPackageDefinition(pkgDef) as any; + _stubCtor = pkg.ember.v1.EmberCache as grpc.ServiceClientConstructor; + return _stubCtor; +} + +// --------------------------------------------------------------------------- +// client +// --------------------------------------------------------------------------- + +/** + * A gRPC client for the ember cache server. + * + * All methods return Promises and throw on error. Pub/sub streaming is + * exposed as an `AsyncIterable` so you can use `for await` naturally. + * + * @example + * ```ts + * const client = new EmberClient('localhost:6380'); + * await client.set('greeting', 'hello'); + * const val = await client.get('greeting'); + * console.log(val?.toString()); // "hello" + * client.close(); + * ``` + */ +export class EmberClient { + private readonly stub: InstanceType; + private readonly meta: grpc.Metadata; + + /** + * @param address - `host:port` of the ember server. defaults to `localhost:6380`. + * @param options - optional client configuration. + */ + constructor(address = 'localhost:6380', options: ClientOptions = {}) { + const Ctor = loadStub(); + this.stub = new Ctor(address, grpc.credentials.createInsecure()); + this.meta = new grpc.Metadata(); + if (options.password) { + this.meta.set('authorization', options.password); + } + } + + /** closes the underlying gRPC connection. */ + close(): void { + this.stub.close(); + } + + // wraps a unary gRPC call in a Promise + private call(method: string, req: object): Promise { + return new Promise((resolve, reject) => { + (this.stub as any)[method](req, this.meta, (err: Error | null, res: T) => { + if (err) reject(err); + else resolve(res); + }); + }); + } + + // wraps a server-streaming RPC as an AsyncIterable + private stream(method: string, req: object): AsyncIterable { + return { + [Symbol.asyncIterator]: () => { + const call = (this.stub as any)[method](req, this.meta) as grpc.ClientReadableStream; + + // buffer events that arrive before the next() caller is ready + const queue: Array<{ value?: T; error?: Error; done?: true }> = []; + let waiter: { + resolve: (v: IteratorResult) => void; + reject: (e: Error) => void; + } | null = null; + + const enqueue = (item: { value?: T; error?: Error; done?: true }) => { + if (waiter) { + const w = waiter; + waiter = null; + if (item.error) w.reject(item.error); + else w.resolve({ value: item.value as T, done: !!item.done }); + } else { + queue.push(item); + } + }; + + call.on('data', (v: T) => enqueue({ value: v })); + call.on('end', () => enqueue({ done: true })); + call.on('error', (err: Error) => enqueue({ error: err })); + + return { + next(): Promise> { + if (queue.length > 0) { + const item = queue.shift()!; + if (item.error) return Promise.reject(item.error); + return Promise.resolve({ value: item.value as T, done: !!item.done }); + } + return new Promise>((resolve, reject) => { + waiter = { resolve, reject }; + }); + }, + return(): Promise> { + call.cancel(); + return Promise.resolve({ value: undefined as unknown as T, done: true }); + }, + }; + }, + }; + } + + // --------------------------------------------------------------------------- + // strings + // --------------------------------------------------------------------------- + + /** + * Returns the value for a key, or `null` if the key does not exist. + */ + async get(key: string): Promise { + const res = await this.call<{ value?: Buffer }>('get', { key }); + return res.value ?? null; + } + + /** + * Stores a key-value pair. Returns `true` if the key was set. + * + * NX/XX conditions: if the condition is not met, returns `false` without + * modifying the key. + * + * @example + * ```ts + * await client.set('counter', '0', { ex: 60 }); // expires in 60 seconds + * await client.set('lock', '1', { nx: true }); // only if absent + * ``` + */ + async set(key: string, value: Buffer | string, opts: SetOptions = {}): Promise { + const req: Record = { + key, + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + }; + if (opts.ex) req.expireSeconds = opts.ex; + if (opts.px) req.expireMillis = opts.px; + if (opts.nx) req.nx = true; + if (opts.xx) req.xx = true; + const res = await this.call<{ ok: boolean }>('set', req); + return res.ok; + } + + /** + * Removes one or more keys. Returns the number of keys deleted. + */ + async del(...keys: string[]): Promise { + const res = await this.call<{ deleted: number }>('del', { keys }); + return res.deleted; + } + + /** + * Returns values for multiple keys in one round-trip. + * Missing keys have `null` at their position. + */ + async mGet(keys: string[]): Promise<(Buffer | null)[]> { + const res = await this.call<{ values: Array<{ value?: Buffer }> }>('mGet', { keys }); + return res.values.map(v => v.value ?? null); + } + + /** + * Sets multiple key-value pairs atomically. + */ + async mSet(entries: Record): Promise { + const pairs = Object.entries(entries).map(([key, value]) => ({ + key, + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + })); + await this.call('mSet', { pairs }); + } + + /** + * Increments a key by 1 and returns the new value. + * Creates the key with value `0` before incrementing if it does not exist. + */ + async incr(key: string): Promise { + const res = await this.call<{ value: number }>('incr', { key }); + return res.value; + } + + /** + * Increments a key by `delta` and returns the new value. + */ + async incrBy(key: string, delta: number): Promise { + const res = await this.call<{ value: number }>('incrBy', { key, delta }); + return res.value; + } + + /** + * Decrements a key by `delta` and returns the new value. + */ + async decrBy(key: string, delta: number): Promise { + const res = await this.call<{ value: number }>('decrBy', { key, delta }); + return res.value; + } + + /** + * Decrements a key by 1 and returns the new value. + */ + async decr(key: string): Promise { + const res = await this.call<{ value: number }>('decr', { key }); + return res.value; + } + + /** + * Increments a key by a floating-point `delta` and returns the new value. + */ + async incrByFloat(key: string, delta: number): Promise { + const res = await this.call<{ value: string }>('incrByFloat', { key, delta }); + return parseFloat(res.value); + } + + /** + * Appends `value` to the string at `key`. Returns the new string length. + */ + async append(key: string, value: Buffer | string): Promise { + const res = await this.call<{ value: number }>('append', { + key, + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + }); + return res.value; + } + + /** + * Returns the byte length of the string at `key`. Returns `0` if the key does not exist. + */ + async strlen(key: string): Promise { + const res = await this.call<{ value: number }>('strlen', { key }); + return res.value; + } + + /** + * Atomically gets and deletes a key. Returns `null` if the key did not exist. + */ + async getDel(key: string): Promise { + const res = await this.call<{ value?: Buffer }>('getDel', { key }); + return res.value ?? null; + } + + /** + * Gets a key and optionally updates its expiry in the same round-trip. + * Pass `{ persist: true }` to remove the expiry. + */ + async getEx(key: string, opts: GetExOptions = {}): Promise { + const req: Record = { key }; + if (opts.ex) req.expireSeconds = opts.ex; + if (opts.px) req.expireMillis = opts.px; + if (opts.persist) req.persist = true; + const res = await this.call<{ value?: Buffer }>('getEx', req); + return res.value ?? null; + } + + /** + * Returns the substring of the string at `key` for the byte range [start, end]. + * Negative indices count from the end of the string. + */ + async getRange(key: string, start: number, end: number): Promise { + const res = await this.call<{ value?: Buffer }>('getRange', { key, start, end }); + return res.value ?? Buffer.alloc(0); + } + + /** + * Overwrites part of the string at `key` starting at byte `offset`. + * Returns the new string length. + */ + async setRange(key: string, offset: number, value: Buffer | string): Promise { + const res = await this.call<{ value: number }>('setRange', { + key, + offset, + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + }); + return res.value; + } + + // --------------------------------------------------------------------------- + // keys + // --------------------------------------------------------------------------- + + /** + * Returns the number of the given keys that exist. + * A key specified multiple times is counted multiple times. + */ + async exists(...keys: string[]): Promise { + const res = await this.call<{ value: number }>('exists', { keys }); + return res.value; + } + + /** + * Sets a timeout on a key in seconds. Returns `true` if the timeout was set. + */ + async expire(key: string, seconds: number): Promise { + const res = await this.call<{ value: boolean }>('expire', { key, seconds }); + return res.value; + } + + /** + * Sets a timeout on a key in milliseconds. Returns `true` if the timeout was set. + */ + async pExpire(key: string, milliseconds: number): Promise { + const res = await this.call<{ value: boolean }>('pExpire', { key, milliseconds }); + return res.value; + } + + /** + * Removes the expiry from a key, making it persistent. + * Returns `true` if the timeout was removed, `false` if the key has no expiry. + */ + async persist(key: string): Promise { + const res = await this.call<{ value: boolean }>('persist', { key }); + return res.value; + } + + /** + * Returns the remaining time to live in seconds. + * Returns `-1` if the key has no expiry, `-2` if the key does not exist. + */ + async ttl(key: string): Promise { + const res = await this.call<{ value: number }>('ttl', { key }); + return res.value; + } + + /** + * Returns the remaining time to live in milliseconds. + * Returns `-1` if the key has no expiry, `-2` if the key does not exist. + */ + async pTtl(key: string): Promise { + const res = await this.call<{ value: number }>('pTtl', { key }); + return res.value; + } + + /** + * Returns the data type stored at `key`: `"string"`, `"list"`, `"set"`, + * `"zset"`, `"hash"`, or `"none"` if the key does not exist. + */ + async type(key: string): Promise { + const res = await this.call<{ typeName: string }>('type', { key }); + return res.typeName; + } + + /** + * Returns all keys matching `pattern`. Supports glob-style patterns: + * `*` matches any sequence, `?` matches any single character, `[abc]` matches a character set. + * + * Avoid running KEYS in production on large datasets — use SCAN instead. + */ + async keys(pattern: string): Promise { + const res = await this.call<{ keys: string[] }>('keys', { pattern }); + return res.keys; + } + + /** + * Renames `key` to `newKey`. Throws if `key` does not exist. + */ + async rename(key: string, newKey: string): Promise { + await this.call('rename', { key, newKey }); + } + + /** + * Iterates over the keyspace one page at a time. + * Start with cursor `0`; the scan is complete when the returned cursor is `0`. + * + * @example + * ```ts + * let cursor = 0; + * do { + * const page = await client.scan(cursor, { pattern: 'user:*', count: 100 }); + * cursor = page.cursor; + * for (const key of page.keys) console.log(key); + * } while (cursor !== 0); + * ``` + */ + async scan(cursor: number, opts: ScanOptions = {}): Promise { + const req: Record = { cursor, count: opts.count ?? 0 }; + if (opts.pattern != null) req.pattern = opts.pattern; + const res = await this.call<{ cursor: number; keys: string[] }>('scan', req); + return { cursor: res.cursor, keys: res.keys }; + } + + /** + * Copies `source` to `destination`. Pass `replace: true` to overwrite an existing key. + * Returns `true` if the key was copied. + */ + async copy(source: string, destination: string, replace = false): Promise { + const res = await this.call<{ value: boolean }>('copy', { source, destination, replace }); + return res.value; + } + + /** + * Returns a random key from the keyspace, or `null` if the database is empty. + */ + async randomKey(): Promise { + const res = await this.call<{ value?: Buffer }>('randomKey', {}); + return res.value ? res.value.toString() : null; + } + + /** + * Updates the last-access time for the given keys without changing their values. + * Returns the number of keys that exist. + */ + async touch(...keys: string[]): Promise { + const res = await this.call<{ value: number }>('touch', { keys }); + return res.value; + } + + /** + * Removes keys asynchronously (background deallocation). + * Returns the number of keys removed. + */ + async unlink(...keys: string[]): Promise { + const res = await this.call<{ deleted: number }>('unlink', { keys }); + return res.deleted; + } + + // --------------------------------------------------------------------------- + // lists + // --------------------------------------------------------------------------- + + /** + * Prepends one or more values to a list. Returns the new list length. + */ + async lPush(key: string, ...values: (Buffer | string)[]): Promise { + const res = await this.call<{ value: number }>('lPush', { + key, + values: values.map(v => (Buffer.isBuffer(v) ? v : Buffer.from(v))), + }); + return res.value; + } + + /** + * Appends one or more values to a list. Returns the new list length. + */ + async rPush(key: string, ...values: (Buffer | string)[]): Promise { + const res = await this.call<{ value: number }>('rPush', { + key, + values: values.map(v => (Buffer.isBuffer(v) ? v : Buffer.from(v))), + }); + return res.value; + } + + /** + * Removes and returns the first element of a list. + * Returns `null` if the list is empty or does not exist. + */ + async lPop(key: string): Promise { + const res = await this.call<{ value?: Buffer }>('lPop', { key }); + return res.value ?? null; + } + + /** + * Removes and returns the last element of a list. + * Returns `null` if the list is empty or does not exist. + */ + async rPop(key: string): Promise { + const res = await this.call<{ value?: Buffer }>('rPop', { key }); + return res.value ?? null; + } + + /** + * Returns the elements in the range [start, stop]. + * Negative indices count from the tail: `-1` is the last element. + */ + async lRange(key: string, start: number, stop: number): Promise { + const res = await this.call<{ values: Buffer[] }>('lRange', { key, start, stop }); + return res.values; + } + + /** + * Returns the number of elements in a list. + */ + async lLen(key: string): Promise { + const res = await this.call<{ value: number }>('lLen', { key }); + return res.value; + } + + /** + * Returns the element at `index`. Negative indices count from the tail. + * Returns `null` if the index is out of range. + */ + async lIndex(key: string, index: number): Promise { + const res = await this.call<{ value?: Buffer }>('lIndex', { key, index }); + return res.value ?? null; + } + + /** + * Sets the element at `index` to `value`. Throws if the index is out of range. + */ + async lSet(key: string, index: number, value: Buffer | string): Promise { + await this.call('lSet', { + key, + index, + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + }); + } + + /** + * Trims the list so it only contains elements in [start, stop]. + */ + async lTrim(key: string, start: number, stop: number): Promise { + await this.call('lTrim', { key, start, stop }); + } + + /** + * Inserts `value` before or after the first occurrence of `pivot` in the list. + * Returns the new list length, or `-1` if `pivot` was not found. + */ + async lInsert( + key: string, + before: boolean, + pivot: Buffer | string, + value: Buffer | string, + ): Promise { + const res = await this.call<{ value: number }>('lInsert', { + key, + before, + pivot: Buffer.isBuffer(pivot) ? pivot : Buffer.from(pivot), + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + }); + return res.value; + } + + /** + * Removes occurrences of `value` from the list. + * `count > 0`: remove from head; `count < 0`: remove from tail; `0`: remove all. + * Returns the number of elements removed. + */ + async lRem(key: string, count: number, value: Buffer | string): Promise { + const res = await this.call<{ value: number }>('lRem', { + key, + count, + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + }); + return res.value; + } + + /** + * Returns the first index of `value` in the list, or `null` if not found. + * Pass `count` to find the first N occurrences (returns the first match only when absent). + */ + async lPos(key: string, value: Buffer | string, count?: number): Promise { + const req: Record = { + key, + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + }; + if (count != null) req.count = count; + const res = await this.call<{ value?: number }>('lPos', req); + return res.value ?? null; + } + + /** + * Atomically pops an element from `source` and pushes it to `destination`. + * Pass `srcLeft: true` to pop from the head of source, + * `dstLeft: true` to push to the head of destination. + * Returns the moved element, or `null` if `source` is empty. + */ + async lMove( + source: string, + destination: string, + srcLeft: boolean, + dstLeft: boolean, + ): Promise { + const res = await this.call<{ value?: Buffer }>('lMove', { + source, + destination, + srcLeft, + dstLeft, + }); + return res.value ?? null; + } + + // --------------------------------------------------------------------------- + // hashes + // --------------------------------------------------------------------------- + + /** + * Sets one or more fields in a hash. Returns the number of new fields added. + * + * @example + * ```ts + * await client.hSet('user:1', { name: 'alice', email: 'alice@example.com' }); + * ``` + */ + async hSet(key: string, fields: Record): Promise { + const fieldValues = Object.entries(fields).map(([field, value]) => ({ + field, + value: Buffer.isBuffer(value) ? value : Buffer.from(value), + })); + const res = await this.call<{ value: number }>('hSet', { key, fields: fieldValues }); + return res.value; + } + + /** + * Returns the value of a field in a hash, or `null` if the field does not exist. + */ + async hGet(key: string, field: string): Promise { + const res = await this.call<{ value?: Buffer }>('hGet', { key, field }); + return res.value ?? null; + } + + /** + * Returns all fields and values in a hash as a plain object. + */ + async hGetAll(key: string): Promise> { + const res = await this.call<{ fields: Array<{ field: string; value: Buffer }> }>( + 'hGetAll', + { key }, + ); + const result: Record = {}; + for (const fv of res.fields) { + result[fv.field] = fv.value; + } + return result; + } + + /** + * Removes fields from a hash. Returns the number of fields deleted. + */ + async hDel(key: string, ...fields: string[]): Promise { + const res = await this.call<{ value: number }>('hDel', { key, fields }); + return res.value; + } + + /** + * Returns `true` if a field exists in a hash. + */ + async hExists(key: string, field: string): Promise { + const res = await this.call<{ value: boolean }>('hExists', { key, field }); + return res.value; + } + + /** + * Returns the number of fields in a hash. + */ + async hLen(key: string): Promise { + const res = await this.call<{ value: number }>('hLen', { key }); + return res.value; + } + + /** + * Returns all field names in a hash. + */ + async hKeys(key: string): Promise { + const res = await this.call<{ keys: string[] }>('hKeys', { key }); + return res.keys; + } + + /** + * Returns all field values in a hash. + */ + async hVals(key: string): Promise { + const res = await this.call<{ values: Buffer[] }>('hVals', { key }); + return res.values; + } + + /** + * Returns values for the specified fields in a hash. + * Missing fields have `null` at their position. + */ + async hmGet(key: string, fields: string[]): Promise<(Buffer | null)[]> { + const res = await this.call<{ values: Array<{ value?: Buffer }> }>('hmGet', { key, fields }); + return res.values.map(v => v.value ?? null); + } + + /** + * Increments the integer value of a hash field by `delta`. Returns the new value. + */ + async hIncrBy(key: string, field: string, delta: number): Promise { + const res = await this.call<{ value: number }>('hIncrBy', { key, field, delta }); + return res.value; + } + + /** + * Iterates over fields in a hash. Start with cursor `0`; done when the + * returned cursor is `0`. + */ + async hScan(key: string, cursor: number, opts: ScanOptions = {}): Promise { + const req: Record = { key, cursor, count: opts.count ?? 0 }; + if (opts.pattern != null) req.pattern = opts.pattern; + const res = await this.call<{ + cursor: number; + fields: Array<{ field: string; value: Buffer }>; + }>('hScan', req); + const fields: Record = {}; + for (const fv of res.fields) { + fields[fv.field] = fv.value; + } + return { cursor: res.cursor, fields }; + } + + // --------------------------------------------------------------------------- + // sets + // --------------------------------------------------------------------------- + + /** + * Adds members to a set. Returns the number of new members added. + */ + async sAdd(key: string, ...members: string[]): Promise { + const res = await this.call<{ value: number }>('sAdd', { key, members }); + return res.value; + } + + /** + * Removes members from a set. Returns the number of members removed. + */ + async sRem(key: string, ...members: string[]): Promise { + const res = await this.call<{ value: number }>('sRem', { key, members }); + return res.value; + } + + /** + * Returns all members of a set. + */ + async sMembers(key: string): Promise { + const res = await this.call<{ keys: string[] }>('sMembers', { key }); + return res.keys; + } + + /** + * Returns `true` if `member` belongs to the set at `key`. + */ + async sIsMember(key: string, member: string): Promise { + const res = await this.call<{ value: boolean }>('sIsMember', { key, member }); + return res.value; + } + + /** + * Returns the number of members in a set. + */ + async sCard(key: string): Promise { + const res = await this.call<{ value: number }>('sCard', { key }); + return res.value; + } + + /** + * Returns the union of two or more sets. + */ + async sUnion(...keys: string[]): Promise { + const res = await this.call<{ keys: string[] }>('sUnion', { keys }); + return res.keys; + } + + /** + * Returns the intersection of two or more sets. + */ + async sInter(...keys: string[]): Promise { + const res = await this.call<{ keys: string[] }>('sInter', { keys }); + return res.keys; + } + + /** + * Returns the members in the first set that are not in any of the subsequent sets. + */ + async sDiff(...keys: string[]): Promise { + const res = await this.call<{ keys: string[] }>('sDiff', { keys }); + return res.keys; + } + + /** + * Stores the union of sets at `destination`. Returns the number of elements stored. + */ + async sUnionStore(destination: string, ...keys: string[]): Promise { + const res = await this.call<{ value: number }>('sUnionStore', { destination, keys }); + return res.value; + } + + /** + * Stores the intersection of sets at `destination`. Returns the number of elements stored. + */ + async sInterStore(destination: string, ...keys: string[]): Promise { + const res = await this.call<{ value: number }>('sInterStore', { destination, keys }); + return res.value; + } + + /** + * Stores the difference of sets at `destination`. Returns the number of elements stored. + */ + async sDiffStore(destination: string, ...keys: string[]): Promise { + const res = await this.call<{ value: number }>('sDiffStore', { destination, keys }); + return res.value; + } + + /** + * Returns `count` random members from a set. + * `count > 0` returns unique members; `count < 0` allows duplicates. + */ + async sRandMember(key: string, count: number): Promise { + const res = await this.call<{ values: Buffer[] }>('sRandMember', { key, count }); + return res.values.map(v => v.toString()); + } + + /** + * Removes and returns `count` random members from a set. + */ + async sPop(key: string, count: number): Promise { + const res = await this.call<{ values: Buffer[] }>('sPop', { key, count }); + return res.values.map(v => v.toString()); + } + + /** + * Returns a boolean for each member indicating whether it belongs to the set. + */ + async sMisMember(key: string, ...members: string[]): Promise { + const res = await this.call<{ values: boolean[] }>('sMisMember', { key, members }); + return res.values; + } + + /** + * Iterates over members of a set. Start with cursor `0`; done when the + * returned cursor is `0`. + */ + async sScan(key: string, cursor: number, opts: ScanOptions = {}): Promise { + const req: Record = { key, cursor, count: opts.count ?? 0 }; + if (opts.pattern != null) req.pattern = opts.pattern; + const res = await this.call<{ cursor: number; members: string[] }>('sScan', req); + return { cursor: res.cursor, members: res.members }; + } + + // --------------------------------------------------------------------------- + // sorted sets + // --------------------------------------------------------------------------- + + /** + * Adds members to a sorted set. Returns the number added + * (or changed when `ch: true`). + * + * @example + * ```ts + * await client.zAdd('leaderboard', [ + * { member: 'alice', score: 9500 }, + * { member: 'bob', score: 8200 }, + * ]); + * ``` + */ + async zAdd(key: string, members: ScoreMember[], opts: ZAddOptions = {}): Promise { + const req: Record = { + key, + members: members.map(m => ({ score: m.score, member: m.member })), + }; + if (opts.nx) req.nx = true; + if (opts.xx) req.xx = true; + if (opts.gt) req.gt = true; + if (opts.lt) req.lt = true; + if (opts.ch) req.ch = true; + const res = await this.call<{ value: number }>('zAdd', req); + return res.value; + } + + /** + * Removes members from a sorted set. Returns the number removed. + */ + async zRem(key: string, ...members: string[]): Promise { + const res = await this.call<{ value: number }>('zRem', { key, members }); + return res.value; + } + + /** + * Returns the score of a member, or `null` if the member does not exist. + */ + async zScore(key: string, member: string): Promise { + const res = await this.call<{ value?: number }>('zScore', { key, member }); + return res.value ?? null; + } + + /** + * Returns the 0-based rank of a member (sorted ascending by score), + * or `null` if the member does not exist. + */ + async zRank(key: string, member: string): Promise { + const res = await this.call<{ value?: number }>('zRank', { key, member }); + return res.value ?? null; + } + + /** + * Returns the reverse rank of a member (rank 0 = highest score), + * or `null` if the member does not exist. + */ + async zRevRank(key: string, member: string): Promise { + const res = await this.call<{ value?: number }>('zRevRank', { key, member }); + return res.value ?? null; + } + + /** + * Returns the number of members in a sorted set. + */ + async zCard(key: string): Promise { + const res = await this.call<{ value: number }>('zCard', { key }); + return res.value; + } + + /** + * Returns members by rank in ascending order. Pass `withScores: true` to + * include scores (always populated in the result regardless, but only + * meaningful when requested). + */ + async zRange(key: string, start: number, stop: number, withScores = false): Promise { + const res = await this.call<{ members: Array<{ score: number; member: string }> }>('zRange', { + key, start, stop, withScores, + }); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Returns members by rank in descending order. + */ + async zRevRange(key: string, start: number, stop: number, withScores = false): Promise { + const res = await this.call<{ members: Array<{ score: number; member: string }> }>('zRevRange', { + key, start, stop, withScores, + }); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Returns the count of members with scores in the range [min, max]. + * Supports redis score range syntax: `"-inf"`, `"+inf"`, `"(5"` (exclusive), `"5"`. + */ + async zCount(key: string, min: string, max: string): Promise { + const res = await this.call<{ value: number }>('zCount', { key, min, max }); + return res.value; + } + + /** + * Increments the score of `member` by `delta`. Returns the new score. + */ + async zIncrBy(key: string, delta: number, member: string): Promise { + const res = await this.call<{ value: string }>('zIncrBy', { key, delta, member }); + return parseFloat(res.value); + } + + /** + * Returns members with scores in [min, max] in ascending order. + * Supports redis score range syntax. + */ + async zRangeByScore( + key: string, + min: string, + max: string, + opts: ZRangeByScoreOptions = {}, + ): Promise { + const req: Record = { key, min, max, withScores: opts.withScores ?? false }; + if (opts.offset != null) req.offset = opts.offset; + if (opts.count != null) req.count = opts.count; + const res = await this.call<{ members: Array<{ score: number; member: string }> }>( + 'zRangeByScore', req, + ); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Returns members with scores in [min, max] in descending order. + * Note: `max` comes before `min` for reverse range queries. + */ + async zRevRangeByScore( + key: string, + max: string, + min: string, + opts: ZRangeByScoreOptions = {}, + ): Promise { + const req: Record = { key, max, min, withScores: opts.withScores ?? false }; + if (opts.offset != null) req.offset = opts.offset; + if (opts.count != null) req.count = opts.count; + const res = await this.call<{ members: Array<{ score: number; member: string }> }>( + 'zRevRangeByScore', req, + ); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Removes and returns up to `count` members with the lowest scores. + */ + async zPopMin(key: string, count = 1): Promise { + const res = await this.call<{ members: Array<{ score: number; member: string }> }>( + 'zPopMin', { key, count }, + ); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Removes and returns up to `count` members with the highest scores. + */ + async zPopMax(key: string, count = 1): Promise { + const res = await this.call<{ members: Array<{ score: number; member: string }> }>( + 'zPopMax', { key, count }, + ); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Returns members that appear in the first key but not in any subsequent key. + */ + async zDiff(keys: string[], withScores = false): Promise { + const res = await this.call<{ members: Array<{ score: number; member: string }> }>( + 'zDiff', { keys, withScores }, + ); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Returns members that appear in all of the given sorted sets. + */ + async zInter(keys: string[], withScores = false): Promise { + const res = await this.call<{ members: Array<{ score: number; member: string }> }>( + 'zInter', { keys, withScores }, + ); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Returns the union of all given sorted sets. + */ + async zUnion(keys: string[], withScores = false): Promise { + const res = await this.call<{ members: Array<{ score: number; member: string }> }>( + 'zUnion', { keys, withScores }, + ); + return res.members.map(m => ({ member: m.member, score: m.score })); + } + + /** + * Iterates over members of a sorted set. Start with cursor `0`; done when + * the returned cursor is `0`. + */ + async zScan(key: string, cursor: number, opts: ScanOptions = {}): Promise { + const req: Record = { key, cursor, count: opts.count ?? 0 }; + if (opts.pattern != null) req.pattern = opts.pattern; + const res = await this.call<{ + cursor: number; + members: Array<{ score: number; member: string }>; + }>('zScan', req); + return { + cursor: res.cursor, + members: res.members.map(m => ({ member: m.member, score: m.score })), + }; + } + + // --------------------------------------------------------------------------- + // vectors (requires server built with the `vector` feature) + // --------------------------------------------------------------------------- + + /** + * Adds a vector to a vector set. Returns `true` if the element was newly added. + * + * @example + * ```ts + * await client.vAdd('embeddings', 'doc:1', [0.1, 0.2, 0.3]); + * ``` + */ + async vAdd( + key: string, + element: string, + vector: number[], + opts: VAddOptions = {}, + ): Promise { + const req: Record = { key, element, vector }; + if (opts.metric) req.metric = opts.metric; + if (opts.quantization) req.quantization = opts.quantization; + if (opts.connectivity != null) req.connectivity = opts.connectivity; + if (opts.efConstruction != null) req.efConstruction = opts.efConstruction; + const res = await this.call<{ value: boolean }>('vAdd', req); + return res.value; + } + + /** + * Adds multiple vectors to a vector set in one call. + * Returns the number of elements added. + */ + async vAddBatch( + key: string, + entries: VAddBatchEntry[], + opts: VAddBatchOptions = {}, + ): Promise { + const req: Record = { key, entries }; + if (opts.metric) req.metric = opts.metric; + if (opts.quantization) req.quantization = opts.quantization; + if (opts.connectivity != null) req.connectivity = opts.connectivity; + if (opts.efConstruction != null) req.efConstruction = opts.efConstruction; + const res = await this.call<{ value: number }>('vAddBatch', req); + return res.value; + } + + /** + * Searches for the `count` nearest neighbors to `query` in the vector set. + * Results are returned sorted by similarity (closest first). + */ + async vSim( + key: string, + query: number[], + count: number, + opts: VSimOptions = {}, + ): Promise { + const req: Record = { key, query, count }; + if (opts.efSearch != null) req.efSearch = opts.efSearch; + const res = await this.call<{ results: Array<{ element: string; distance: number }> }>( + 'vSim', req, + ); + return res.results.map(r => ({ element: r.element, distance: r.distance })); + } + + /** + * Removes an element from a vector set. Returns `true` if the element was removed. + */ + async vRem(key: string, element: string): Promise { + const res = await this.call<{ value: boolean }>('vRem', { key, element }); + return res.value; + } + + /** + * Returns the stored vector for `element`. + * `exists: false` if the element is not in the set. + */ + async vGet(key: string, element: string): Promise { + const res = await this.call<{ exists?: boolean; vector: number[] }>('vGet', { key, element }); + return { exists: res.exists ?? false, vector: res.vector ?? [] }; + } + + /** + * Returns the number of elements in a vector set. + */ + async vCard(key: string): Promise { + const res = await this.call<{ value: number }>('vCard', { key }); + return res.value; + } + + /** + * Returns the dimensionality of vectors in the set. + */ + async vDim(key: string): Promise { + const res = await this.call<{ value: number }>('vDim', { key }); + return res.value; + } + + /** + * Returns metadata about a vector set (metric, quantization, dimensions, etc.). + */ + async vInfo(key: string): Promise { + const res = await this.call<{ + exists: boolean; + info: Array<{ field: string; value: Buffer }>; + }>('vInfo', { key }); + const info: Record = {}; + for (const fv of res.info) { + info[fv.field] = fv.value.toString(); + } + return { exists: res.exists, info }; + } + + // --------------------------------------------------------------------------- + // pub/sub + // --------------------------------------------------------------------------- + + /** + * Publishes a message to a channel. Returns the number of subscribers that + * received it. + */ + async publish(channel: string, message: Buffer | string): Promise { + const res = await this.call<{ value: number }>('publish', { + channel, + message: Buffer.isBuffer(message) ? message : Buffer.from(message), + }); + return res.value; + } + + /** + * Subscribes to one or more channels and/or patterns. Yields events until + * the caller breaks out of the loop or calls `return()` on the iterator. + * + * Pass channel names in `channels` for exact matches and glob patterns in + * `patterns` for wildcard matching. + * + * @example + * ```ts + * for await (const evt of client.subscribe(['news', 'alerts'])) { + * console.log(evt.channel, evt.data?.toString()); + * if (evt.channel === 'alerts') break; // unsubscribes cleanly + * } + * ``` + */ + subscribe(channels: string[], patterns: string[] = []): AsyncIterable { + const raw = this.stream<{ + kind: string; + channel: string; + data: Buffer; + pattern?: string; + }>('subscribe', { channels, patterns }); + + return { + [Symbol.asyncIterator]() { + const iter = raw[Symbol.asyncIterator](); + return { + async next() { + const { value, done } = await iter.next(); + if (done) return { value: undefined as unknown as SubscribeEvent, done: true }; + return { + value: { + kind: value.kind, + channel: value.channel, + data: value.data ?? null, + pattern: value.pattern, + } as SubscribeEvent, + done: false, + }; + }, + return() { + return ( + iter.return?.() ?? + Promise.resolve({ value: undefined as unknown as SubscribeEvent, done: true }) + ); + }, + }; + }, + }; + } + + /** + * Returns the names of all active channels, optionally filtered by `pattern`. + */ + async pubSubChannels(pattern?: string): Promise { + const req: Record = {}; + if (pattern != null) req.pattern = pattern; + const res = await this.call<{ keys: string[] }>('pubSubChannels', req); + return res.keys; + } + + /** + * Returns a map of channel name → subscriber count for the given channels. + */ + async pubSubNumSub(...channels: string[]): Promise> { + const res = await this.call<{ counts: Array<{ channel: string; count: number }> }>( + 'pubSubNumSub', { channels }, + ); + const result = new Map(); + for (const c of res.counts) { + result.set(c.channel, c.count); + } + return result; + } + + /** + * Returns the number of active pattern subscriptions across all clients. + */ + async pubSubNumPat(): Promise { + const res = await this.call<{ value: number }>('pubSubNumPat', {}); + return res.value; + } + + // --------------------------------------------------------------------------- + // server + // --------------------------------------------------------------------------- + + /** + * Sends a PING. Returns `"PONG"` by default, or echoes `message` if provided. + */ + async ping(message?: string): Promise { + const req: Record = {}; + if (message != null) req.message = message; + const res = await this.call<{ message: string }>('ping', req); + return res.message; + } + + /** + * Sends `message` to the server and returns it back unchanged. + */ + async echo(message: string): Promise { + const res = await this.call<{ message: string }>('echo', { message }); + return res.message; + } + + /** + * Removes all keys from the active database. + * Pass `true` for a non-blocking background flush. + */ + async flushDb(async_ = false): Promise { + await this.call('flushDb', { async: async_ }); + } + + /** + * Returns the total number of keys across all shards. + */ + async dbSize(): Promise { + const res = await this.call<{ value: number }>('dbSize', {}); + return res.value; + } + + /** + * Returns server statistics as a multi-line string. + * Pass an optional `section` to limit the output (e.g. `"memory"`, `"server"`). + */ + async info(section?: string): Promise { + const req: Record = {}; + if (section != null) req.section = section; + const res = await this.call<{ info: string }>('info', req); + return res.info; + } + + /** + * Triggers an asynchronous snapshot of the dataset to disk. Returns the + * server's status message. + */ + async bgSave(): Promise { + const res = await this.call<{ status: string }>('bgSave', {}); + return res.status; + } + + /** + * Triggers an asynchronous rewrite of the append-only file. Returns the + * server's status message. + */ + async bgRewriteAof(): Promise { + const res = await this.call<{ status: string }>('bgRewriteAof', {}); + return res.status; + } + + /** + * Returns the current server time. + */ + async time(): Promise { + const res = await this.call<{ seconds: number; microseconds: number }>('time', {}); + return { seconds: res.seconds, microseconds: res.microseconds }; + } + + /** + * Returns the Unix timestamp of the last successful BGSAVE. + */ + async lastSave(): Promise { + const res = await this.call<{ value: number }>('lastSave', {}); + return res.value; + } + + // --------------------------------------------------------------------------- + // slowlog + // --------------------------------------------------------------------------- + + /** + * Returns entries from the slow log. Pass `count` to limit the number returned. + */ + async slowLogGet(count?: number): Promise { + const req: Record = {}; + if (count != null) req.count = count; + const res = await this.call<{ + entries: Array<{ + id: number; + timestampUnix: number; + durationMicros: number; + command: string; + }>; + }>('slowLogGet', req); + return res.entries.map(e => ({ + id: e.id, + timestamp: e.timestampUnix, + durationMicros: e.durationMicros, + command: e.command, + })); + } + + /** + * Returns the number of entries currently in the slow log. + */ + async slowLogLen(): Promise { + const res = await this.call<{ value: number }>('slowLogLen', {}); + return res.value; + } + + /** + * Clears all entries from the slow log. + */ + async slowLogReset(): Promise { + await this.call('slowLogReset', {}); + } +} diff --git a/clients/ember-ts/src/index.ts b/clients/ember-ts/src/index.ts new file mode 100644 index 00000000..016e19f5 --- /dev/null +++ b/clients/ember-ts/src/index.ts @@ -0,0 +1,26 @@ +export { EmberClient } from './client'; +export type { + ClientOptions, + SetOptions, + GetExOptions, + ScanOptions, + ZAddOptions, + ZRangeByScoreOptions, + VectorMetric, + VectorQuantization, + VAddOptions, + VAddBatchOptions, + VAddBatchEntry, + VSimOptions, + ScoreMember, + VSimResult, + SlowLogEntry, + SubscribeEvent, + ScanPage, + HScanPage, + ZScanPage, + SScanPage, + TimeResult, + VGetResult, + VInfoResult, +} from './types'; diff --git a/clients/ember-ts/src/types.ts b/clients/ember-ts/src/types.ts new file mode 100644 index 00000000..db530430 --- /dev/null +++ b/clients/ember-ts/src/types.ts @@ -0,0 +1,246 @@ +/** + * Options passed to the EmberClient constructor. + */ +export interface ClientOptions { + /** authentication password, sent as the `authorization` metadata header. */ + password?: string; +} + +/** + * Options for the SET command. + */ +export interface SetOptions { + /** expire the key after this many seconds. */ + ex?: number; + /** expire the key after this many milliseconds. */ + px?: number; + /** only set the key if it does not already exist. */ + nx?: boolean; + /** only set the key if it already exists. */ + xx?: boolean; +} + +/** + * Options for the GETEX command. + */ +export interface GetExOptions { + /** set expiry in seconds. */ + ex?: number; + /** set expiry in milliseconds (takes precedence over `ex`). */ + px?: number; + /** remove the existing expiry, making the key persistent. */ + persist?: boolean; +} + +/** + * Options for SCAN, HSCAN, SSCAN, and ZSCAN commands. + */ +export interface ScanOptions { + /** glob-style pattern to filter returned keys/members. */ + pattern?: string; + /** hint for how many items to return per call. the server may return more or fewer. */ + count?: number; +} + +/** + * Options for the ZADD command. + */ +export interface ZAddOptions { + /** only add new members; never update scores of existing members. */ + nx?: boolean; + /** only update existing members; never add new members. */ + xx?: boolean; + /** only update if the new score is greater than the current score. */ + gt?: boolean; + /** only update if the new score is less than the current score. */ + lt?: boolean; + /** return the number of elements changed (added + updated) instead of only added. */ + ch?: boolean; +} + +/** + * Options for ZRANGEBYSCORE and ZREVRANGEBYSCORE commands. + */ +export interface ZRangeByScoreOptions { + /** starting offset for pagination. */ + offset?: number; + /** maximum number of results to return. */ + count?: number; + /** include scores in the response. */ + withScores?: boolean; +} + +/** + * Distance metric used for vector similarity search. + */ +export type VectorMetric = + | 'VECTOR_METRIC_COSINE' + | 'VECTOR_METRIC_EUCLIDEAN' + | 'VECTOR_METRIC_INNER_PRODUCT'; + +/** + * Quantization strategy for compressing stored vectors. + */ +export type VectorQuantization = + | 'VECTOR_QUANTIZATION_NONE' + | 'VECTOR_QUANTIZATION_F16' + | 'VECTOR_QUANTIZATION_I8'; + +/** + * Options for the VADD command. + */ +export interface VAddOptions { + /** distance metric for the vector set. defaults to cosine. */ + metric?: VectorMetric; + /** quantization strategy to compress stored vectors. */ + quantization?: VectorQuantization; + /** HNSW M parameter — number of connections per node. */ + connectivity?: number; + /** HNSW ef_construction parameter — search width during index build. */ + efConstruction?: number; +} + +/** + * Options for the VADDBATCH command. + */ +export interface VAddBatchOptions { + /** distance metric for the vector set. defaults to cosine. */ + metric?: VectorMetric; + /** quantization strategy to compress stored vectors. */ + quantization?: VectorQuantization; + /** HNSW M parameter. */ + connectivity?: number; + /** HNSW ef_construction parameter. */ + efConstruction?: number; +} + +/** + * A single entry for VADDBATCH. + */ +export interface VAddBatchEntry { + /** the element name. */ + element: string; + /** the vector as an array of floats. */ + vector: number[]; +} + +/** + * Options for the VSIM command. + */ +export interface VSimOptions { + /** ef_search parameter — controls recall vs. latency trade-off. */ + efSearch?: number; +} + +// --------------------------------------------------------------------------- +// result types +// --------------------------------------------------------------------------- + +/** + * A sorted-set member paired with its score. + */ +export interface ScoreMember { + member: string; + score: number; +} + +/** + * A single result from VSIM (vector similarity search). + */ +export interface VSimResult { + element: string; + distance: number; +} + +/** + * An entry from the slow log. + */ +export interface SlowLogEntry { + id: number; + /** unix timestamp when the command was executed. */ + timestamp: number; + /** how long the command took, in microseconds. */ + durationMicros: number; + /** the command string as logged by the server. */ + command: string; +} + +/** + * An event received on a pub/sub subscription. + */ +export interface SubscribeEvent { + /** "message" for exact channel matches, "pmessage" for pattern matches. */ + kind: string; + /** the channel the message was published to. */ + channel: string; + /** the message payload. */ + data: Buffer | null; + /** the pattern that matched (only present for pmessage events). */ + pattern?: string; +} + +/** + * A page of keys returned by SCAN. + */ +export interface ScanPage { + /** the cursor for the next call. 0 means the scan is complete. */ + cursor: number; + keys: string[]; +} + +/** + * A page of field-value pairs returned by HSCAN. + */ +export interface HScanPage { + /** the cursor for the next call. 0 means the scan is complete. */ + cursor: number; + fields: Record; +} + +/** + * A page of score-member pairs returned by ZSCAN. + */ +export interface ZScanPage { + /** the cursor for the next call. 0 means the scan is complete. */ + cursor: number; + members: ScoreMember[]; +} + +/** + * A page of set members returned by SSCAN. + */ +export interface SScanPage { + /** the cursor for the next call. 0 means the scan is complete. */ + cursor: number; + members: string[]; +} + +/** + * The current server time, as returned by TIME. + */ +export interface TimeResult { + /** unix timestamp in whole seconds. */ + seconds: number; + /** microseconds offset within the current second. */ + microseconds: number; +} + +/** + * The result of VGET — the stored vector for a named element. + */ +export interface VGetResult { + /** false if the element does not exist in the vector set. */ + exists: boolean; + /** the stored vector as an array of floats. empty if the element does not exist. */ + vector: number[]; +} + +/** + * Metadata about a vector set, returned by VINFO. + */ +export interface VInfoResult { + /** false if the vector set key does not exist. */ + exists: boolean; + /** metadata fields (metric, dimensions, capacity, etc.) as string values. */ + info: Record; +}