@desmat/redis-store 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/index copy.d.ts +60 -0
- package/dist/index copy.js +356 -0
- package/dist/index.d.ts +32 -4
- package/dist/index.js +62 -4
- package/dist/memory-store.d.ts +54 -0
- package/dist/memory-store.js +279 -0
- package/dist/store.d.ts +37 -0
- package/dist/store.js +2 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ Leans into Redis’ strong suits to bring relational aspects to the simple but p
|
|
|
12
12
|
|
|
13
13
|
Plays well with Upstash and Vercel but will work with any Redis instance via REST API.
|
|
14
14
|
|
|
15
|
+
Also ships with `MemoryStore`, a pure in-memory implementation of `RedisStore` — a drop-in swap for local development and tests that don't need (or want) a live Redis instance. See [In-memory store](#in-memory-store) below.
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
## Installation
|
|
17
19
|
|
|
@@ -171,3 +173,50 @@ await Promise.all([
|
|
|
171
173
|
// ZREM testthings:user:<USER_UUID> <UUID>
|
|
172
174
|
// ...
|
|
173
175
|
```
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
## In-memory store
|
|
179
|
+
|
|
180
|
+
`MemoryStore<T>` implements the exact same `Store<T>` interface as `RedisStore<T>` but keeps everything in plain JS `Map`s — no Redis connection, no environment variables, no network calls. Application code written against `Store<T>` can swap between the two without any changes.
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { MemoryStore } from '@desmat/redis-store';
|
|
184
|
+
|
|
185
|
+
const store = {
|
|
186
|
+
users: new MemoryStore<User>({ key: "user" }),
|
|
187
|
+
things: new MemoryStore<Thing>({ key: "thing", options: ThingOptions }),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const user = await store.users.create({ name: "User One" });
|
|
191
|
+
const things = await store.things.find({ user: user.id });
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Useful for:
|
|
195
|
+
- Local development without provisioning a real Redis instance
|
|
196
|
+
- Test suites that need fast, isolated, disposable data (each `MemoryStore` instance is its own private state — nothing persists between processes, and nothing is shared unless you share the instance)
|
|
197
|
+
|
|
198
|
+
Constructor accepts an optional `seed` array to pre-populate records (and their lookup indices) at construction time, so seeded data is immediately queryable via `find`/`ids` without needing to call `.create()` for each one:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
const things = new MemoryStore<Thing>({
|
|
202
|
+
key: "thing",
|
|
203
|
+
options: ThingOptions,
|
|
204
|
+
seed: [
|
|
205
|
+
{ id: "thing-1", createdAt: Date.now(), createdBy: "user-1", label: "Seeded thing" },
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Notes on parity with `RedisStore`:
|
|
211
|
+
- `create`/`update`/`delete`/lookup/counter semantics are ported 1:1, including Redis's rank-based (not score-range) windowing for `count`/`offset` in `ids()`/`find()`, and `queryCounter`'s lexicographic range-bound behavior for `incCounters`/`queryCounter`.
|
|
212
|
+
- `options.expire` is a no-op (data is ephemeral for the life of the process anyway).
|
|
213
|
+
- There's no equivalent to `RedisStore`'s raw `.redis` client escape hatch — code that reaches through it directly for one-off scripts (rather than going through the `Store<T>` methods) isn't portable to `MemoryStore`.
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
## Testing
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
npm test
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Runs the `MemoryStore` unit test suite (`test/*.test.ts`) via Node's built-in test runner (through `tsx`) — no live Redis needed. `RedisStore` itself has no automated tests; it's a thin wrapper around `@upstash/redis`.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Redis } from "@upstash/redis";
|
|
2
|
+
export type RedisStoreRecord = {
|
|
3
|
+
id: string;
|
|
4
|
+
createdAt: number;
|
|
5
|
+
updatedAt?: number;
|
|
6
|
+
deletedAt?: number;
|
|
7
|
+
};
|
|
8
|
+
export default class RedisStore<T extends RedisStoreRecord> {
|
|
9
|
+
redis: Redis;
|
|
10
|
+
key: string;
|
|
11
|
+
setKey: string;
|
|
12
|
+
valueKey: (id: string) => string;
|
|
13
|
+
options: any;
|
|
14
|
+
debug: boolean;
|
|
15
|
+
constructor({ url, token, key, setKey, options, debug, }: {
|
|
16
|
+
key: string;
|
|
17
|
+
setKey?: string;
|
|
18
|
+
options?: any;
|
|
19
|
+
url?: string;
|
|
20
|
+
token?: string;
|
|
21
|
+
debug?: boolean;
|
|
22
|
+
});
|
|
23
|
+
lookupKeys(value: any, options?: {
|
|
24
|
+
noLookup?: boolean;
|
|
25
|
+
lookups?: any;
|
|
26
|
+
}): any[][];
|
|
27
|
+
exists(id: string): Promise<boolean>;
|
|
28
|
+
get(id: string, options?: any): Promise<T | undefined>;
|
|
29
|
+
scan(query?: any): Promise<Set<string>>;
|
|
30
|
+
ids(query?: any, options?: {
|
|
31
|
+
withScores?: boolean;
|
|
32
|
+
aggregate?: string;
|
|
33
|
+
}): Promise<Set<string>>;
|
|
34
|
+
find(query?: any): Promise<T[]>;
|
|
35
|
+
create(value: any, options?: {
|
|
36
|
+
expire?: number;
|
|
37
|
+
noIndex?: boolean;
|
|
38
|
+
score?: number;
|
|
39
|
+
noLookup?: boolean;
|
|
40
|
+
lookups?: any;
|
|
41
|
+
}): Promise<T>;
|
|
42
|
+
update(value: any, options?: any): Promise<T>;
|
|
43
|
+
incrementCounters(values: Record<string, string | number>, delta: {
|
|
44
|
+
total: number;
|
|
45
|
+
count: number;
|
|
46
|
+
}): Promise<any>;
|
|
47
|
+
queryCounter(kind: "count" | "counts" | "totals", dims: string[], exact: Record<string, string | number>, range?: {
|
|
48
|
+
field: string;
|
|
49
|
+
min?: string;
|
|
50
|
+
max?: string;
|
|
51
|
+
}): Promise<number | Array<{
|
|
52
|
+
member: string;
|
|
53
|
+
score: number;
|
|
54
|
+
}>>;
|
|
55
|
+
delete(id: string, options?: {
|
|
56
|
+
hardDelete?: boolean;
|
|
57
|
+
noLookup?: boolean;
|
|
58
|
+
lookups?: any;
|
|
59
|
+
}): Promise<T | undefined>;
|
|
60
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
Some useful commands
|
|
4
|
+
|
|
5
|
+
keys *
|
|
6
|
+
scan 0 match thing:*
|
|
7
|
+
del thing1 thing2 etc
|
|
8
|
+
json.get things $
|
|
9
|
+
json.get things '$[?((@.deletedAt > 0) == false)]'
|
|
10
|
+
json.get things '$[?((@.deletedAt > 0) == true)]'
|
|
11
|
+
json.get things '$[?(@.createdBy == "UID")]'
|
|
12
|
+
json.get things '$[?(@.content ~= "(?i)lorem")]'
|
|
13
|
+
json.get things '$[?(@.id ~= "(ID1)|(ID2)")]
|
|
14
|
+
json.set thing:UUID '$.foos[5].bar' '{"car": 42}'
|
|
15
|
+
json.set thing:UUID '$.foos[1].bar.car' '42'
|
|
16
|
+
json.get userhaikus '$[?(@.haikuId == "ID" && (@.likedAt > 0) == true)]'
|
|
17
|
+
*/
|
|
18
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
19
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
const moment_1 = __importDefault(require("moment"));
|
|
23
|
+
const utils_1 = require("@desmat/utils");
|
|
24
|
+
const redis_1 = require("@upstash/redis");
|
|
25
|
+
// polyfill Set.intersection
|
|
26
|
+
;
|
|
27
|
+
(function () {
|
|
28
|
+
// @ts-ignore
|
|
29
|
+
if (!Set.prototype.intersection) {
|
|
30
|
+
// @ts-ignore
|
|
31
|
+
Set.prototype.intersection = function (other) {
|
|
32
|
+
return new Set(Array.from(this).filter((value) => other.has(value)));
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
class RedisStore {
|
|
37
|
+
constructor({ url, token, key, setKey, options, debug, }) {
|
|
38
|
+
const _url = url || process.env.KV_REST_API_URL; //|| "https://crisp-skylark-47483.upstash.io";
|
|
39
|
+
const _token = token || process.env.KV_REST_API_TOKEN; //|| "Abl7AAIncDFiZDM3ZjVjOTEyMmI0MWYxOWRkYTlhMWVkYWQ3ZGRjN3AxNDc0ODM";
|
|
40
|
+
if (!_url)
|
|
41
|
+
throw 'Error creating RedisStore: `url` is required: either provide in constructor or via environment variable `KV_REST_API_URL`';
|
|
42
|
+
if (!_token)
|
|
43
|
+
throw 'Error creating RedisStore: `token` is required: either provide in constructor or via environment variable `KV_REST_API_TOKEN`';
|
|
44
|
+
this.redis = new redis_1.Redis({
|
|
45
|
+
url: _url,
|
|
46
|
+
token: _token
|
|
47
|
+
});
|
|
48
|
+
this.key = key;
|
|
49
|
+
this.setKey = setKey || key + "s";
|
|
50
|
+
this.valueKey = (id) => `${key}:${id}`;
|
|
51
|
+
this.options = options;
|
|
52
|
+
this.debug = !!debug;
|
|
53
|
+
}
|
|
54
|
+
lookupKeys(value, options) {
|
|
55
|
+
options = { ...this.options, ...options };
|
|
56
|
+
this.debug && console.log(`RedisStore.lookupKeys<${this.key}>.lookupKeys`, { value, options });
|
|
57
|
+
/*
|
|
58
|
+
create index and lookup sets based on options.lookups
|
|
59
|
+
|
|
60
|
+
given a likedhaiku record:
|
|
61
|
+
{
|
|
62
|
+
id: 123:456,
|
|
63
|
+
userId: 123,
|
|
64
|
+
haikuId 456,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
and lookups:
|
|
68
|
+
{
|
|
69
|
+
user: { userId: "haikuId"},
|
|
70
|
+
haiku: { haikuId: "userId" }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
we want indexes:
|
|
74
|
+
|
|
75
|
+
likedhaiku:123:456 -> value (JSON, the rest are sorted sets, not handled here)
|
|
76
|
+
likedhaikus -> all likedhaiku id's (ie 123:456, etc, not handled here)
|
|
77
|
+
// NOT SUPPORTED FOR NOW // likedhaikus:users -> all user ids (ie 123, etc) NOTE: this should be a sorted set of user ids with its score as number of haikus liked
|
|
78
|
+
likedhaikus:user:123 -> all likedhaiku id's for the given user (ie 123:456, etc)
|
|
79
|
+
// NOT SUPPORTED FOR NOW // likedhaikus:haikus -> NOTE: this should be a sorted set of haiku ids with its score as number of users who liked it
|
|
80
|
+
likedhaikus:haiku:456 -> all likedhaiku id's for the given haiku (ie 123:456, etc)
|
|
81
|
+
|
|
82
|
+
*/
|
|
83
|
+
const lookupKeys = !(options === null || options === void 0 ? void 0 : options.noLookup) && Object
|
|
84
|
+
.entries((options === null || options === void 0 ? void 0 : options.lookups) || {})
|
|
85
|
+
.map((entry) => {
|
|
86
|
+
const id = value.id;
|
|
87
|
+
const lookupName = entry[0];
|
|
88
|
+
const lookupKey = entry[1];
|
|
89
|
+
// TODO validate and log errors
|
|
90
|
+
// @ts-ignore
|
|
91
|
+
const lookupId = value[lookupKey];
|
|
92
|
+
// foos:bar:123 -> 123:456
|
|
93
|
+
return [`${this.setKey}:${lookupName}:${lookupId}`, id];
|
|
94
|
+
}) || [];
|
|
95
|
+
this.debug && console.log(`RedisStore<${this.key}>.lookupKeys`, { options, lookupKeys });
|
|
96
|
+
return lookupKeys;
|
|
97
|
+
}
|
|
98
|
+
async exists(id) {
|
|
99
|
+
this.debug && console.log(`RedisStore<${this.key}>.exists`, { id });
|
|
100
|
+
const response = await this.redis.exists(this.valueKey(id));
|
|
101
|
+
this.debug && console.log(`RedisStore<${this.key}>.exists`, { response });
|
|
102
|
+
return response > 0;
|
|
103
|
+
}
|
|
104
|
+
async get(id, options) {
|
|
105
|
+
this.debug && console.log(`RedisStore<${this.key}>.get`, { id });
|
|
106
|
+
const response = await this.redis.json.get(this.valueKey(id), "$");
|
|
107
|
+
this.debug && console.log(`RedisStore<${this.key}>.get`, { response });
|
|
108
|
+
let value;
|
|
109
|
+
if (response && response[0] && !(response[0].deletedAt && !(options === null || options === void 0 ? void 0 : options.deleted))) {
|
|
110
|
+
value = response[0];
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
async scan(query = {}) {
|
|
115
|
+
this.debug && console.log(`RedisStore<${this.key}>.scan`, { query });
|
|
116
|
+
!query.count && console.warn(`RedisStore.RedisStore<${this.key}>.find WARNING: scan command with no count provided: setting count at 999`);
|
|
117
|
+
const count = query.count || 999;
|
|
118
|
+
const match = this.valueKey(query.scan);
|
|
119
|
+
let keys = new Set();
|
|
120
|
+
let nextCursor = "0";
|
|
121
|
+
do {
|
|
122
|
+
const ret = await this.redis.scan(nextCursor, { match, type: "json", count: count - keys.size });
|
|
123
|
+
this.debug && console.log(`RedisStore<${this.key}>.scan`, { ret });
|
|
124
|
+
nextCursor = ret[0];
|
|
125
|
+
ret[1].forEach((key) => keys.size < count && keys.add(key.substring(key.indexOf(':') + 1)));
|
|
126
|
+
} while (keys.size < count && nextCursor && nextCursor != "0");
|
|
127
|
+
this.debug && console.log(`RedisStore<${this.key}>.scan`, { keys });
|
|
128
|
+
return keys;
|
|
129
|
+
}
|
|
130
|
+
async ids(query = {}, options) {
|
|
131
|
+
this.debug && console.log(`RedisStore<${this.key}>.ids`, { query });
|
|
132
|
+
if (query.scan) {
|
|
133
|
+
return this.scan(query);
|
|
134
|
+
}
|
|
135
|
+
let count = query.count;
|
|
136
|
+
delete query.count;
|
|
137
|
+
if (typeof (count) != "undefined" && typeof (count) != "number") {
|
|
138
|
+
console.warn(`RedisStore.RedisStore<${this.key}>.id WARNING: query.count is not a number`);
|
|
139
|
+
count = undefined;
|
|
140
|
+
}
|
|
141
|
+
let offset = query.offset;
|
|
142
|
+
delete query.offset;
|
|
143
|
+
if (typeof (offset) != "undefined" && typeof (offset) != "number") {
|
|
144
|
+
console.warn(`RedisStore.RedisStore<${this.key}>.id WARNING: query.offset must be a number`);
|
|
145
|
+
offset = undefined;
|
|
146
|
+
}
|
|
147
|
+
const min = offset || 0;
|
|
148
|
+
const max = min + (count || 0) - 1;
|
|
149
|
+
const queryEntries = query && Object.entries(query);
|
|
150
|
+
if (options === null || options === void 0 ? void 0 : options.withScores) {
|
|
151
|
+
// const setOfIds = await Promise.all(
|
|
152
|
+
// queryEntries.map(([queryKey, queryVal]: [string, string]) => {
|
|
153
|
+
// if (queryVal) {
|
|
154
|
+
// // lookup keys via the foos:bar:123 lookup set
|
|
155
|
+
// return this.redis.zrange(`${this.setKey}:${queryKey}:${queryVal}`, min, max, { rev: true, withScores: !!options?.withScores });
|
|
156
|
+
// } else {
|
|
157
|
+
// throw `redis.find(query) query must have key and value`;
|
|
158
|
+
// }
|
|
159
|
+
// })
|
|
160
|
+
// );
|
|
161
|
+
const keys = queryEntries.map(([queryKey, queryVal]) => `${queryKey}:${queryVal}`);
|
|
162
|
+
const overallKey = `${this.setKey}:${keys.join(":")}`;
|
|
163
|
+
const zinterstoreRet = await this.redis.zinterstore(overallKey, keys.length, keys.map((k) => `${this.setKey}:${k}`), { aggregate: "min" });
|
|
164
|
+
const zrangeRet = await this.redis.zrange(overallKey, min, max, { rev: true, withScores: !!(options === null || options === void 0 ? void 0 : options.withScores) });
|
|
165
|
+
console.warn(`RedisStore.RedisStore<${this.key}>.ids`, { overallKey, keys, zinterstoreRet, zrangeRet });
|
|
166
|
+
// const ids = setOfIds.reduce((prev: Set<any> | undefined, curr: any[]) => {
|
|
167
|
+
// new Set(curr).intersection(prev || new Set(curr)), undefined)
|
|
168
|
+
// throw 'NOT IMPLEMENTED';
|
|
169
|
+
}
|
|
170
|
+
// .ids() or .ids({})
|
|
171
|
+
if (!(queryEntries === null || queryEntries === void 0 ? void 0 : queryEntries.length)) {
|
|
172
|
+
// get all keys via the index set
|
|
173
|
+
return new Set(await this.redis.zrange(`${this.setKey}`, min, max, { rev: true /* , withScores: !!options?.withScores */ }));
|
|
174
|
+
}
|
|
175
|
+
// .ids({ foo: "FOO", bar: "BAR", ... })
|
|
176
|
+
const setOfIds = await Promise.all(queryEntries.map(([queryKey, queryVal]) => {
|
|
177
|
+
if (queryVal) {
|
|
178
|
+
// lookup keys via the foos:bar:123 lookup set
|
|
179
|
+
return this.redis.zrange(`${this.setKey}:${queryKey}:${queryVal}`, min, max, { rev: true /* , withScores: !!options?.withScores */ });
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
throw `redis.find(query) query must have key and value`;
|
|
183
|
+
}
|
|
184
|
+
}));
|
|
185
|
+
// @ts-ignore
|
|
186
|
+
const ids = setOfIds.reduce((prev, curr) => new Set(curr).intersection(prev || new Set(curr)), undefined);
|
|
187
|
+
this.debug && console.log(`RedisStore<${this.key}>.ids queried lookup key`, { query, setOfIds, ids });
|
|
188
|
+
return ids;
|
|
189
|
+
}
|
|
190
|
+
async find(query = {}) {
|
|
191
|
+
this.debug && console.log(`RedisStore<${this.key}>.find`, { query });
|
|
192
|
+
const keys = Array.isArray(query.id)
|
|
193
|
+
? query.id
|
|
194
|
+
.map((id) => id && this.valueKey(id))
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
: Array.from(await this.ids(query))
|
|
197
|
+
// @ts-ignore
|
|
198
|
+
.map((key) => `${this.key}:${key}`);
|
|
199
|
+
if (keys.length > 100) {
|
|
200
|
+
console.warn(`RedisStore.RedisStore<${this.key}>.find WARNING: json.mget more than 100 values`, { keys });
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
this.debug && console.log(`RedisStore<${this.key}>.find`, { keys });
|
|
204
|
+
}
|
|
205
|
+
// don't mget too many at once otherwise 💥
|
|
206
|
+
const blockSize = 256;
|
|
207
|
+
const blocks = keys && keys.length && Array
|
|
208
|
+
.apply(null, Array(Math.ceil(keys.length / blockSize)))
|
|
209
|
+
.map((v, block) => (keys || [])
|
|
210
|
+
.slice(blockSize * block, blockSize * (block + 1)));
|
|
211
|
+
this.debug && console.log(`RedisStore<${this.key}>.find`, { blocks });
|
|
212
|
+
const values = blocks && blocks.length > 0
|
|
213
|
+
? (await Promise.all(blocks
|
|
214
|
+
.map(async (keys) => (await this.redis.json.mget(keys, "$"))
|
|
215
|
+
.flat())))
|
|
216
|
+
.flat()
|
|
217
|
+
.filter((value) => value && !value.deletedAt)
|
|
218
|
+
: [];
|
|
219
|
+
this.debug && console.log(`RedisStore<${this.key}>.find`, { values });
|
|
220
|
+
return values;
|
|
221
|
+
}
|
|
222
|
+
async create(value, options) {
|
|
223
|
+
this.debug && console.log(`RedisStore<${this.key}>.create`, { value, options, this_options: this.options });
|
|
224
|
+
const now = (0, moment_1.default)().valueOf();
|
|
225
|
+
options = { ...this.options, ...options };
|
|
226
|
+
const createdValue = {
|
|
227
|
+
id: value.id || (0, utils_1.uuid)(),
|
|
228
|
+
createdAt: value.createdAt || now,
|
|
229
|
+
...value,
|
|
230
|
+
};
|
|
231
|
+
this.debug && console.log(`RedisStore<${this.key}>.create`, { createdValue });
|
|
232
|
+
const lookupKeys = this.lookupKeys(createdValue, options);
|
|
233
|
+
this.debug && console.log(`RedisStore<${this.key}>.create`, { lookupKeys });
|
|
234
|
+
const responses = await Promise.all([
|
|
235
|
+
this.redis.json.set(this.valueKey(createdValue.id), "$", createdValue),
|
|
236
|
+
(options === null || options === void 0 ? void 0 : options.expire) && this.redis.expire(this.valueKey(createdValue.id), options === null || options === void 0 ? void 0 : options.expire),
|
|
237
|
+
// !options?.noIndex && this.redis.zadd(this.setKey, { score: options?.score || createdValue.createdAt, member: createdValue.id }),
|
|
238
|
+
// ...(lookupKeys ? lookupKeys.map((lookupKey: any) => this.redis.zadd(lookupKey[0], { score: options?.score || createdValue.createdAt, member: lookupKey[1] })) : []),
|
|
239
|
+
...(lookupKeys ? lookupKeys.map((lookupKey) => this.redis.zincrby(lookupKey[0], (options === null || options === void 0 ? void 0 : options.score) || createdValue.createdAt, lookupKey[1])) : []),
|
|
240
|
+
]);
|
|
241
|
+
this.debug && console.log(`RedisStore<${this.key}>.create`, { responses });
|
|
242
|
+
return createdValue;
|
|
243
|
+
}
|
|
244
|
+
async update(value, options) {
|
|
245
|
+
this.debug && console.log(`RedisStore<${this.key}>.update`, { value, options });
|
|
246
|
+
if (!value.id) {
|
|
247
|
+
throw `Cannot update ${this.key}: null id`;
|
|
248
|
+
}
|
|
249
|
+
const prevValue = await this.get(value.id);
|
|
250
|
+
if (!prevValue) {
|
|
251
|
+
throw `Cannot update ${this.key}: does not exist: ${value.id}`;
|
|
252
|
+
}
|
|
253
|
+
const now = (0, moment_1.default)().valueOf();
|
|
254
|
+
options = { ...this.options, ...options };
|
|
255
|
+
const updatedValue = {
|
|
256
|
+
...value,
|
|
257
|
+
updatedAt: now,
|
|
258
|
+
};
|
|
259
|
+
// optionally update lookups
|
|
260
|
+
// @ts-ignore
|
|
261
|
+
const prevLookupKeys = new Map(this.lookupKeys(prevValue, options));
|
|
262
|
+
// @ts-ignore
|
|
263
|
+
const lookupKeys = new Map(this.lookupKeys(updatedValue, options));
|
|
264
|
+
const lookupsToRemove = prevLookupKeys && Array.from(prevLookupKeys)
|
|
265
|
+
.filter(([k, v]) => !lookupKeys || lookupKeys.get(k) != v);
|
|
266
|
+
const lookupsToAdd = lookupKeys && Array.from(lookupKeys)
|
|
267
|
+
.filter(([k, v]) => !prevLookupKeys || prevLookupKeys.get(k) != v);
|
|
268
|
+
this.debug && console.log(`RedisStore<${this.key}>.update`, { prevLookupKeys, lookupKeys, prevLookupKeyMap: prevLookupKeys, keysToRemove: lookupsToRemove, keysToAdd: lookupsToAdd });
|
|
269
|
+
if (lookupsToRemove && lookupsToRemove.length) {
|
|
270
|
+
this.debug && console.log(`RedisStore<${this.key}>.update deleting previous lookup keys`, { prevLookupKeys });
|
|
271
|
+
const response = await Promise.all([
|
|
272
|
+
...lookupsToRemove.map((lookupKey) => this.redis.zrem(lookupKey[0], lookupKey[1]))
|
|
273
|
+
]);
|
|
274
|
+
this.debug && console.log(`RedisStore<${this.key}>.update deleted previous lookup keys`, { response });
|
|
275
|
+
}
|
|
276
|
+
const response = await Promise.all([
|
|
277
|
+
this.redis.json.set(this.valueKey(value.id), "$", updatedValue),
|
|
278
|
+
options.expire && this.redis.expire(this.valueKey(value.id), options.expire),
|
|
279
|
+
...(lookupsToAdd ? lookupsToAdd.map((lookupKey) => this.redis.zadd(lookupKey[0], { score: updatedValue.createdAt || updatedValue.updatedAt, member: lookupKey[1] })) : []),
|
|
280
|
+
]);
|
|
281
|
+
this.debug && console.log(`RedisStore<${this.key}>.update`, { response });
|
|
282
|
+
return updatedValue;
|
|
283
|
+
}
|
|
284
|
+
async incrementCounters(values, delta) {
|
|
285
|
+
var _a;
|
|
286
|
+
this.debug && console.log(`RedisStore<${this.key}>.incrementCounters`, { values, delta });
|
|
287
|
+
const counters = ((_a = this.options) === null || _a === void 0 ? void 0 : _a.counters) || [];
|
|
288
|
+
const responses = await Promise.all(counters
|
|
289
|
+
.filter((dims) => dims.every((d) => typeof values[d] != "undefined"))
|
|
290
|
+
.flatMap((dims) => {
|
|
291
|
+
const member = dims.map((d) => `${d}=${values[d]}`).join(":");
|
|
292
|
+
return [
|
|
293
|
+
this.redis.zincrby(`${this.key}Totals:${dims.join(":")}`, delta.total, member),
|
|
294
|
+
this.redis.zincrby(`${this.key}Counts:${dims.join(":")}`, delta.count, member),
|
|
295
|
+
];
|
|
296
|
+
}));
|
|
297
|
+
this.debug && console.log(`RedisStore<${this.key}>.incrementCounters`, { responses });
|
|
298
|
+
return responses;
|
|
299
|
+
}
|
|
300
|
+
async queryCounter(kind, dims, exact, range) {
|
|
301
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { kind, dims, exact, range });
|
|
302
|
+
const setKey = `${this.key}${kind == "totals" ? "Totals" : "Counts"}:${dims.join(":")}`;
|
|
303
|
+
const prefix = dims
|
|
304
|
+
.filter((d) => typeof exact[d] != "undefined")
|
|
305
|
+
.map((d) => `${d}=${exact[d]}`)
|
|
306
|
+
.join(":");
|
|
307
|
+
// the trailing sentinel scopes the range to the "prefix:" family regardless of whether
|
|
308
|
+
// the range field itself is the last dim, matching how the original hand-rolled queries worked
|
|
309
|
+
const hasMore = dims.length > Object.keys(exact).length;
|
|
310
|
+
const minPrefix = (range === null || range === void 0 ? void 0 : range.min) != null ? `${prefix}${prefix ? ":" : ""}${range.field}=${range.min}` : prefix;
|
|
311
|
+
const maxPrefix = (range === null || range === void 0 ? void 0 : range.max) != null ? `${prefix}${prefix ? ":" : ""}${range.field}=${range.max}` : prefix;
|
|
312
|
+
const min = `[${minPrefix}${hasMore ? ":\x00" : ""}`;
|
|
313
|
+
const max = `[${maxPrefix}${hasMore ? ":\x7f" : ""}`;
|
|
314
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { setKey, min, max });
|
|
315
|
+
if (kind == "count") {
|
|
316
|
+
const count = await this.redis.zlexcount(setKey, min, max);
|
|
317
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { count });
|
|
318
|
+
return count;
|
|
319
|
+
}
|
|
320
|
+
const raw = await this.redis.zrange(setKey, min, max, { byLex: true, withScores: true });
|
|
321
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { raw });
|
|
322
|
+
// members are returned as-is ("field=value:field=value...") rather than parsed into an
|
|
323
|
+
// object: some field values (eg namespaced ids) may themselves contain the ":" delimiter,
|
|
324
|
+
// which only the caller can safely account for when splitting a member back into fields
|
|
325
|
+
const results = [];
|
|
326
|
+
for (let i = 0; i < raw.length / 2; i++) {
|
|
327
|
+
results.push({ member: `${raw[2 * i]}`, score: raw[2 * i + 1] });
|
|
328
|
+
}
|
|
329
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { results });
|
|
330
|
+
return results;
|
|
331
|
+
}
|
|
332
|
+
async delete(id, options) {
|
|
333
|
+
this.debug && console.log(`RedisStore<${this.key}>.delete`, { id, options });
|
|
334
|
+
if (!id) {
|
|
335
|
+
throw `Cannot delete ${this.key}: null id`;
|
|
336
|
+
}
|
|
337
|
+
options = { ...this.options, ...options };
|
|
338
|
+
const value = await this.get(id, { deleted: true });
|
|
339
|
+
if (!value) {
|
|
340
|
+
console.warn(`RedisStore<${this.key}>.delete WARNING: does not exist: ${id}`);
|
|
341
|
+
}
|
|
342
|
+
const lookupKeys = value && this.lookupKeys(value, options);
|
|
343
|
+
this.debug && console.log(`RedisStore<${this.key}>.delete`, { lookupKeys });
|
|
344
|
+
const deletedAt = (0, moment_1.default)().valueOf();
|
|
345
|
+
const response = await Promise.all([
|
|
346
|
+
(options === null || options === void 0 ? void 0 : options.hardDelete)
|
|
347
|
+
? this.redis.json.del(this.valueKey(id), "$")
|
|
348
|
+
: this.redis.json.set(this.valueKey(id), "$.deletedAt", deletedAt),
|
|
349
|
+
this.redis.zrem(this.setKey, id),
|
|
350
|
+
...(lookupKeys ? lookupKeys.map((lookupKey) => this.redis.zrem(lookupKey[0], lookupKey[1])) : []),
|
|
351
|
+
]);
|
|
352
|
+
this.debug && console.log(`RedisStore<${this.key}>.delete`, { response });
|
|
353
|
+
return value ? { ...value, deletedAt } : undefined;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
exports.default = RedisStore;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Redis } from "@upstash/redis";
|
|
2
|
+
import type { Store } from "./store";
|
|
2
3
|
export type RedisStoreRecord = {
|
|
3
4
|
id: string;
|
|
4
5
|
createdAt: number;
|
|
5
6
|
updatedAt?: number;
|
|
6
7
|
deletedAt?: number;
|
|
7
8
|
};
|
|
8
|
-
export
|
|
9
|
+
export { Store } from "./store";
|
|
10
|
+
export { default as MemoryStore } from "./memory-store";
|
|
11
|
+
export default class RedisStore<T extends RedisStoreRecord> implements Store<T> {
|
|
9
12
|
redis: Redis;
|
|
10
13
|
key: string;
|
|
11
14
|
setKey: string;
|
|
@@ -20,13 +23,38 @@ export default class RedisStore<T extends RedisStoreRecord> {
|
|
|
20
23
|
token?: string;
|
|
21
24
|
debug?: boolean;
|
|
22
25
|
});
|
|
23
|
-
lookupKeys(value: any, options?:
|
|
26
|
+
lookupKeys(value: any, options?: {
|
|
27
|
+
noLookup?: boolean;
|
|
28
|
+
lookups?: any;
|
|
29
|
+
}): any[][];
|
|
24
30
|
exists(id: string): Promise<boolean>;
|
|
25
31
|
get(id: string, options?: any): Promise<T | undefined>;
|
|
26
32
|
scan(query?: any): Promise<Set<string>>;
|
|
27
33
|
ids(query?: any): Promise<Set<string>>;
|
|
28
34
|
find(query?: any): Promise<T[]>;
|
|
29
|
-
create(value: any, options?:
|
|
35
|
+
create(value: any, options?: {
|
|
36
|
+
expire?: number;
|
|
37
|
+
noIndex?: boolean;
|
|
38
|
+
score?: number;
|
|
39
|
+
noLookup?: boolean;
|
|
40
|
+
lookups?: any;
|
|
41
|
+
}): Promise<T>;
|
|
30
42
|
update(value: any, options?: any): Promise<T>;
|
|
31
|
-
|
|
43
|
+
incCounters(values: Record<string, string | number>, delta: {
|
|
44
|
+
total: number;
|
|
45
|
+
count: number;
|
|
46
|
+
}): Promise<any>;
|
|
47
|
+
queryCounter(kind: "count" | "counts" | "totals", counter: string, exact: Record<string, string | number>, range?: {
|
|
48
|
+
field: string;
|
|
49
|
+
min?: string;
|
|
50
|
+
max?: string;
|
|
51
|
+
}): Promise<number | Array<{
|
|
52
|
+
member: string;
|
|
53
|
+
score: number;
|
|
54
|
+
}>>;
|
|
55
|
+
delete(id: string, options?: {
|
|
56
|
+
hardDelete?: boolean;
|
|
57
|
+
noLookup?: boolean;
|
|
58
|
+
lookups?: any;
|
|
59
|
+
}): Promise<T | undefined>;
|
|
32
60
|
}
|
package/dist/index.js
CHANGED
|
@@ -19,9 +19,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
19
19
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
20
|
};
|
|
21
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.MemoryStore = void 0;
|
|
22
23
|
const moment_1 = __importDefault(require("moment"));
|
|
23
24
|
const utils_1 = require("@desmat/utils");
|
|
24
25
|
const redis_1 = require("@upstash/redis");
|
|
26
|
+
var memory_store_1 = require("./memory-store");
|
|
27
|
+
Object.defineProperty(exports, "MemoryStore", { enumerable: true, get: function () { return __importDefault(memory_store_1).default; } });
|
|
25
28
|
// polyfill Set.intersection
|
|
26
29
|
;
|
|
27
30
|
(function () {
|
|
@@ -213,8 +216,8 @@ class RedisStore {
|
|
|
213
216
|
this.debug && console.log(`RedisStore<${this.key}>.create`, { lookupKeys });
|
|
214
217
|
const responses = await Promise.all([
|
|
215
218
|
this.redis.json.set(this.valueKey(createdValue.id), "$", createdValue),
|
|
216
|
-
options.expire && this.redis.expire(this.valueKey(createdValue.id), options.expire),
|
|
217
|
-
!options.noIndex && this.redis.zadd(this.setKey, { score: createdValue.createdAt, member: createdValue.id }),
|
|
219
|
+
(options === null || options === void 0 ? void 0 : options.expire) && this.redis.expire(this.valueKey(createdValue.id), options === null || options === void 0 ? void 0 : options.expire),
|
|
220
|
+
!(options === null || options === void 0 ? void 0 : options.noIndex) && this.redis.zadd(this.setKey, { score: createdValue.createdAt, member: createdValue.id }),
|
|
218
221
|
...(lookupKeys ? lookupKeys.map((lookupKey) => this.redis.zadd(lookupKey[0], { score: createdValue.createdAt, member: lookupKey[1] })) : []),
|
|
219
222
|
]);
|
|
220
223
|
this.debug && console.log(`RedisStore<${this.key}>.create`, { responses });
|
|
@@ -260,7 +263,62 @@ class RedisStore {
|
|
|
260
263
|
this.debug && console.log(`RedisStore<${this.key}>.update`, { response });
|
|
261
264
|
return updatedValue;
|
|
262
265
|
}
|
|
263
|
-
async
|
|
266
|
+
async incCounters(values, delta) {
|
|
267
|
+
var _a;
|
|
268
|
+
this.debug && console.log(`RedisStore<${this.key}>.incCounters`, { values, delta });
|
|
269
|
+
const counters = ((_a = this.options) === null || _a === void 0 ? void 0 : _a.counters) || [];
|
|
270
|
+
counters
|
|
271
|
+
.filter((counter) => !counter.split(":").every((d) => typeof values[d] != "undefined"))
|
|
272
|
+
.forEach((counter) => {
|
|
273
|
+
const missing = counter.split(":").filter((d) => typeof values[d] == "undefined");
|
|
274
|
+
console.warn(`RedisStore<${this.key}>.incCounters WARNING: skipping counter "${counter}": missing dimension(s) ${missing.join(", ")} in values`, { values });
|
|
275
|
+
});
|
|
276
|
+
const responses = await Promise.all(counters
|
|
277
|
+
.filter((counter) => counter.split(":").every((d) => typeof values[d] != "undefined"))
|
|
278
|
+
.flatMap((counter) => {
|
|
279
|
+
const member = counter.split(":").map((d) => `${d}=${values[d]}`).join(":");
|
|
280
|
+
return [
|
|
281
|
+
this.redis.zincrby(`${this.key}Totals:${counter}`, delta.total, member),
|
|
282
|
+
this.redis.zincrby(`${this.key}Counts:${counter}`, delta.count, member),
|
|
283
|
+
];
|
|
284
|
+
}));
|
|
285
|
+
this.debug && console.log(`RedisStore<${this.key}>.incCounters`, { responses });
|
|
286
|
+
return responses;
|
|
287
|
+
}
|
|
288
|
+
async queryCounter(kind, counter, exact, range) {
|
|
289
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { kind, counter, exact, range });
|
|
290
|
+
const dims = counter.split(":");
|
|
291
|
+
const setKey = `${this.key}${kind == "totals" ? "Totals" : "Counts"}:${counter}`;
|
|
292
|
+
const prefix = dims
|
|
293
|
+
.filter((d) => typeof exact[d] != "undefined")
|
|
294
|
+
.map((d) => `${d}=${exact[d]}`)
|
|
295
|
+
.join(":");
|
|
296
|
+
// the trailing sentinel scopes the range to the "prefix:" family regardless of whether
|
|
297
|
+
// the range field itself is the last dim, matching how the original hand-rolled queries worked
|
|
298
|
+
const hasMore = dims.length > Object.keys(exact).length;
|
|
299
|
+
const minPrefix = (range === null || range === void 0 ? void 0 : range.min) != null ? `${prefix}${prefix ? ":" : ""}${range.field}=${range.min}` : prefix;
|
|
300
|
+
const maxPrefix = (range === null || range === void 0 ? void 0 : range.max) != null ? `${prefix}${prefix ? ":" : ""}${range.field}=${range.max}` : prefix;
|
|
301
|
+
const min = `[${minPrefix}${hasMore ? ":\x00" : ""}`;
|
|
302
|
+
const max = `[${maxPrefix}${hasMore ? ":\x7f" : ""}`;
|
|
303
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { setKey, min, max });
|
|
304
|
+
if (kind == "count") {
|
|
305
|
+
const count = await this.redis.zlexcount(setKey, min, max);
|
|
306
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { count });
|
|
307
|
+
return count;
|
|
308
|
+
}
|
|
309
|
+
const raw = await this.redis.zrange(setKey, min, max, { byLex: true, withScores: true });
|
|
310
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { raw });
|
|
311
|
+
// members are returned as-is ("field=value:field=value...") rather than parsed into an
|
|
312
|
+
// object: some field values (eg namespaced ids) may themselves contain the ":" delimiter,
|
|
313
|
+
// which only the caller can safely account for when splitting a member back into fields
|
|
314
|
+
const results = [];
|
|
315
|
+
for (let i = 0; i < raw.length / 2; i++) {
|
|
316
|
+
results.push({ member: `${raw[2 * i]}`, score: raw[2 * i + 1] });
|
|
317
|
+
}
|
|
318
|
+
this.debug && console.log(`RedisStore<${this.key}>.queryCounter`, { results });
|
|
319
|
+
return results;
|
|
320
|
+
}
|
|
321
|
+
async delete(id, options) {
|
|
264
322
|
this.debug && console.log(`RedisStore<${this.key}>.delete`, { id, options });
|
|
265
323
|
if (!id) {
|
|
266
324
|
throw `Cannot delete ${this.key}: null id`;
|
|
@@ -274,7 +332,7 @@ class RedisStore {
|
|
|
274
332
|
this.debug && console.log(`RedisStore<${this.key}>.delete`, { lookupKeys });
|
|
275
333
|
const deletedAt = (0, moment_1.default)().valueOf();
|
|
276
334
|
const response = await Promise.all([
|
|
277
|
-
options.hardDelete
|
|
335
|
+
(options === null || options === void 0 ? void 0 : options.hardDelete)
|
|
278
336
|
? this.redis.json.del(this.valueKey(id), "$")
|
|
279
337
|
: this.redis.json.set(this.valueKey(id), "$.deletedAt", deletedAt),
|
|
280
338
|
this.redis.zrem(this.setKey, id),
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RedisStoreRecord } from "./index";
|
|
2
|
+
import type { Store } from "./store";
|
|
3
|
+
export default class MemoryStore<T extends RedisStoreRecord> implements Store<T> {
|
|
4
|
+
key: string;
|
|
5
|
+
setKey: string;
|
|
6
|
+
options: any;
|
|
7
|
+
debug: boolean;
|
|
8
|
+
private records;
|
|
9
|
+
private index;
|
|
10
|
+
private lookups;
|
|
11
|
+
private counters;
|
|
12
|
+
constructor({ key, setKey, options, debug, seed, }: {
|
|
13
|
+
key: string;
|
|
14
|
+
setKey?: string;
|
|
15
|
+
options?: any;
|
|
16
|
+
debug?: boolean;
|
|
17
|
+
seed?: T[];
|
|
18
|
+
});
|
|
19
|
+
lookupKeys(value: any, options?: {
|
|
20
|
+
noLookup?: boolean;
|
|
21
|
+
lookups?: any;
|
|
22
|
+
}): any[][];
|
|
23
|
+
private _index;
|
|
24
|
+
exists(id: string): Promise<boolean>;
|
|
25
|
+
get(id: string, options?: any): Promise<T | undefined>;
|
|
26
|
+
scan(query?: any): Promise<Set<string>>;
|
|
27
|
+
ids(query?: any): Promise<Set<string>>;
|
|
28
|
+
find(query?: any): Promise<T[]>;
|
|
29
|
+
create(value: any, options?: {
|
|
30
|
+
expire?: number;
|
|
31
|
+
noIndex?: boolean;
|
|
32
|
+
score?: number;
|
|
33
|
+
noLookup?: boolean;
|
|
34
|
+
lookups?: any;
|
|
35
|
+
}): Promise<T>;
|
|
36
|
+
update(value: any, options?: any): Promise<T>;
|
|
37
|
+
incCounters(values: Record<string, string | number>, delta: {
|
|
38
|
+
total: number;
|
|
39
|
+
count: number;
|
|
40
|
+
}): Promise<any>;
|
|
41
|
+
queryCounter(kind: "count" | "counts" | "totals", counter: string, exact: Record<string, string | number>, range?: {
|
|
42
|
+
field: string;
|
|
43
|
+
min?: string;
|
|
44
|
+
max?: string;
|
|
45
|
+
}): Promise<number | Array<{
|
|
46
|
+
member: string;
|
|
47
|
+
score: number;
|
|
48
|
+
}>>;
|
|
49
|
+
delete(id: string, options?: {
|
|
50
|
+
hardDelete?: boolean;
|
|
51
|
+
noLookup?: boolean;
|
|
52
|
+
lookups?: any;
|
|
53
|
+
}): Promise<T | undefined>;
|
|
54
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_1 = require("@desmat/utils");
|
|
4
|
+
// mimics a Redis sorted set: members ordered by score, ties broken by member
|
|
5
|
+
// string ascending (Redis zset default ordering), with ZRANGE's index semantics
|
|
6
|
+
// (negative indices count from the end, e.g. -1 is the last element).
|
|
7
|
+
class SortedIdList {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.entries = [];
|
|
10
|
+
}
|
|
11
|
+
add(id, score) {
|
|
12
|
+
this.remove(id);
|
|
13
|
+
this.entries.push({ id, score });
|
|
14
|
+
}
|
|
15
|
+
remove(id) {
|
|
16
|
+
this.entries = this.entries.filter((e) => e.id !== id);
|
|
17
|
+
}
|
|
18
|
+
sorted() {
|
|
19
|
+
return [...this.entries].sort((a, b) => a.score - b.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
20
|
+
}
|
|
21
|
+
range(min, max, rev) {
|
|
22
|
+
const ordered = rev ? this.sorted().reverse() : this.sorted();
|
|
23
|
+
const len = ordered.length;
|
|
24
|
+
const resolve = (i) => (i < 0 ? len + i : i);
|
|
25
|
+
const start = Math.max(resolve(min), 0);
|
|
26
|
+
const stop = Math.min(resolve(max), len - 1);
|
|
27
|
+
if (len === 0 || start > stop)
|
|
28
|
+
return [];
|
|
29
|
+
return ordered.slice(start, stop + 1).map((e) => e.id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function globToRegExp(pattern) {
|
|
33
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
34
|
+
return new RegExp(`^${escaped}$`);
|
|
35
|
+
}
|
|
36
|
+
// intersect() polyfills Set.prototype.intersection so this module has no load-order
|
|
37
|
+
// dependency on index.ts's IIFE that installs it globally.
|
|
38
|
+
function intersect(a, b) {
|
|
39
|
+
return new Set(Array.from(a).filter((value) => b.has(value)));
|
|
40
|
+
}
|
|
41
|
+
class MemoryStore {
|
|
42
|
+
constructor({ key, setKey, options, debug, seed, }) {
|
|
43
|
+
this.records = new Map();
|
|
44
|
+
this.index = new SortedIdList();
|
|
45
|
+
this.lookups = new Map();
|
|
46
|
+
this.counters = new Map();
|
|
47
|
+
this.key = key;
|
|
48
|
+
this.setKey = setKey || key + "s";
|
|
49
|
+
this.options = options;
|
|
50
|
+
this.debug = !!debug;
|
|
51
|
+
(seed || []).forEach((value) => this._index(value));
|
|
52
|
+
}
|
|
53
|
+
lookupKeys(value, options) {
|
|
54
|
+
options = { ...this.options, ...options };
|
|
55
|
+
this.debug && console.log(`MemoryStore<${this.key}>.lookupKeys`, { value, options });
|
|
56
|
+
const lookupKeys = !(options === null || options === void 0 ? void 0 : options.noLookup) && Object
|
|
57
|
+
.entries((options === null || options === void 0 ? void 0 : options.lookups) || {})
|
|
58
|
+
.map((entry) => {
|
|
59
|
+
const id = value.id;
|
|
60
|
+
const lookupName = entry[0];
|
|
61
|
+
const lookupKey = entry[1];
|
|
62
|
+
// @ts-ignore
|
|
63
|
+
const lookupId = value[lookupKey];
|
|
64
|
+
return [`${this.setKey}:${lookupName}:${lookupId}`, id];
|
|
65
|
+
}) || [];
|
|
66
|
+
this.debug && console.log(`MemoryStore<${this.key}>.lookupKeys`, { options, lookupKeys });
|
|
67
|
+
return lookupKeys;
|
|
68
|
+
}
|
|
69
|
+
// shared by the constructor's `seed` and create(): adds a record to the main
|
|
70
|
+
// index and lookup indices without re-deriving id/createdAt defaults.
|
|
71
|
+
_index(value, options) {
|
|
72
|
+
this.records.set(value.id, value);
|
|
73
|
+
!(options === null || options === void 0 ? void 0 : options.noIndex) && this.index.add(value.id, value.createdAt);
|
|
74
|
+
const lookupKeys = this.lookupKeys(value, options);
|
|
75
|
+
(lookupKeys || []).forEach(([lookupKey, id]) => {
|
|
76
|
+
if (!this.lookups.has(lookupKey))
|
|
77
|
+
this.lookups.set(lookupKey, new SortedIdList());
|
|
78
|
+
this.lookups.get(lookupKey).add(id, value.createdAt);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async exists(id) {
|
|
82
|
+
this.debug && console.log(`MemoryStore<${this.key}>.exists`, { id });
|
|
83
|
+
return this.records.has(id);
|
|
84
|
+
}
|
|
85
|
+
async get(id, options) {
|
|
86
|
+
this.debug && console.log(`MemoryStore<${this.key}>.get`, { id });
|
|
87
|
+
const value = this.records.get(id);
|
|
88
|
+
return value && !(value.deletedAt && !(options === null || options === void 0 ? void 0 : options.deleted)) ? value : undefined;
|
|
89
|
+
}
|
|
90
|
+
async scan(query = {}) {
|
|
91
|
+
var _a;
|
|
92
|
+
this.debug && console.log(`MemoryStore<${this.key}>.scan`, { query });
|
|
93
|
+
const count = query.count || 999;
|
|
94
|
+
const pattern = globToRegExp((_a = query.scan) !== null && _a !== void 0 ? _a : "*");
|
|
95
|
+
const keys = new Set();
|
|
96
|
+
for (const id of this.records.keys()) {
|
|
97
|
+
if (keys.size >= count)
|
|
98
|
+
break;
|
|
99
|
+
if (pattern.test(id))
|
|
100
|
+
keys.add(id);
|
|
101
|
+
}
|
|
102
|
+
this.debug && console.log(`MemoryStore<${this.key}>.scan`, { keys });
|
|
103
|
+
return keys;
|
|
104
|
+
}
|
|
105
|
+
async ids(query = {}) {
|
|
106
|
+
this.debug && console.log(`MemoryStore<${this.key}>.ids`, { query });
|
|
107
|
+
if (query.scan) {
|
|
108
|
+
return this.scan(query);
|
|
109
|
+
}
|
|
110
|
+
let count = query.count;
|
|
111
|
+
delete query.count;
|
|
112
|
+
if (typeof (count) != "undefined" && typeof (count) != "number") {
|
|
113
|
+
console.warn(`MemoryStore<${this.key}>.ids WARNING: query.count is not a number`);
|
|
114
|
+
count = undefined;
|
|
115
|
+
}
|
|
116
|
+
let offset = query.offset;
|
|
117
|
+
delete query.offset;
|
|
118
|
+
if (typeof (offset) != "undefined" && typeof (offset) != "number") {
|
|
119
|
+
console.warn(`MemoryStore<${this.key}>.ids WARNING: query.offset must be a number`);
|
|
120
|
+
offset = undefined;
|
|
121
|
+
}
|
|
122
|
+
const min = offset || 0;
|
|
123
|
+
const max = min + (count || 0) - 1;
|
|
124
|
+
const queryEntries = query && Object.entries(query);
|
|
125
|
+
if (!(queryEntries === null || queryEntries === void 0 ? void 0 : queryEntries.length)) {
|
|
126
|
+
return new Set(this.index.range(min, max, true));
|
|
127
|
+
}
|
|
128
|
+
const setOfIds = queryEntries.map(([queryKey, queryVal]) => {
|
|
129
|
+
var _a;
|
|
130
|
+
if (queryVal) {
|
|
131
|
+
const lookupKey = `${this.setKey}:${queryKey}:${queryVal}`;
|
|
132
|
+
return new Set(((_a = this.lookups.get(lookupKey)) === null || _a === void 0 ? void 0 : _a.range(min, max, true)) || []);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
throw `MemoryStore.ids(query) query must have key and value`;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
const ids = setOfIds.reduce((prev, curr) => intersect(curr, prev || curr), undefined) || new Set();
|
|
139
|
+
this.debug && console.log(`MemoryStore<${this.key}>.ids queried lookup key`, { query, setOfIds, ids });
|
|
140
|
+
return ids;
|
|
141
|
+
}
|
|
142
|
+
async find(query = {}) {
|
|
143
|
+
this.debug && console.log(`MemoryStore<${this.key}>.find`, { query });
|
|
144
|
+
const ids = Array.isArray(query.id)
|
|
145
|
+
? query.id.filter(Boolean)
|
|
146
|
+
: Array.from(await this.ids(query));
|
|
147
|
+
const values = ids
|
|
148
|
+
.map((id) => this.records.get(id))
|
|
149
|
+
.filter((value) => !!value && !value.deletedAt);
|
|
150
|
+
this.debug && console.log(`MemoryStore<${this.key}>.find`, { values });
|
|
151
|
+
return values;
|
|
152
|
+
}
|
|
153
|
+
async create(value, options) {
|
|
154
|
+
this.debug && console.log(`MemoryStore<${this.key}>.create`, { value, options, this_options: this.options });
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
options = { ...this.options, ...options };
|
|
157
|
+
const createdValue = {
|
|
158
|
+
id: value.id || (0, utils_1.uuid)(),
|
|
159
|
+
createdAt: value.createdAt || now,
|
|
160
|
+
...value,
|
|
161
|
+
};
|
|
162
|
+
this.debug && console.log(`MemoryStore<${this.key}>.create`, { createdValue });
|
|
163
|
+
this._index(createdValue, options);
|
|
164
|
+
(options === null || options === void 0 ? void 0 : options.expire) && this.debug && console.log(`MemoryStore<${this.key}>.create ignoring options.expire (memory store is ephemeral)`);
|
|
165
|
+
return createdValue;
|
|
166
|
+
}
|
|
167
|
+
async update(value, options) {
|
|
168
|
+
this.debug && console.log(`MemoryStore<${this.key}>.update`, { value, options });
|
|
169
|
+
if (!value.id) {
|
|
170
|
+
throw `Cannot update ${this.key}: null id`;
|
|
171
|
+
}
|
|
172
|
+
const prevValue = await this.get(value.id);
|
|
173
|
+
if (!prevValue) {
|
|
174
|
+
throw `Cannot update ${this.key}: does not exist: ${value.id}`;
|
|
175
|
+
}
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
options = { ...this.options, ...options };
|
|
178
|
+
const updatedValue = {
|
|
179
|
+
...value,
|
|
180
|
+
updatedAt: now,
|
|
181
|
+
};
|
|
182
|
+
// @ts-ignore
|
|
183
|
+
const prevLookupKeys = new Map(this.lookupKeys(prevValue, options));
|
|
184
|
+
// @ts-ignore
|
|
185
|
+
const lookupKeys = new Map(this.lookupKeys(updatedValue, options));
|
|
186
|
+
const lookupsToRemove = prevLookupKeys && Array.from(prevLookupKeys)
|
|
187
|
+
.filter(([k, v]) => !lookupKeys || lookupKeys.get(k) != v);
|
|
188
|
+
const lookupsToAdd = lookupKeys && Array.from(lookupKeys)
|
|
189
|
+
.filter(([k, v]) => !prevLookupKeys || prevLookupKeys.get(k) != v);
|
|
190
|
+
this.debug && console.log(`MemoryStore<${this.key}>.update`, { prevLookupKeys, lookupKeys, keysToRemove: lookupsToRemove, keysToAdd: lookupsToAdd });
|
|
191
|
+
lookupsToRemove.forEach(([lookupKey, id]) => { var _a; return (_a = this.lookups.get(lookupKey)) === null || _a === void 0 ? void 0 : _a.remove(id); });
|
|
192
|
+
this.records.set(value.id, updatedValue);
|
|
193
|
+
lookupsToAdd.forEach(([lookupKey, id]) => {
|
|
194
|
+
if (!this.lookups.has(lookupKey))
|
|
195
|
+
this.lookups.set(lookupKey, new SortedIdList());
|
|
196
|
+
this.lookups.get(lookupKey).add(id, updatedValue.createdAt || updatedValue.updatedAt);
|
|
197
|
+
});
|
|
198
|
+
return updatedValue;
|
|
199
|
+
}
|
|
200
|
+
async incCounters(values, delta) {
|
|
201
|
+
var _a;
|
|
202
|
+
this.debug && console.log(`MemoryStore<${this.key}>.incCounters`, { values, delta });
|
|
203
|
+
const counters = ((_a = this.options) === null || _a === void 0 ? void 0 : _a.counters) || [];
|
|
204
|
+
counters
|
|
205
|
+
.filter((counter) => !counter.split(":").every((d) => typeof values[d] != "undefined"))
|
|
206
|
+
.forEach((counter) => {
|
|
207
|
+
const missing = counter.split(":").filter((d) => typeof values[d] == "undefined");
|
|
208
|
+
console.warn(`MemoryStore<${this.key}>.incCounters WARNING: skipping counter "${counter}": missing dimension(s) ${missing.join(", ")} in values`, { values });
|
|
209
|
+
});
|
|
210
|
+
counters
|
|
211
|
+
.filter((counter) => counter.split(":").every((d) => typeof values[d] != "undefined"))
|
|
212
|
+
.forEach((counter) => {
|
|
213
|
+
const member = counter.split(":").map((d) => `${d}=${values[d]}`).join(":");
|
|
214
|
+
[
|
|
215
|
+
[`${this.key}Totals:${counter}`, delta.total],
|
|
216
|
+
[`${this.key}Counts:${counter}`, delta.count],
|
|
217
|
+
].forEach(([setKey, d]) => {
|
|
218
|
+
if (!this.counters.has(setKey))
|
|
219
|
+
this.counters.set(setKey, new Map());
|
|
220
|
+
const counterMap = this.counters.get(setKey);
|
|
221
|
+
counterMap.set(member, (counterMap.get(member) || 0) + d);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async queryCounter(kind, counter, exact, range) {
|
|
226
|
+
this.debug && console.log(`MemoryStore<${this.key}>.queryCounter`, { kind, counter, exact, range });
|
|
227
|
+
const dims = counter.split(":");
|
|
228
|
+
const setKey = `${this.key}${kind == "totals" ? "Totals" : "Counts"}:${counter}`;
|
|
229
|
+
const counterMap = this.counters.get(setKey) || new Map();
|
|
230
|
+
const prefix = dims
|
|
231
|
+
.filter((d) => typeof exact[d] != "undefined")
|
|
232
|
+
.map((d) => `${d}=${exact[d]}`)
|
|
233
|
+
.join(":");
|
|
234
|
+
const hasMore = dims.length > Object.keys(exact).length;
|
|
235
|
+
const minPrefix = (range === null || range === void 0 ? void 0 : range.min) != null ? `${prefix}${prefix ? ":" : ""}${range.field}=${range.min}` : prefix;
|
|
236
|
+
const maxPrefix = (range === null || range === void 0 ? void 0 : range.max) != null ? `${prefix}${prefix ? ":" : ""}${range.field}=${range.max}` : prefix;
|
|
237
|
+
// matches RedisStore's `[`-prefixed (always inclusive) ZRANGEBYLEX bounds, minus the
|
|
238
|
+
// Redis-specific bound-type marker -- comparisons below are plain string comparisons.
|
|
239
|
+
const min = `${minPrefix}${hasMore ? ":\x00" : ""}`;
|
|
240
|
+
const max = `${maxPrefix}${hasMore ? ":\x7f" : ""}`;
|
|
241
|
+
this.debug && console.log(`MemoryStore<${this.key}>.queryCounter`, { setKey, min, max });
|
|
242
|
+
// sorted lexicographically to mirror ZRANGEBYLEX's byte-order member comparison
|
|
243
|
+
const members = Array.from(counterMap.keys()).sort();
|
|
244
|
+
const filtered = members.filter((m) => m >= min && m <= max);
|
|
245
|
+
if (kind == "count") {
|
|
246
|
+
this.debug && console.log(`MemoryStore<${this.key}>.queryCounter`, { count: filtered.length });
|
|
247
|
+
return filtered.length;
|
|
248
|
+
}
|
|
249
|
+
const results = filtered.map((member) => ({ member, score: counterMap.get(member) }));
|
|
250
|
+
this.debug && console.log(`MemoryStore<${this.key}>.queryCounter`, { results });
|
|
251
|
+
return results;
|
|
252
|
+
}
|
|
253
|
+
async delete(id, options) {
|
|
254
|
+
this.debug && console.log(`MemoryStore<${this.key}>.delete`, { id, options });
|
|
255
|
+
if (!id) {
|
|
256
|
+
throw `Cannot delete ${this.key}: null id`;
|
|
257
|
+
}
|
|
258
|
+
options = { ...this.options, ...options };
|
|
259
|
+
const value = await this.get(id, { deleted: true });
|
|
260
|
+
if (!value) {
|
|
261
|
+
console.warn(`MemoryStore<${this.key}>.delete WARNING: does not exist: ${id}`);
|
|
262
|
+
}
|
|
263
|
+
const lookupKeys = value && this.lookupKeys(value, options);
|
|
264
|
+
this.debug && console.log(`MemoryStore<${this.key}>.delete`, { lookupKeys });
|
|
265
|
+
const deletedAt = Date.now();
|
|
266
|
+
if (value) {
|
|
267
|
+
if (options === null || options === void 0 ? void 0 : options.hardDelete) {
|
|
268
|
+
this.records.delete(id);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
this.records.set(id, { ...value, deletedAt });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
this.index.remove(id);
|
|
275
|
+
(lookupKeys || []).forEach(([lookupKey, lookupId]) => { var _a; return (_a = this.lookups.get(lookupKey)) === null || _a === void 0 ? void 0 : _a.remove(lookupId); });
|
|
276
|
+
return value ? { ...value, deletedAt } : undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
exports.default = MemoryStore;
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RedisStoreRecord } from "./index";
|
|
2
|
+
export interface Store<T extends RedisStoreRecord> {
|
|
3
|
+
key: string;
|
|
4
|
+
setKey: string;
|
|
5
|
+
options: any;
|
|
6
|
+
debug: boolean;
|
|
7
|
+
exists(id: string): Promise<boolean>;
|
|
8
|
+
get(id: string, options?: any): Promise<T | undefined>;
|
|
9
|
+
scan(query?: any): Promise<Set<string>>;
|
|
10
|
+
ids(query?: any): Promise<Set<string>>;
|
|
11
|
+
find(query?: any): Promise<T[]>;
|
|
12
|
+
create(value: any, options?: {
|
|
13
|
+
expire?: number;
|
|
14
|
+
noIndex?: boolean;
|
|
15
|
+
score?: number;
|
|
16
|
+
noLookup?: boolean;
|
|
17
|
+
lookups?: any;
|
|
18
|
+
}): Promise<T>;
|
|
19
|
+
update(value: any, options?: any): Promise<T>;
|
|
20
|
+
incCounters(values: Record<string, string | number>, delta: {
|
|
21
|
+
total: number;
|
|
22
|
+
count: number;
|
|
23
|
+
}): Promise<any>;
|
|
24
|
+
queryCounter(kind: "count" | "counts" | "totals", counter: string, exact: Record<string, string | number>, range?: {
|
|
25
|
+
field: string;
|
|
26
|
+
min?: string;
|
|
27
|
+
max?: string;
|
|
28
|
+
}): Promise<number | Array<{
|
|
29
|
+
member: string;
|
|
30
|
+
score: number;
|
|
31
|
+
}>>;
|
|
32
|
+
delete(id: string, options?: {
|
|
33
|
+
hardDelete?: boolean;
|
|
34
|
+
noLookup?: boolean;
|
|
35
|
+
lookups?: any;
|
|
36
|
+
}): Promise<T | undefined>;
|
|
37
|
+
}
|
package/dist/store.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desmat/redis-store",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
|
-
"
|
|
16
|
+
"prepare": "tsc",
|
|
17
|
+
"example": "ts-node --skipProject ./src/example.ts",
|
|
18
|
+
"test": "tsx --test test/**/*.test.ts"
|
|
17
19
|
},
|
|
18
20
|
"repository": {
|
|
19
21
|
"type": "git",
|
|
@@ -30,11 +32,12 @@
|
|
|
30
32
|
"license": "MIT",
|
|
31
33
|
"dependencies": {
|
|
32
34
|
"@desmat/utils": "^1.0.0",
|
|
33
|
-
"@upstash/redis": "^1.
|
|
35
|
+
"@upstash/redis": "^1.38.0",
|
|
34
36
|
"dotenv": "^16.4.5"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@types/node": "^22.8.4",
|
|
40
|
+
"tsx": "^4.21.0",
|
|
38
41
|
"typescript": "^4.0.0"
|
|
39
42
|
}
|
|
40
43
|
}
|