Skip to content

Commit e21c60c

Browse files
committed
feat: add type-safe keyspaces
This feature allows you to constrain the set of valid keys in a “keyspace” (a namespace of keys). This is especially useful if the keys should have a specific format or if only a finite set of keys are valid. enum Robot { Butler, Gardener, } enum Power { On = "on", Off = "off", } const robot = new RedisEntity( new RedisKeyspace<Robot>("robot-configuration"), Type.Enum(Power), ); // You must qualify which robot you're setting the power for. // TypeScript will enforce this. SET(robot.qualify(Robot.Butler), Power.On);
1 parent 8d6d289 commit e21c60c

File tree

6 files changed

+142
-92
lines changed

6 files changed

+142
-92
lines changed

src/channel.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { TSchema, TString, Type } from "@sinclair/typebox";
22
import { isString } from "radashi";
3-
import { RedisKey } from "./key";
3+
import { RedisKey, RedisKeyspace } from "./key";
44
import { MessageEvent } from "./subscriber";
55
import { RedisTransform } from "./transform";
66

@@ -9,23 +9,31 @@ import { RedisTransform } from "./transform";
99
*/
1010
export class RedisChannel<
1111
T extends TSchema = TSchema,
12+
K extends string | RedisKeyspace = string,
1213
> extends RedisTransform<T> {
1314
declare $$typeof: "RedisChannel";
1415
constructor(
15-
readonly name: string,
16+
readonly name: K,
1617
schema: T,
1718
) {
1819
super(schema);
1920
}
2021

2122
/**
22-
* Derive a subchannel by prefixing the current channel with the given
23-
* keys. When multiple keys are passed in, they will be joined with a
24-
* colon.
23+
* Creates a new channel within a namespace.
2524
*/
26-
join(...keys: (string | number)[]) {
27-
if (keys.length === 0) return this;
28-
return new RedisChannel(`${this.name}:${keys.join(":")}`, this.schema);
25+
qualify(
26+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
27+
): RedisChannel<T, string>;
28+
qualify<T extends TSchema>(
29+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
30+
schema: T,
31+
): RedisChannel<T, string>;
32+
qualify(
33+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
34+
schema: TSchema = this.schema,
35+
) {
36+
return new RedisChannel<any>(`${this.name}:${name}`, schema);
2937
}
3038

3139
/**
@@ -62,7 +70,7 @@ export class RedisChannelPattern<
6270
* [1]: https://redis.io/docs/latest/develop/use/keyspace-notifications/
6371
*/
6472
export class RedisKeyspacePattern extends RedisChannelPattern<TString> {
65-
constructor(pattern: string | RedisKey, database?: number) {
73+
constructor(pattern: string | RedisKey | RedisKeyspace, database?: number) {
6674
super(
6775
`__keyspace@${database ?? "*"}__:${isString(pattern) ? pattern : pattern.name}`,
6876
Type.String(),

src/commands/read.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import { StaticEncode, TSchema } from "@sinclair/typebox";
22
import { RedisCommand } from "../command";
3-
import { RedisKey } from "../key";
3+
import { RedisEntity } from "../key";
44
import { RedisModifier } from "../modifier";
55

66
/** Return the previous value stored at this key */
77
export type GET = RedisModifier<"GET">;
88

99
/** Get the value of a key */
1010
export function GET<T extends TSchema>(
11-
key: RedisKey<T>,
11+
key: RedisEntity<T>,
1212
): RedisCommand<StaticEncode<T> | undefined>;
1313

1414
/** Return the previous value stored at this key */
1515
export function GET(): GET;
1616

1717
export function GET<T extends TSchema>(
18-
key?: RedisKey<T>,
18+
key?: RedisEntity<T>,
1919
): GET | RedisCommand<StaticEncode<T> | undefined> {
2020
if (!key) {
2121
return new RedisModifier("GET");

src/commands/write.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { StaticEncode, TNumber, TSchema } from "@sinclair/typebox";
22
import { RedisCommand } from "../command";
3-
import { RedisKey } from "../key";
3+
import { RedisEntity, RedisKey } from "../key";
44
import { encodeModifiers, Modifiers, Require } from "../modifier";
55
import {
66
EX,
@@ -31,7 +31,7 @@ export function FLUSHALL(mode?: "sync" | "async") {
3131
* expired, the key will automatically be deleted.
3232
*/
3333
export function EXPIRE(
34-
key: RedisKey<any>,
34+
key: RedisKey,
3535
timeout: number,
3636
...modifiers: Modifiers<[XX | NX | GT | LT]>
3737
) {
@@ -46,7 +46,7 @@ export function EXPIRE(
4646
* similar to {@link GET}, but is a write command with additional options.
4747
*/
4848
export function GETEX<T extends TSchema>(
49-
key: RedisKey<T>,
49+
key: RedisEntity<T>,
5050
...modifiers: Modifiers<[EX | PX | EXAT | PXAT | PERSIST]>
5151
) {
5252
return new RedisCommand<StaticEncode<T> | undefined>(
@@ -59,22 +59,22 @@ export function GETEX<T extends TSchema>(
5959
* Set the value of a key and optionally set its expiration.
6060
*/
6161
export function SET<T extends TSchema>(
62-
key: RedisKey<T>,
62+
key: RedisEntity<T>,
6363
value: StaticEncode<T>,
6464
...modifiers: Modifiers<
6565
[NX | XX, Require<GET>, EX | PX | EXAT | PXAT | KEEPTTL]
6666
>
6767
): RedisCommand<StaticEncode<T> | undefined>;
6868

6969
export function SET<T extends TSchema>(
70-
key: RedisKey<T>,
70+
key: RedisEntity<T>,
7171
value: StaticEncode<T>,
7272
...modifiers: Modifiers<[NX | XX, EX | PX | EXAT | PXAT | KEEPTTL]>
7373
): RedisCommand<boolean>;
7474

7575
export function SET(
76-
key: RedisKey<any>,
77-
value: StaticEncode<any>,
76+
key: RedisEntity,
77+
value: unknown,
7878
...modifiers: Modifiers
7979
): RedisCommand<any> {
8080
return new RedisCommand(
@@ -95,27 +95,27 @@ export function DEL(...keys: [RedisKey, ...RedisKey[]]) {
9595
/**
9696
* Decrement the value of a key by 1.
9797
*/
98-
export function DECR(key: RedisKey<TNumber>) {
98+
export function DECR(key: RedisEntity<TNumber>) {
9999
return new RedisCommand<number>(["DECR", key.name]);
100100
}
101101

102102
/**
103103
* Decrement the value of a key by a specific amount.
104104
*/
105-
export function DECRBY(key: RedisKey<TNumber>, amount: number) {
105+
export function DECRBY(key: RedisEntity<TNumber>, amount: number) {
106106
return new RedisCommand<number>(["DECRBY", key.name, amount]);
107107
}
108108

109109
/**
110110
* Increment the value of a key by 1.
111111
*/
112-
export function INCR(key: RedisKey<TNumber>) {
112+
export function INCR(key: RedisEntity<TNumber>) {
113113
return new RedisCommand<number>(["INCR", key.name]);
114114
}
115115

116116
/**
117117
* Increment the value of a key by a specific amount.
118118
*/
119-
export function INCRBY(key: RedisKey<TNumber>, amount: number) {
119+
export function INCRBY(key: RedisEntity<TNumber>, amount: number) {
120120
return new RedisCommand<number>(["INCRBY", key.name, amount]);
121121
}

src/key.ts

Lines changed: 92 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,139 @@
1-
import { StaticEncode, TSchema } from "@sinclair/typebox";
1+
import * as Type from "@sinclair/typebox/type";
2+
import { StaticEncode, TObject, TSchema } from "@sinclair/typebox/type";
23
import { Decode, Encode } from "@sinclair/typebox/value";
34
import { RedisValue } from "./command";
45
import { RedisTransform } from "./transform";
56

6-
export type TRedisHash<TValue extends TSchema = TSchema> = Record<
7-
string,
8-
TValue
9-
>;
10-
11-
export type StaticHash<T extends TRedisHash> = {
12-
[K in RedisField<T>]: StaticEncode<T[K]>;
13-
};
14-
157
/**
16-
* A field name of a Redis key that points to a hash map.
8+
* Represents a namespace of keys in a Redis database.
179
*/
18-
export type RedisField<T extends TSchema | TRedisHash> = T extends TSchema
19-
? never
20-
: Extract<keyof T, string>;
10+
export class RedisKeyspace<K extends string | number = string | number> {
11+
declare $$typeof: "RedisKeyspace" & { K: K };
12+
constructor(readonly name: string) {}
13+
14+
/**
15+
* Creates a string pattern that matches all keys in the keyspace.
16+
*
17+
* You may pass this to the `RedisKeyspacePattern` constructor.
18+
*/
19+
any() {
20+
return `${this.name}:*` as const;
21+
}
22+
}
2123

2224
/**
23-
* A Redis key that points to a primitive value or a hash map.
25+
* Represents a key in a Redis database with an unknown data structure. For
26+
* example, it could be a primitive, hash, stream, etc.
2427
*/
25-
export class RedisKey<
26-
T extends TSchema | TRedisHash = TSchema | TRedisHash,
28+
export abstract class RedisKey<
29+
T extends TSchema = TSchema,
30+
K extends string | RedisKeyspace = string,
2731
> extends RedisTransform<T> {
2832
declare $$typeof: "RedisKey";
33+
2934
constructor(
30-
readonly name: string,
31-
schema: T,
35+
readonly name: K,
36+
schema: T = Type.Unknown() as T,
3237
) {
3338
super(schema);
3439
}
3540

3641
/**
37-
* Derive a new key by prefixing the current key with the given keys. When multiple keys are
38-
* passed in, they will be joined with a colon.
42+
* Creates a new key within a namespace.
3943
*/
40-
join(...keys: (string | number)[]): this {
41-
if (keys.length === 0) return this;
42-
return new (this.constructor as new (name: string, schema: T) => this)(
43-
`${this.name}:${keys.join(":")}`,
44-
this.schema,
45-
);
46-
}
44+
qualify(
45+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
46+
): RedisKey<T, string>;
47+
qualify<T extends TSchema>(
48+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
49+
schema: T,
50+
): RedisKey<T, string>;
51+
qualify(
52+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
53+
schema: TSchema = this.schema,
54+
) {
55+
const RedisKey = this.constructor as new (
56+
name: string,
57+
schema: TSchema,
58+
) => any;
4759

48-
/**
49-
* Use this key as a namespace for a pattern. The `pattern` is appended
50-
* to the current key with a colon between them.
51-
*/
52-
match(pattern: string) {
53-
return this.name + ":" + pattern;
60+
return new RedisKey(`${this.name}:${name}`, schema);
5461
}
5562
}
5663

57-
export class RedisSet<T extends TSchema = TSchema> extends RedisKey<T> {
64+
/**
65+
* Represents a key in a Redis database with a single datum, like a
66+
* primitive or a JSON object.
67+
*/
68+
export class RedisEntity<
69+
T extends TSchema = TSchema,
70+
K extends string | RedisKeyspace = string,
71+
> extends RedisKey<T, K> {
72+
declare $$typeof: "RedisKey" & { subtype: "RedisEntity" };
73+
declare qualify: {
74+
(
75+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
76+
): RedisEntity<T, string>;
77+
<T extends TSchema>(
78+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
79+
schema: T,
80+
): RedisEntity<T, string>;
81+
};
82+
}
83+
84+
export class RedisSet<
85+
T extends TSchema = TSchema,
86+
K extends string | RedisKeyspace = string,
87+
> extends RedisKey<T, K> {
5888
declare $$typeof: "RedisKey" & { subtype: "RedisSet" };
89+
declare qualify: {
90+
(
91+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
92+
): RedisSet<T, string>;
93+
<T extends TSchema>(
94+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
95+
schema: T,
96+
): RedisSet<T, string>;
97+
};
5998
}
6099

61-
export class RedisHash<T extends TRedisHash = TRedisHash> extends RedisKey<T> {
100+
export class RedisHash<
101+
T extends Record<string, TSchema> = Record<string, TSchema>,
102+
K extends string | RedisKeyspace = string,
103+
> extends RedisKey<TObject<T>, K> {
62104
declare $$typeof: "RedisKey" & { subtype: "RedisHash" };
105+
declare qualify: {
106+
(
107+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
108+
): RedisHash<T, string>;
109+
<T extends TSchema>(
110+
name: K extends RedisKeyspace<infer Key> ? Key : string | number,
111+
schema: T,
112+
): RedisHash<T, string>;
113+
};
63114

64115
/**
65116
* Like `encode`, but for keys that point to a hash map.
66117
*/
67-
encodeField<TField extends RedisField<T>>(
118+
encodeField<TField extends keyof T>(
68119
field: TField,
69120
value: StaticEncode<T[TField]>,
70121
): RedisValue {
71122
// The schema is defined for JS, not Redis, so a "decoded" value
72123
// represents a Redis value.
73-
return Decode(this.schema[field], value) as any;
124+
return Decode(this.schema.properties[field], value) as any;
74125
}
75126

76127
/**
77128
* Like `decode`, but for keys that point to a hash map.
78129
*/
79-
decodeField<TField extends RedisField<T>>(
130+
decodeField<TField extends keyof T>(
80131
field: TField,
81132
value: unknown,
82-
): T extends TSchema ? never : StaticEncode<T[TField]> {
133+
): StaticEncode<T[TField]> {
83134
// The schema is defined for JS, not Redis, so an "encoded" value
84135
// represents a JS value.
85-
return Encode(this.schema[field], value);
136+
return Encode(this.schema.properties[field], value);
86137
}
87138
}
88139

src/stream.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
TString,
77
} from "@sinclair/typebox";
88
import { RedisValue } from "./command";
9-
import { RedisTransform } from "./transform";
9+
import { RedisKey, RedisKeyspace } from "./key";
1010

1111
/** Any valid schema for a Redis stream entry. */
1212
export type TRedisStreamEntry =
@@ -25,14 +25,9 @@ export type RedisStreamPosition<
2525

2626
export class RedisStream<
2727
T extends TRedisStreamEntry = TRedisStreamEntry,
28-
> extends RedisTransform<T> {
29-
declare $$typeof: "RedisStream";
30-
constructor(
31-
readonly name: string,
32-
entrySchema: T,
33-
) {
34-
super(entrySchema);
35-
}
28+
K extends string | RedisKeyspace = string,
29+
> extends RedisKey<T, K> {
30+
declare $$typeof: "RedisKey" & { subtype: "RedisStream" };
3631

3732
/**
3833
* For use with `XREAD` or `XREADGROUP`. Defines which ID was last delivered to the
@@ -59,7 +54,7 @@ export class RedisStreamEntry<T extends TRedisStreamEntry = TRedisStreamEntry> {
5954
readonly data: StaticEncode<T>;
6055
constructor(
6156
/** The stream this entry belongs to */
62-
readonly stream: RedisStream<T>,
57+
readonly stream: RedisStream<T, string>,
6358
/** The ID of this entry */
6459
readonly id: string,
6560
/** The fields and values of this entry */

0 commit comments

Comments
 (0)