@managemint-solutions/sdk 0.34.0 → 0.35.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
@@ -21,7 +21,8 @@ are peer dependencies — the request DTOs (`AddStatusDto`, `UpdateStatusDto`, `
21
21
  `ResetPasswordDto`, `CreateConfigDto`, `UpdateConfigDto`) ship with their class-validator
22
22
  decorators so consumers validate against
23
23
  the same rules the SDK's input types describe. `@nestjs/common` (^11) is an optional peer
24
- dependency, needed only for the `@managemint-solutions/sdk/nest` subpath.
24
+ dependency, needed only for the `@managemint-solutions/sdk/nest` subpath, and `@upstash/redis`
25
+ (^1.38) is an optional peer needed only for the `@managemint-solutions/sdk/cache` subpath.
25
26
 
26
27
  ## Usage
27
28
 
@@ -412,6 +413,33 @@ missing JWT is `401 Authentication Error`. Consumers return these as-is instead
412
413
  database errors themselves, and `error.code` keeps the raw Postgres code for the few callers
413
414
  that branch on one (the invoice-number retry on `23505`).
414
415
 
416
+ ## Cache
417
+
418
+ ```ts
419
+ import { createTaggedCache } from '@managemint-solutions/sdk/cache';
420
+ import { userMeCacheKey, userCacheTag, USER_ME_CACHE_TTL_SECONDS } from '@managemint-solutions/entities/users/cache';
421
+
422
+ const cache = createTaggedCache({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });
423
+
424
+ await cache.set(userMeCacheKey(mmsId), profile, { ttl: USER_ME_CACHE_TTL_SECONDS, tags: [userCacheTag(mmsId)] });
425
+ const hit = await cache.get<LoggedInUser>(userMeCacheKey(mmsId)); // null on a miss
426
+ await cache.invalidateTag([userCacheTag(mmsId)]);
427
+ ```
428
+
429
+ A tag-aware key-value cache on Upstash Redis (REST), shared by the portal, which reads and
430
+ writes each member's own `GET /users/me`, and the api, which only expires it after a write.
431
+ Redis has no tags, so each tag is a set of the keys written under it, stored as `tag:<tag>`
432
+ and expiring with its newest member: `set` is one pipeline (`SET … EX ttl`, then `SADD` and
433
+ `EXPIRE` per tag), `invalidateTag` is two round trips (`SMEMBERS` per tag, then one `DEL` of the
434
+ members and the sets). Values are JSON-serialised by the client; `get<T>` is the caller's
435
+ promise about what was written, not a check.
436
+
437
+ The client takes its URL and token as config — the SDK reads no environment variables — and
438
+ retries once with a three-second timeout per request. It **throws** on a failed call: what a
439
+ failure means is the caller's decision, and both callers today fail open (a miss on the portal,
440
+ a Sentry report on the api). The key, tag and TTL contract for the profile entry lives in
441
+ `@managemint-solutions/entities/users/cache`, so the tagging is one edit for both sides.
442
+
415
443
  ## NestJS
416
444
 
417
445
  ```ts
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A tag-aware key-value cache on Upstash Redis, shared by the portal (which reads and writes
3
+ * profile entries) and the api (which only expires them).
4
+ *
5
+ * Redis has no tags of its own, so each tag is a set of the keys written under it, stored as
6
+ * `tag:<tag>`. Expiring a tag deletes every key in the set and the set itself. The sets expire
7
+ * with the entries they hold, so an entry that simply ages out leaves nothing behind.
8
+ *
9
+ * Deliberately narrow: three calls and no knowledge of what is cached. The key, tag and TTL
10
+ * contract for the one thing cached today lives in `@managemint-solutions/entities/users/cache`.
11
+ *
12
+ * Like every other client in the SDK it takes its configuration as an argument and reads no
13
+ * environment variables. It throws on a failed call; the callers decide what a failure means,
14
+ * and both of them fail open.
15
+ */
16
+ export type TaggedCacheConfig = {
17
+ /** The database's REST URL, `https://<name>.upstash.io`. */
18
+ url: string;
19
+ /** The database's REST token. */
20
+ token: string;
21
+ };
22
+ export type TaggedCacheSetOptions = {
23
+ /** Seconds until the entry expires on its own. */
24
+ ttl: number;
25
+ /** The tags a later `invalidateTag` may drop the entry under. */
26
+ tags?: string[];
27
+ };
28
+ export declare class TaggedCache {
29
+ private readonly redis;
30
+ constructor(config: TaggedCacheConfig);
31
+ /**
32
+ * The entry under `key`, or null when there is none. The value is JSON-parsed by the
33
+ * client; `T` is the caller's promise about what was written, not a check.
34
+ */
35
+ get<T>(key: string): Promise<T | null>;
36
+ /** Writes `value` under `key` for `ttl` seconds, and files the key under each tag. One round trip. */
37
+ set(key: string, value: unknown, { ttl, tags }: TaggedCacheSetOptions): Promise<void>;
38
+ /** Deletes every entry filed under any of `tags`, and the tag sets themselves. Two round trips. */
39
+ invalidateTag(tags: string[]): Promise<void>;
40
+ }
41
+ export declare const createTaggedCache: (config: TaggedCacheConfig) => TaggedCache;
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createTaggedCache = exports.TaggedCache = void 0;
4
+ const redis_1 = require("@upstash/redis");
5
+ /**
6
+ * How long one Upstash call may take before it is abandoned. Every call here sits on the
7
+ * request path - a portal boot on the read side, a profile write on the expiry side - so a
8
+ * stalled Redis must cost the caller a bounded wait and nothing more.
9
+ */
10
+ const REQUEST_TIMEOUT_MS = 3_000;
11
+ /** One retry, briefly: the callers fail open, so patience only lengthens the miss. */
12
+ const RETRY = { retries: 1, backoff: () => 200 };
13
+ const tagSetKey = (tag) => `tag:${tag}`;
14
+ class TaggedCache {
15
+ redis;
16
+ constructor(config) {
17
+ this.redis = new redis_1.Redis({
18
+ url: config.url,
19
+ token: config.token,
20
+ retry: RETRY,
21
+ // A function, so each request gets a fresh signal; one shared signal would be dead for
22
+ // every call after the first timeout.
23
+ signal: () => AbortSignal.timeout(REQUEST_TIMEOUT_MS),
24
+ });
25
+ }
26
+ /**
27
+ * The entry under `key`, or null when there is none. The value is JSON-parsed by the
28
+ * client; `T` is the caller's promise about what was written, not a check.
29
+ */
30
+ get(key) {
31
+ return this.redis.get(key);
32
+ }
33
+ /** Writes `value` under `key` for `ttl` seconds, and files the key under each tag. One round trip. */
34
+ async set(key, value, { ttl, tags = [] }) {
35
+ const pipeline = this.redis.pipeline().set(key, value, { ex: ttl });
36
+ for (const tag of tags) {
37
+ // The set lives at least as long as its newest member, so a member can never outlive
38
+ // the set that would be used to find it.
39
+ pipeline.sadd(tagSetKey(tag), key).expire(tagSetKey(tag), ttl);
40
+ }
41
+ await pipeline.exec();
42
+ }
43
+ /** Deletes every entry filed under any of `tags`, and the tag sets themselves. Two round trips. */
44
+ async invalidateTag(tags) {
45
+ if (tags.length === 0) {
46
+ return;
47
+ }
48
+ const sets = tags.map(tagSetKey);
49
+ const read = this.redis.pipeline();
50
+ for (const set of sets) {
51
+ read.smembers(set);
52
+ }
53
+ const members = (await read.exec()).flat();
54
+ await this.redis.del(...new Set([...members, ...sets]));
55
+ }
56
+ }
57
+ exports.TaggedCache = TaggedCache;
58
+ const createTaggedCache = (config) => new TaggedCache(config);
59
+ exports.createTaggedCache = createTaggedCache;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@managemint-solutions/sdk",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Typed Supabase data-access SDK for ManageMint Solutions",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Scott Bebington <scottbebington@gmail.com>",
@@ -20,6 +20,10 @@
20
20
  "./nest": {
21
21
  "types": "./dist/nest/index.d.ts",
22
22
  "default": "./dist/nest/index.js"
23
+ },
24
+ "./cache": {
25
+ "types": "./dist/cache/index.d.ts",
26
+ "default": "./dist/cache/index.js"
23
27
  }
24
28
  },
25
29
  "files": [
@@ -52,6 +56,7 @@
52
56
  "peerDependencies": {
53
57
  "@nestjs/common": "^11.0.0",
54
58
  "@supabase/supabase-js": "^2.103.0",
59
+ "@upstash/redis": "^1.38.0",
55
60
  "class-transformer": "^0.5.1",
56
61
  "class-validator": "^0.15.0",
57
62
  "reflect-metadata": "^0.2.0"
@@ -59,6 +64,9 @@
59
64
  "peerDependenciesMeta": {
60
65
  "@nestjs/common": {
61
66
  "optional": true
67
+ },
68
+ "@upstash/redis": {
69
+ "optional": true
62
70
  }
63
71
  },
64
72
  "devDependencies": {
@@ -67,6 +75,7 @@
67
75
  "@types/express": "^5.0.0",
68
76
  "@types/jest": "^30.0.0",
69
77
  "@types/node": "^22.10.7",
78
+ "@upstash/redis": "^1.38.4",
70
79
  "class-transformer": "^0.5.1",
71
80
  "class-validator": "^0.15.1",
72
81
  "jest": "^30.0.0",