@managemint-solutions/sdk 0.34.0 → 0.36.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 +40 -1
- package/dist/cache/index.d.ts +47 -0
- package/dist/cache/index.js +66 -0
- package/package.json +11 -2
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,44 @@ 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 { createCache, profileCacheKey } from '@managemint-solutions/sdk/cache';
|
|
420
|
+
import { PROFILE_CACHE_TTL_SECONDS } from '@managemint-solutions/entities/users/cache';
|
|
421
|
+
import { FEATURE_FLAGS_CACHE_KEY } from '@managemint-solutions/entities/feature-flags';
|
|
422
|
+
|
|
423
|
+
const cache = createCache({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });
|
|
424
|
+
|
|
425
|
+
// Profiles: one hash per organization, one field per member.
|
|
426
|
+
await cache.setField(profileCacheKey(organizationId), mmsId, profile, PROFILE_CACHE_TTL_SECONDS);
|
|
427
|
+
const hit = await cache.getField<LoggedInUser>(profileCacheKey(organizationId), mmsId); // null on a miss
|
|
428
|
+
await cache.deleteField(profileCacheKey(organizationId), mmsId); // one member
|
|
429
|
+
await cache.delete(profileCacheKey(organizationId)); // every member
|
|
430
|
+
|
|
431
|
+
// Global values: plain strings with their own TTL.
|
|
432
|
+
await cache.set(FEATURE_FLAGS_CACHE_KEY, flags, 86_400);
|
|
433
|
+
const flags = await cache.get<FeatureFlags>(FEATURE_FLAGS_CACHE_KEY);
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
A small client on Upstash Redis (REST), shared by the portal, which reads and writes each
|
|
437
|
+
member's own `GET /users/me` and reads the feature flags, and the api, which writes the flags
|
|
438
|
+
and drops profile entries after a write. Two shapes, chosen so the data browser stays readable
|
|
439
|
+
and nothing needs an index of its own: a **string** per global value, and a **hash per
|
|
440
|
+
organization** for the profiles, so dropping a member is one field delete and dropping an
|
|
441
|
+
organization is one key delete. `setField` is one pipeline (`HSET`, then `EXPIRE … NX`): the
|
|
442
|
+
hash's TTL is set once, when it is first written, so an organization's whole cache re-reads
|
|
443
|
+
once a day however busy it is. Every other call is one round trip. Values are JSON-serialised
|
|
444
|
+
by the client; `get<T>` / `getField<T>` are the caller's promise about what was written, not a
|
|
445
|
+
check.
|
|
446
|
+
|
|
447
|
+
The client takes its URL and token as config — the SDK reads no environment variables — and
|
|
448
|
+
retries once with a three-second timeout per request. It **throws** on a failed call: what a
|
|
449
|
+
failure means is the caller's decision, and both callers today fail open (a miss on the portal,
|
|
450
|
+
a Sentry report on the api). The key prefix and TTL for the profile hash live in
|
|
451
|
+
`@managemint-solutions/entities/users/cache` and the flags key in `entities/feature-flags`;
|
|
452
|
+
`profileCacheKey` here is the one builder both sides use.
|
|
453
|
+
|
|
415
454
|
## NestJS
|
|
416
455
|
|
|
417
456
|
```ts
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Upstash Redis cache shared by the portal (which reads and writes profile entries and
|
|
3
|
+
* reads the feature flags) and the api (which writes the flags and deletes profile entries).
|
|
4
|
+
*
|
|
5
|
+
* Two shapes, chosen so the data browser stays readable and nothing needs an index of its own:
|
|
6
|
+
*
|
|
7
|
+
* - **A string per global value** - the feature flags - with its own TTL.
|
|
8
|
+
* - **A hash per organization** for the member profiles: one field per member, so dropping a
|
|
9
|
+
* member is one field delete and dropping an organization is one key delete. The hash's
|
|
10
|
+
* TTL is set once, when it is first written, so the whole organization re-reads once a day
|
|
11
|
+
* however busy it is.
|
|
12
|
+
*
|
|
13
|
+
* Like every other client in the SDK it takes its configuration as an argument and reads no
|
|
14
|
+
* environment variables. It throws on a failed call; the callers decide what a failure means,
|
|
15
|
+
* and both of them fail open.
|
|
16
|
+
*/
|
|
17
|
+
export type CacheConfig = {
|
|
18
|
+
/** The database's REST URL, `https://<name>.upstash.io`. */
|
|
19
|
+
url: string;
|
|
20
|
+
/** The database's REST token. */
|
|
21
|
+
token: string;
|
|
22
|
+
};
|
|
23
|
+
/** The hash that holds an organization's cached member profiles. */
|
|
24
|
+
export declare const profileCacheKey: (organizationId: string) => string;
|
|
25
|
+
export declare class Cache {
|
|
26
|
+
private readonly redis;
|
|
27
|
+
constructor(config: CacheConfig);
|
|
28
|
+
/**
|
|
29
|
+
* The string under `key`, or null when there is none. The value is JSON-parsed by the
|
|
30
|
+
* client; `T` is the caller's promise about what was written, not a check.
|
|
31
|
+
*/
|
|
32
|
+
get<T>(key: string): Promise<T | null>;
|
|
33
|
+
/** Writes `value` under `key` for `ttl` seconds. One round trip. */
|
|
34
|
+
set(key: string, value: unknown, ttl: number): Promise<void>;
|
|
35
|
+
/** Deletes `key` - a string or a whole hash. One round trip. */
|
|
36
|
+
delete(key: string): Promise<void>;
|
|
37
|
+
/** One field of the hash under `key`, or null when either is missing. */
|
|
38
|
+
getField<T>(key: string, field: string): Promise<T | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Writes one field of the hash under `key`, and gives the hash `ttl` seconds to live if it
|
|
41
|
+
* has no expiry yet - a hash written to every day still dies once a day. One round trip.
|
|
42
|
+
*/
|
|
43
|
+
setField(key: string, field: string, value: unknown, ttl: number): Promise<void>;
|
|
44
|
+
/** Deletes one field of the hash under `key`; a missing field or hash is a no-op. One round trip. */
|
|
45
|
+
deleteField(key: string, field: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export declare const createCache: (config: CacheConfig) => Cache;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCache = exports.Cache = exports.profileCacheKey = void 0;
|
|
4
|
+
const redis_1 = require("@upstash/redis");
|
|
5
|
+
const cache_1 = require("@managemint-solutions/entities/users/cache");
|
|
6
|
+
/**
|
|
7
|
+
* How long one Upstash call may take before it is abandoned. Every call here sits on the
|
|
8
|
+
* request path - a portal boot on the read side, a profile write on the delete side - so a
|
|
9
|
+
* stalled Redis must cost the caller a bounded wait and nothing more.
|
|
10
|
+
*/
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
12
|
+
/** One retry, briefly: the callers fail open, so patience only lengthens the miss. */
|
|
13
|
+
const RETRY = { retries: 1, backoff: () => 200 };
|
|
14
|
+
/** The hash that holds an organization's cached member profiles. */
|
|
15
|
+
const profileCacheKey = (organizationId) => `${cache_1.PROFILE_CACHE_KEY_PREFIX}${organizationId}`;
|
|
16
|
+
exports.profileCacheKey = profileCacheKey;
|
|
17
|
+
class Cache {
|
|
18
|
+
redis;
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.redis = new redis_1.Redis({
|
|
21
|
+
url: config.url,
|
|
22
|
+
token: config.token,
|
|
23
|
+
retry: RETRY,
|
|
24
|
+
// A function, so each request gets a fresh signal; one shared signal would be dead for
|
|
25
|
+
// every call after the first timeout.
|
|
26
|
+
signal: () => AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The string under `key`, or null when there is none. The value is JSON-parsed by the
|
|
31
|
+
* client; `T` is the caller's promise about what was written, not a check.
|
|
32
|
+
*/
|
|
33
|
+
get(key) {
|
|
34
|
+
return this.redis.get(key);
|
|
35
|
+
}
|
|
36
|
+
/** Writes `value` under `key` for `ttl` seconds. One round trip. */
|
|
37
|
+
async set(key, value, ttl) {
|
|
38
|
+
await this.redis.set(key, value, { ex: ttl });
|
|
39
|
+
}
|
|
40
|
+
/** Deletes `key` - a string or a whole hash. One round trip. */
|
|
41
|
+
async delete(key) {
|
|
42
|
+
await this.redis.del(key);
|
|
43
|
+
}
|
|
44
|
+
/** One field of the hash under `key`, or null when either is missing. */
|
|
45
|
+
getField(key, field) {
|
|
46
|
+
return this.redis.hget(key, field);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Writes one field of the hash under `key`, and gives the hash `ttl` seconds to live if it
|
|
50
|
+
* has no expiry yet - a hash written to every day still dies once a day. One round trip.
|
|
51
|
+
*/
|
|
52
|
+
async setField(key, field, value, ttl) {
|
|
53
|
+
await this.redis
|
|
54
|
+
.pipeline()
|
|
55
|
+
.hset(key, { [field]: value })
|
|
56
|
+
.expire(key, ttl, 'NX')
|
|
57
|
+
.exec();
|
|
58
|
+
}
|
|
59
|
+
/** Deletes one field of the hash under `key`; a missing field or hash is a no-op. One round trip. */
|
|
60
|
+
async deleteField(key, field) {
|
|
61
|
+
await this.redis.hdel(key, field);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.Cache = Cache;
|
|
65
|
+
const createCache = (config) => new Cache(config);
|
|
66
|
+
exports.createCache = createCache;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@managemint-solutions/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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": [
|
|
@@ -47,11 +51,12 @@
|
|
|
47
51
|
"testEnvironment": "node"
|
|
48
52
|
},
|
|
49
53
|
"dependencies": {
|
|
50
|
-
"@managemint-solutions/entities": "^1.
|
|
54
|
+
"@managemint-solutions/entities": "^1.20.0"
|
|
51
55
|
},
|
|
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",
|