@managemint-solutions/sdk 0.35.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 CHANGED
@@ -416,29 +416,40 @@ that branch on one (the invoice-number retry on `23505`).
416
416
  ## Cache
417
417
 
418
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';
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';
421
422
 
422
- const cache = createTaggedCache({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });
423
+ const cache = createCache({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });
423
424
 
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)]);
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);
427
434
  ```
428
435
 
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
+ 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.
436
446
 
437
447
  The client takes its URL and token as config — the SDK reads no environment variables — and
438
448
  retries once with a three-second timeout per request. It **throws** on a failed call: what a
439
449
  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.
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.
442
453
 
443
454
  ## NestJS
444
455
 
@@ -1,41 +1,47 @@
1
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).
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
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.
5
+ * Two shapes, chosen so the data browser stays readable and nothing needs an index of its own:
8
6
  *
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`.
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.
11
12
  *
12
13
  * Like every other client in the SDK it takes its configuration as an argument and reads no
13
14
  * environment variables. It throws on a failed call; the callers decide what a failure means,
14
15
  * and both of them fail open.
15
16
  */
16
- export type TaggedCacheConfig = {
17
+ export type CacheConfig = {
17
18
  /** The database's REST URL, `https://<name>.upstash.io`. */
18
19
  url: string;
19
20
  /** The database's REST token. */
20
21
  token: string;
21
22
  };
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 {
23
+ /** The hash that holds an organization's cached member profiles. */
24
+ export declare const profileCacheKey: (organizationId: string) => string;
25
+ export declare class Cache {
29
26
  private readonly redis;
30
- constructor(config: TaggedCacheConfig);
27
+ constructor(config: CacheConfig);
31
28
  /**
32
- * The entry under `key`, or null when there is none. The value is JSON-parsed by the
29
+ * The string under `key`, or null when there is none. The value is JSON-parsed by the
33
30
  * client; `T` is the caller's promise about what was written, not a check.
34
31
  */
35
32
  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>;
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>;
40
46
  }
41
- export declare const createTaggedCache: (config: TaggedCacheConfig) => TaggedCache;
47
+ export declare const createCache: (config: CacheConfig) => Cache;
@@ -1,17 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createTaggedCache = exports.TaggedCache = void 0;
3
+ exports.createCache = exports.Cache = exports.profileCacheKey = void 0;
4
4
  const redis_1 = require("@upstash/redis");
5
+ const cache_1 = require("@managemint-solutions/entities/users/cache");
5
6
  /**
6
7
  * 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
+ * request path - a portal boot on the read side, a profile write on the delete side - so a
8
9
  * stalled Redis must cost the caller a bounded wait and nothing more.
9
10
  */
10
11
  const REQUEST_TIMEOUT_MS = 3_000;
11
12
  /** One retry, briefly: the callers fail open, so patience only lengthens the miss. */
12
13
  const RETRY = { retries: 1, backoff: () => 200 };
13
- const tagSetKey = (tag) => `tag:${tag}`;
14
- class TaggedCache {
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 {
15
18
  redis;
16
19
  constructor(config) {
17
20
  this.redis = new redis_1.Redis({
@@ -24,36 +27,40 @@ class TaggedCache {
24
27
  });
25
28
  }
26
29
  /**
27
- * The entry under `key`, or null when there is none. The value is JSON-parsed by the
30
+ * The string under `key`, or null when there is none. The value is JSON-parsed by the
28
31
  * client; `T` is the caller's promise about what was written, not a check.
29
32
  */
30
33
  get(key) {
31
34
  return this.redis.get(key);
32
35
  }
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();
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 });
42
39
  }
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]));
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);
55
62
  }
56
63
  }
57
- exports.TaggedCache = TaggedCache;
58
- const createTaggedCache = (config) => new TaggedCache(config);
59
- exports.createTaggedCache = createTaggedCache;
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.35.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>",
@@ -51,7 +51,7 @@
51
51
  "testEnvironment": "node"
52
52
  },
53
53
  "dependencies": {
54
- "@managemint-solutions/entities": "^1.17.0"
54
+ "@managemint-solutions/entities": "^1.20.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@nestjs/common": "^11.0.0",