@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 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 default class RedisStore<T extends RedisStoreRecord> {
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?: any): any[][];
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?: 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>;
30
42
  update(value: any, options?: any): Promise<T>;
31
- delete(id: string, options?: any): Promise<T | undefined>;
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 delete(id, options = {}) {
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;
@@ -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
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desmat/redis-store",
3
- "version": "1.2.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
- "example": "ts-node --skipProject ./src/example.ts"
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.34.3",
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
  }