|
1 |
| -# Open-source stack |
| 1 | +# Redis fluent keys: Finally, Typesafe Redis Keys You'll Actually Enjoy! ✨ |
2 | 2 |
|
3 |
| -This is a placeholder npm package for the open-source-stack by Forge 42. |
4 |
| -It is a full starter stack to develop CJS/ESM compatible npm packages with TypeScript, Vitest, Biome and GitHub Actions. |
5 |
| -Find the template here: |
6 |
| -https://github.com/forge-42/open-source-stack |
| 3 | + |
| 4 | + |
| 5 | + |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | + |
| 10 | +**(Because stringly-typed keys are just asking for trouble, right?)** |
| 11 | + |
| 12 | +Ugh, Redis keys. We all use 'em, but managing them can be a pain: |
| 13 | + |
| 14 | +* Typo in `users:profile:usr_123` vs `user:profile:user_123`? Good luck finding that bug! 😭 |
| 15 | +* Inconsistent naming conventions across your app? Chaos! |
| 16 | +* Need to refactor a key structure? Prepare for a risky find-and-replace adventure. 😬 |
| 17 | +* Want to include a user ID or timestamp? Hope you format that template string correctly *every single time*. |
| 18 | + |
| 19 | +**Enough is enough!** This little library helps you define your Redis key structures in one place, using plain TypeScript, and gives you back **fully typesafe functions** to generate those keys. |
| 20 | + |
| 21 | +**What you get:** |
| 22 | + |
| 23 | +* ✅ **Autocomplete Heaven:** Define your keys, get autocomplete for paths and placeholders. |
| 24 | +* 🔒 **Bulletproof Type Safety:** Pass the wrong type (like a `number` for a `userId` string)? TypeScript yells at you *before* you deploy. Forget a placeholder? Compile error! |
| 25 | +* 🌳 **Organized Structure:** Define keys in a nested way that makes sense for your domain. |
| 26 | +* ⚙️ **Refactor with Confidence:** Change a key definition in one place, TypeScript guides you to fix all the usages. |
| 27 | +* 😎 **Awesome DX:** Simple API, minimal boilerplate, focuses on getting the job done cleanly. |
| 28 | + |
| 29 | +Works perfectly with [`ioredis`](https://github.com/luin/ioredis) or any other Redis client that just needs the final key string. |
| 30 | + |
| 31 | +## Installation |
| 32 | + |
| 33 | +```bash |
| 34 | +npm install @flixy-dev/redis-fluent-keys --save |
| 35 | +# or |
| 36 | +yarn add @flixy-dev/redis-fluent-keys |
| 37 | +# or |
| 38 | +pnpm add @flixy-dev/redis-fluent-keys |
| 39 | +bun add @flixy-dev/redis-fluent-keys |
| 40 | +``` |
| 41 | + |
| 42 | +# Quick Start |
| 43 | +Let's see how easy this is: |
| 44 | + |
| 45 | +```ts |
| 46 | +// src/redis-keys.ts |
| 47 | +import { createKeyBuilder, p } from 'redis-fluent-keys'; |
| 48 | + |
| 49 | +// 1. Create a key builder instance (separator defaults to ':') |
| 50 | +const keyBuilder = createKeyBuilder(); |
| 51 | +// const keyBuilder = createKeyBuilder({ separator: '__' }); // Custom separator! |
| 52 | + |
| 53 | +// 2. Define your key schema |
| 54 | +export const redisKeys = keyBuilder({ |
| 55 | + // A simple static key |
| 56 | + config: { |
| 57 | + cacheVersion: ['config', 'cacheVersion'], // -> config:cacheVersion |
| 58 | + }, |
| 59 | + |
| 60 | + // Keys related to users |
| 61 | + users: { |
| 62 | + // A key with a dynamic part (placeholder) |
| 63 | + profile: ['users', p('userId'), 'profile'], // -> users:{userId}:profile |
| 64 | + |
| 65 | + // Another static one nested |
| 66 | + allActiveSet: ['users', 'all', 'active'], // -> users:all:active |
| 67 | + }, |
| 68 | +}); |
| 69 | + |
| 70 | +// ------------------------------------- |
| 71 | + |
| 72 | +// src/some-service.ts |
| 73 | +import Redis from 'ioredis'; |
| 74 | +import { redisKeys } from './redis-keys'; // Import your defined keys |
| 75 | + |
| 76 | +const redis = new Redis(); // Your ioredis instance |
| 77 | + |
| 78 | +async function getUserProfile(id: string) { |
| 79 | + // 3. Use the typesafe function! ✨ |
| 80 | + const key = redisKeys.users.profile({ userId: id }); |
| 81 | + // key will be "users:id:profile" |
| 82 | + |
| 83 | + console.log(`Fetching from Redis key: ${key}`); |
| 84 | + const profileData = await redis.hgetall(key); |
| 85 | + |
| 86 | + // Trying to misuse it? TypeScript catches it! |
| 87 | + // const wrongKey = redisKeys.users.profile({}); // TS Error: userId missing! |
| 88 | + // const wrongKey2 = redisKeys.users.profile({ userId: 123 }); // TS Error: userId needs string! |
| 89 | + |
| 90 | + return profileData; |
| 91 | +} |
| 92 | + |
| 93 | +async function getCacheVersion() { |
| 94 | + // No arguments needed for static keys! |
| 95 | + const key = redisKeys.config.cacheVersion(); |
| 96 | + // key will be "config:cacheVersion" |
| 97 | + return redis.get(key); |
| 98 | +} |
| 99 | + |
| 100 | +getUserProfile('usr_987'); |
| 101 | +getCacheVersion(); |
| 102 | +``` |
| 103 | + |
| 104 | +See? Define once, use everywhere safely! |
| 105 | + |
| 106 | +# Features Deep Dive Placeholders (`p`, `p.number`, `p.boolean`) |
| 107 | + |
| 108 | +Dynamic parts are the heart of most Redis keys. We use the `p()` helper: |
| 109 | +`p('placeholderName')`: Creates a placeholder expecting a string. Infers the name `"placeholderName"` literally for the argument object. (This is the default and most common). |
| 110 | +`p.number('placeholderName')`: Creates a placeholder expecting a number. |
| 111 | +`p.boolean('placeholderName')`: Creates a placeholder expecting a boolean (will be converted to "true" or "false" in the key). |
| 112 | + |
| 113 | +```ts |
| 114 | +const keys = createKeyBuilder()({ |
| 115 | + user: p('userId'), // -> {userId} (string) |
| 116 | + productStock: ['products', p.number('productId'), 'stock'], // -> products:{productId}:stock |
| 117 | + featureFlag: ['features', p('flagName'), p.boolean('isEnabled')], // -> features:{flagName}:{isEnabled} |
| 118 | +}); |
| 119 | + |
| 120 | +const userKey = keys.user({ userId: 'user-123' }); // "user-123" |
| 121 | +const stockKey = keys.productStock({ productId: 55 }); // "products:55:stock" |
| 122 | +const flagKey = keys.featureFlag({ flagName: 'newUI', isEnabled: true }); // "features:newUI:true" |
| 123 | + |
| 124 | +// Compile-time errors: |
| 125 | +// const badStock = keys.productStock({ productId: 'abc' }); // TS Error! Expects number |
| 126 | +// const badFlag = keys.featureFlag({ flagName: 'oldUI' }); // TS Error! isEnabled missing |
| 127 | +``` |
| 128 | + |
| 129 | +# Nesting (The Easy Way) |
| 130 | + |
| 131 | +Organize your keys logically using nested objects. The object keys automatically become part of the prefix. |
| 132 | + |
| 133 | +```ts |
| 134 | +const keys = createKeyBuilder()({ |
| 135 | + users: { // "users" becomes a prefix |
| 136 | + all: ['all'], // -> users:all |
| 137 | + settings: { // "settings" becomes a prefix |
| 138 | + byUser: [p('userId')], // -> users:settings:{userId} |
| 139 | + notifications: { // "notifications" becomes a prefix |
| 140 | + email: ['email', p('userId')], // -> users:settings:notifications:email:{userId} |
| 141 | + } |
| 142 | + } |
| 143 | + }, |
| 144 | + cache: { // "cache" becomes a prefix |
| 145 | + images: ['images'], // -> cache:images |
| 146 | + } |
| 147 | +}); |
| 148 | + |
| 149 | +const settingsKey = keys.users.settings.byUser({ userId: 'u-456' }); |
| 150 | +// "users:settings:u-456" |
| 151 | +const emailKey = keys.users.settings.notifications.email({ userId: 'u-789' }); |
| 152 | +// "users:settings:notifications:email:u-789" |
| 153 | +const imgKey = keys.cache.images(); |
| 154 | +// "cache:images" |
| 155 | +``` |
| 156 | + |
| 157 | +# Parameterized Nesting (`parameterize`) |
| 158 | + |
| 159 | +Sometimes, the nesting level itself needs a dynamic value (like accessing keys for a *specific* user). That's where parameterize comes in! |
| 160 | + |
| 161 | +Think about it: how would you define keys like `user:{userId}:profile` AND `user:{userId}:settings` using the nesting above? You can't easily make `user:{userId}` the prefix directly. |
| 162 | + |
| 163 | +`parameterize` solves this: |
| 164 | + |
| 165 | +```ts |
| 166 | +import { createKeyBuilder, p, parameterize } from 'redis-fluent-keys'; |
| 167 | + |
| 168 | +const keys = createKeyBuilder()({ |
| 169 | + // Parameterize the 'user' level by userId |
| 170 | + user: parameterize(p('userId'), { // Now requires { userId: string } to access inner keys |
| 171 | + // Inside here, "user:{userId}" is the implicit prefix! |
| 172 | + |
| 173 | + profile: ['profile'], // Definition is just the final part |
| 174 | + // -> user:{userId}:profile |
| 175 | + |
| 176 | + settings: ['settings'], // Definition is just the final part |
| 177 | + // -> user:{userId}:settings |
| 178 | + |
| 179 | + orders: { // You can still nest further statically |
| 180 | + all: ['all'], // -> user:{userId}:orders:all (Fixed schema example) |
| 181 | + byId: [p.number('orderId')], // -> user:{userId}:orders:{orderId} (Fixed schema example) |
| 182 | + } |
| 183 | + }), |
| 184 | + |
| 185 | + // You can parameterize with multiple placeholders too! |
| 186 | + tenantResource: parameterize( |
| 187 | + [p('tenantId'), p.number('resourceId')], // Requires { tenantId: string, resourceId: number } |
| 188 | + { |
| 189 | + config: ['config'], // -> tenantResource:{tenantId}:{resourceId}:config (Fixed schema example) |
| 190 | + status: ['status'], // -> tenantResource:{tenantId}:{resourceId}:status (Fixed schema example) |
| 191 | + } |
| 192 | + ), |
| 193 | + |
| 194 | + // A regular key for comparison |
| 195 | + globalConfig: ['global', 'config'], |
| 196 | +}); |
| 197 | + |
| 198 | +// --- Usage --- |
| 199 | + |
| 200 | +// 1. Call the parameterized function first to get the access object for that user |
| 201 | +const userAccess = keys.user({ userId: 'u-abc' }); |
| 202 | + |
| 203 | +// 2. Now use the returned object like normal |
| 204 | +const profileKey = userAccess.profile(); // -> "user:u-abc:profile" |
| 205 | +const settingsKey = userAccess.settings(); // -> "user:u-abc:settings" |
| 206 | +const orderKey = userAccess.orders.byId({ orderId: 99 }); // -> "user:u-abc:orders:99" |
| 207 | + |
| 208 | +// Multi-parameter example |
| 209 | +const tenantAccess = keys.tenantResource({ tenantId: 'acme', resourceId: 123 }); |
| 210 | +const configKey = tenantAccess.config(); // -> "tenantResource:acme:123:config" |
| 211 | + |
| 212 | +// Trying to access before parameterizing? TS Error! |
| 213 | +// const badAccess = keys.user.profile(); // TS Error! 'profile' doesn't exist directly on keys.user |
| 214 | +``` |
| 215 | + |
| 216 | +`parameterize` returns a function. You call that function with the required path parameters, and *it* returns the object containing the next level of key builders, now correctly prefixed! Pretty neat, huh? 🤔 |
| 217 | + |
| 218 | +# Custom Separator |
| 219 | + |
| 220 | +Don't like `:`? No problem! |
| 221 | + |
| 222 | +```ts |
| 223 | +const keyBuilder = createKeyBuilder({ separator: '::' }); |
| 224 | + |
| 225 | +const keys = keyBuilder({ |
| 226 | + user: ['user', p('id')] |
| 227 | +}); |
| 228 | + |
| 229 | +const key = keys.user({ id: '123' }); // -> user::123 |
| 230 | +``` |
| 231 | + |
| 232 | +# API Reference |
| 233 | + |
| 234 | +- `createKeyBuilder(options?: { separator?: string }): (schema) => KeyBuilderResult` |
| 235 | + - Creates the builder factory. Call the returned function with your schema object. |
| 236 | +- `p<const Name extends string>(name: Name): Placeholder<string, Name>` |
| 237 | + - Creates a string placeholder, inferring the literal name. |
| 238 | +- `p.number<const Name extends string>(name: Name): Placeholder<number, Name>` |
| 239 | + - Creates a number placeholder. |
| 240 | +- `p.boolean<const Name extends string>(name: Name): Placeholder<boolean, Name>` |
| 241 | + - Creates a boolean placeholder. |
| 242 | +- `parameterize<const P, const S>(placeholders: P, nestedSchema: S): Parameterized<P, S>` |
| 243 | + - Defines a schema level that requires runtime parameters (placeholders) to access the nestedSchema. placeholders can be a single p() result or a readonly array/tuple of them. |
| 244 | + |
| 245 | +# Contributing |
| 246 | + |
| 247 | +Found a bug? Have an idea? Feel free to open an issue or submit a PR! |
| 248 | + |
| 249 | +# License |
| 250 | +MIT License. Use it, love it, break it, fix it. ❤️ |
0 commit comments