@entifix/redis 0.1.0-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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/redis-lock-service.d.ts +17 -0
  3. package/dist/adapters/redis-lock-service.d.ts.map +1 -0
  4. package/dist/adapters/redis-lock-service.js +48 -0
  5. package/dist/adapters/redis-lock-service.js.map +1 -0
  6. package/dist/adapters/redis-one-time-token-store.d.ts +19 -0
  7. package/dist/adapters/redis-one-time-token-store.d.ts.map +1 -0
  8. package/dist/adapters/redis-one-time-token-store.js +48 -0
  9. package/dist/adapters/redis-one-time-token-store.js.map +1 -0
  10. package/dist/adapters/redis-sequence-service.d.ts +13 -0
  11. package/dist/adapters/redis-sequence-service.d.ts.map +1 -0
  12. package/dist/adapters/redis-sequence-service.js +20 -0
  13. package/dist/adapters/redis-sequence-service.js.map +1 -0
  14. package/dist/adapters/redis-session-store.d.ts +29 -0
  15. package/dist/adapters/redis-session-store.d.ts.map +1 -0
  16. package/dist/adapters/redis-session-store.js +154 -0
  17. package/dist/adapters/redis-session-store.js.map +1 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/redis-connection/redis-connection.d.ts +29 -0
  23. package/dist/redis-connection/redis-connection.d.ts.map +1 -0
  24. package/dist/redis-connection/redis-connection.js +43 -0
  25. package/dist/redis-connection/redis-connection.js.map +1 -0
  26. package/dist/redis-connection/redis-health-probe.d.ts +19 -0
  27. package/dist/redis-connection/redis-health-probe.d.ts.map +1 -0
  28. package/dist/redis-connection/redis-health-probe.js +27 -0
  29. package/dist/redis-connection/redis-health-probe.js.map +1 -0
  30. package/dist/transactions/index.d.ts +11 -0
  31. package/dist/transactions/index.d.ts.map +1 -0
  32. package/dist/transactions/index.js +11 -0
  33. package/dist/transactions/index.js.map +1 -0
  34. package/package.json +82 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 r10c Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ import { type LockService, LockServiceTag } from '@entifix/transactions';
2
+ import { Layer } from 'effect';
3
+ import type { Redis } from 'ioredis';
4
+ import { RedisTag } from '../redis-connection/redis-connection.js';
5
+ /** How long a held lock survives without renewal — bounds a crashed holder. */
6
+ export declare const LOCK_TTL_MS = 5000;
7
+ /** Bounded retry budget so contention fails fast rather than hanging. */
8
+ export declare const LOCK_RETRIES = 20;
9
+ export declare const LOCK_RETRY_DELAY_MS = 100;
10
+ /**
11
+ * Redis-backed {@link LockService}: `SET key token NX PX ttl` with bounded
12
+ * retry. The `NX` flag makes acquisition atomic across instances.
13
+ */
14
+ export declare const makeRedisLockService: (redis: Redis) => LockService;
15
+ /** Provides {@link LockServiceTag} from a {@link RedisTag} connection. */
16
+ export declare const RedisLockServiceLayer: Layer.Layer<LockServiceTag, never, RedisTag>;
17
+ //# sourceMappingURL=redis-lock-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-lock-service.d.ts","sourceRoot":"","sources":["../../src/adapters/redis-lock-service.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAAoB,KAAK,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,EAAE,QAAQ,EAAE,MAAM,yCAAyC,CAAC;AAEnE,+EAA+E;AAC/E,eAAO,MAAM,WAAW,OAAQ,CAAC;AACjC,yEAAyE;AACzE,eAAO,MAAM,YAAY,KAAK,CAAC;AAC/B,eAAO,MAAM,mBAAmB,MAAM,CAAC;AASvC;;;GAGG;AACH,eAAO,MAAM,oBAAoB,GAAI,OAAO,KAAK,KAAG,WA8BlD,CAAC;AAEH,0EAA0E;AAC1E,eAAO,MAAM,qBAAqB,8CAGjC,CAAC"}
@@ -0,0 +1,48 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { EntifixConnError, EntifixLockError } from '@entifix/core';
3
+ import { LockServiceTag } from '@entifix/transactions';
4
+ import { Duration, Effect, Layer } from 'effect';
5
+ import { RedisTag } from '../redis-connection/redis-connection.js';
6
+ /** How long a held lock survives without renewal — bounds a crashed holder. */ export const LOCK_TTL_MS = 5000;
7
+ /** Bounded retry budget so contention fails fast rather than hanging. */ export const LOCK_RETRIES = 20;
8
+ export const LOCK_RETRY_DELAY_MS = 100;
9
+ /**
10
+ * Compare-and-delete so a release only frees the caller's own lock — never one
11
+ * a later holder acquired after this one's TTL lapsed.
12
+ */ const RELEASE_SCRIPT = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
13
+ /**
14
+ * Redis-backed {@link LockService}: `SET key token NX PX ttl` with bounded
15
+ * retry. The `NX` flag makes acquisition atomic across instances.
16
+ */ export const makeRedisLockService = (redis)=>({
17
+ acquire: (key)=>Effect.gen(function*() {
18
+ const token = randomUUID();
19
+ for(let attempt = 0; attempt < LOCK_RETRIES; attempt += 1){
20
+ const result = yield* Effect.tryPromise({
21
+ try: ()=>redis.set(key, token, 'PX', LOCK_TTL_MS, 'NX'),
22
+ catch: (error)=>new EntifixLockError('Redis SET NX failed', error, {
23
+ key
24
+ })
25
+ });
26
+ if (result === 'OK') {
27
+ return {
28
+ key,
29
+ token
30
+ };
31
+ }
32
+ yield* Effect.sleep(Duration.millis(LOCK_RETRY_DELAY_MS));
33
+ }
34
+ return yield* Effect.fail(new EntifixLockError(`Could not acquire lock "${key}"`, undefined, {
35
+ key,
36
+ retries: LOCK_RETRIES
37
+ }));
38
+ }),
39
+ release: (handle)=>Effect.tryPromise({
40
+ try: ()=>redis.eval(RELEASE_SCRIPT, 1, handle.key, handle.token),
41
+ catch: (error)=>new EntifixConnError('Redis lock release failed', error, {
42
+ key: handle.key
43
+ })
44
+ }).pipe(Effect.asVoid)
45
+ });
46
+ /** Provides {@link LockServiceTag} from a {@link RedisTag} connection. */ export const RedisLockServiceLayer = Layer.effect(LockServiceTag, Effect.map(RedisTag, makeRedisLockService));
47
+
48
+ //# sourceMappingURL=redis-lock-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/redis-lock-service.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport { EntifixConnError, EntifixLockError } from '@entifix/core';\nimport { type LockService, LockServiceTag } from '@entifix/transactions';\nimport { Duration, Effect, Layer } from 'effect';\nimport type { Redis } from 'ioredis';\n\nimport { RedisTag } from '../redis-connection/redis-connection.js';\n\n/** How long a held lock survives without renewal — bounds a crashed holder. */\nexport const LOCK_TTL_MS = 5_000;\n/** Bounded retry budget so contention fails fast rather than hanging. */\nexport const LOCK_RETRIES = 20;\nexport const LOCK_RETRY_DELAY_MS = 100;\n\n/**\n * Compare-and-delete so a release only frees the caller's own lock — never one\n * a later holder acquired after this one's TTL lapsed.\n */\nconst RELEASE_SCRIPT =\n 'if redis.call(\"get\", KEYS[1]) == ARGV[1] then return redis.call(\"del\", KEYS[1]) else return 0 end';\n\n/**\n * Redis-backed {@link LockService}: `SET key token NX PX ttl` with bounded\n * retry. The `NX` flag makes acquisition atomic across instances.\n */\nexport const makeRedisLockService = (redis: Redis): LockService => ({\n acquire: key =>\n Effect.gen(function* () {\n const token = randomUUID();\n for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) {\n const result = yield* Effect.tryPromise({\n try: () => redis.set(key, token, 'PX', LOCK_TTL_MS, 'NX'),\n catch: error =>\n new EntifixLockError('Redis SET NX failed', error, { key }),\n });\n if (result === 'OK') {\n return { key, token };\n }\n yield* Effect.sleep(Duration.millis(LOCK_RETRY_DELAY_MS));\n }\n return yield* Effect.fail(\n new EntifixLockError(`Could not acquire lock \"${key}\"`, undefined, {\n key,\n retries: LOCK_RETRIES,\n }),\n );\n }),\n release: handle =>\n Effect.tryPromise({\n try: () => redis.eval(RELEASE_SCRIPT, 1, handle.key, handle.token),\n catch: error =>\n new EntifixConnError('Redis lock release failed', error, {\n key: handle.key,\n }),\n }).pipe(Effect.asVoid),\n});\n\n/** Provides {@link LockServiceTag} from a {@link RedisTag} connection. */\nexport const RedisLockServiceLayer = Layer.effect(\n LockServiceTag,\n Effect.map(RedisTag, makeRedisLockService),\n);\n"],"names":["randomUUID","EntifixConnError","EntifixLockError","LockServiceTag","Duration","Effect","Layer","RedisTag","LOCK_TTL_MS","LOCK_RETRIES","LOCK_RETRY_DELAY_MS","RELEASE_SCRIPT","makeRedisLockService","redis","acquire","key","gen","token","attempt","result","tryPromise","try","set","catch","error","sleep","millis","fail","undefined","retries","release","handle","eval","pipe","asVoid","RedisLockServiceLayer","effect","map"],"mappings":"AAAA,SAASA,UAAU,QAAQ,cAAc;AAEzC,SAASC,gBAAgB,EAAEC,gBAAgB,QAAQ,gBAAgB;AACnE,SAA2BC,cAAc,QAAQ,wBAAwB;AACzE,SAASC,QAAQ,EAAEC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGjD,SAASC,QAAQ,QAAQ,0CAA0C;AAEnE,6EAA6E,GAC7E,OAAO,MAAMC,cAAc,KAAM;AACjC,uEAAuE,GACvE,OAAO,MAAMC,eAAe,GAAG;AAC/B,OAAO,MAAMC,sBAAsB,IAAI;AAEvC;;;CAGC,GACD,MAAMC,iBACJ;AAEF;;;CAGC,GACD,OAAO,MAAMC,uBAAuB,CAACC,QAA+B,CAAA;QAClEC,SAASC,CAAAA,MACPV,OAAOW,GAAG,CAAC;gBACT,MAAMC,QAAQjB;gBACd,IAAK,IAAIkB,UAAU,GAAGA,UAAUT,cAAcS,WAAW,EAAG;oBAC1D,MAAMC,SAAS,OAAOd,OAAOe,UAAU,CAAC;wBACtCC,KAAK,IAAMR,MAAMS,GAAG,CAACP,KAAKE,OAAO,MAAMT,aAAa;wBACpDe,OAAOC,CAAAA,QACL,IAAItB,iBAAiB,uBAAuBsB,OAAO;gCAAET;4BAAI;oBAC7D;oBACA,IAAII,WAAW,MAAM;wBACnB,OAAO;4BAAEJ;4BAAKE;wBAAM;oBACtB;oBACA,OAAOZ,OAAOoB,KAAK,CAACrB,SAASsB,MAAM,CAAChB;gBACtC;gBACA,OAAO,OAAOL,OAAOsB,IAAI,CACvB,IAAIzB,iBAAiB,CAAC,wBAAwB,EAAEa,IAAI,CAAC,CAAC,EAAEa,WAAW;oBACjEb;oBACAc,SAASpB;gBACX;YAEJ;QACFqB,SAASC,CAAAA,SACP1B,OAAOe,UAAU,CAAC;gBAChBC,KAAK,IAAMR,MAAMmB,IAAI,CAACrB,gBAAgB,GAAGoB,OAAOhB,GAAG,EAAEgB,OAAOd,KAAK;gBACjEM,OAAOC,CAAAA,QACL,IAAIvB,iBAAiB,6BAA6BuB,OAAO;wBACvDT,KAAKgB,OAAOhB,GAAG;oBACjB;YACJ,GAAGkB,IAAI,CAAC5B,OAAO6B,MAAM;IACzB,CAAA,EAAG;AAEH,wEAAwE,GACxE,OAAO,MAAMC,wBAAwB7B,MAAM8B,MAAM,CAC/CjC,gBACAE,OAAOgC,GAAG,CAAC9B,UAAUK,uBACrB"}
@@ -0,0 +1,19 @@
1
+ import { type OneTimeTokenStore, OneTimeTokenStoreTag } from '@entifix/business';
2
+ import { Layer } from 'effect';
3
+ import type { Redis } from 'ioredis';
4
+ import { RedisTag } from '../redis-connection/redis-connection.js';
5
+ /** Default key prefix; every key this adapter writes lives under it. */
6
+ export declare const DEFAULT_TOKEN_NAMESPACE = "ott";
7
+ export interface RedisOneTimeTokenStoreOptions {
8
+ readonly namespace?: string;
9
+ }
10
+ /**
11
+ * Redis-backed {@link OneTimeTokenStore}. The subject is stored under
12
+ * `{ns}:{purpose}:{hash}` with the TTL enforced by Redis, and `consume` uses
13
+ * `GETDEL` so redemption is atomic — two requests racing the same link cannot
14
+ * both succeed.
15
+ */
16
+ export declare const makeRedisOneTimeTokenStore: (redis: Redis, options?: RedisOneTimeTokenStoreOptions) => OneTimeTokenStore;
17
+ /** Provides {@link OneTimeTokenStoreTag} from a {@link RedisTag} connection. */
18
+ export declare const RedisOneTimeTokenStoreLayer: (options?: RedisOneTimeTokenStoreOptions) => Layer.Layer<OneTimeTokenStoreTag, never, RedisTag>;
19
+ //# sourceMappingURL=redis-one-time-token-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-one-time-token-store.d.ts","sourceRoot":"","sources":["../../src/adapters/redis-one-time-token-store.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,iBAAiB,EACtB,oBAAoB,EAErB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,EAAE,QAAQ,EAAE,MAAM,yCAAyC,CAAC;AAEnE,wEAAwE;AACxE,eAAO,MAAM,uBAAuB,QAAQ,CAAC;AAE7C,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAeD;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GACrC,OAAO,KAAK,EACZ,UAAS,6BAAkC,KAC1C,iBAqCF,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,2BAA2B,GACtC,UAAS,6BAAkC,uDAK1C,CAAC"}
@@ -0,0 +1,48 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { OneTimeTokenStoreTag } from '@entifix/business';
3
+ import { EntifixConnError, EntifixLogicError } from '@entifix/core';
4
+ import { Effect, Layer } from 'effect';
5
+ import { RedisTag } from '../redis-connection/redis-connection.js';
6
+ /** Default key prefix; every key this adapter writes lives under it. */ export const DEFAULT_TOKEN_NAMESPACE = 'ott';
7
+ /** 256 bits of randomness — not guessable inside any realistic TTL. */ const mintToken = ()=>randomBytes(32).toString('base64url');
8
+ /**
9
+ * SHA-256 of the token.
10
+ *
11
+ * Fast hashing is right here and wrong for passwords: the input already has 256
12
+ * bits of entropy, so there is nothing to brute-force and no salt to add. What
13
+ * this buys is that a dump of Redis contains no redeemable token.
14
+ */ const digest = (token)=>createHash('sha256').update(token).digest('hex');
15
+ /**
16
+ * Redis-backed {@link OneTimeTokenStore}. The subject is stored under
17
+ * `{ns}:{purpose}:{hash}` with the TTL enforced by Redis, and `consume` uses
18
+ * `GETDEL` so redemption is atomic — two requests racing the same link cannot
19
+ * both succeed.
20
+ */ export const makeRedisOneTimeTokenStore = (redis, options = {})=>{
21
+ var _options_namespace;
22
+ const namespace = (_options_namespace = options.namespace) != null ? _options_namespace : DEFAULT_TOKEN_NAMESPACE;
23
+ const key = (purpose, token)=>`${namespace}:${purpose}:${digest(token)}`;
24
+ const attempt = (what, run)=>Effect.tryPromise({
25
+ try: run,
26
+ catch: (error)=>new EntifixConnError(what, error)
27
+ });
28
+ return {
29
+ issue: (purpose, subject, ttlSeconds)=>Effect.gen(function*() {
30
+ const token = mintToken();
31
+ yield* attempt('Redis token issue failed', ()=>redis.set(key(purpose, token), subject, 'EX', ttlSeconds));
32
+ return token;
33
+ }),
34
+ consume: (purpose, token)=>Effect.gen(function*() {
35
+ // Atomic read-and-delete: without it, two clicks on the same emailed
36
+ // link could each read the subject before either deleted it, and a
37
+ // single-use token would quietly be reusable.
38
+ const subject = yield* attempt('Redis token consume failed', ()=>redis.getdel(key(purpose, token)));
39
+ if (subject === null) {
40
+ return yield* Effect.fail(new EntifixLogicError('Token is unknown, expired or already used'));
41
+ }
42
+ return subject;
43
+ })
44
+ };
45
+ };
46
+ /** Provides {@link OneTimeTokenStoreTag} from a {@link RedisTag} connection. */ export const RedisOneTimeTokenStoreLayer = (options = {})=>Layer.effect(OneTimeTokenStoreTag, Effect.map(RedisTag, (redis)=>makeRedisOneTimeTokenStore(redis, options)));
47
+
48
+ //# sourceMappingURL=redis-one-time-token-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/redis-one-time-token-store.ts"],"sourcesContent":["import { createHash, randomBytes } from 'node:crypto';\n\nimport {\n type OneTimeTokenStore,\n OneTimeTokenStoreTag,\n type TokenPurpose,\n} from '@entifix/business';\nimport { EntifixConnError, EntifixLogicError } from '@entifix/core';\nimport { Effect, Layer } from 'effect';\nimport type { Redis } from 'ioredis';\n\nimport { RedisTag } from '../redis-connection/redis-connection.js';\n\n/** Default key prefix; every key this adapter writes lives under it. */\nexport const DEFAULT_TOKEN_NAMESPACE = 'ott';\n\nexport interface RedisOneTimeTokenStoreOptions {\n readonly namespace?: string;\n}\n\n/** 256 bits of randomness — not guessable inside any realistic TTL. */\nconst mintToken = (): string => randomBytes(32).toString('base64url');\n\n/**\n * SHA-256 of the token.\n *\n * Fast hashing is right here and wrong for passwords: the input already has 256\n * bits of entropy, so there is nothing to brute-force and no salt to add. What\n * this buys is that a dump of Redis contains no redeemable token.\n */\nconst digest = (token: string): string =>\n createHash('sha256').update(token).digest('hex');\n\n/**\n * Redis-backed {@link OneTimeTokenStore}. The subject is stored under\n * `{ns}:{purpose}:{hash}` with the TTL enforced by Redis, and `consume` uses\n * `GETDEL` so redemption is atomic — two requests racing the same link cannot\n * both succeed.\n */\nexport const makeRedisOneTimeTokenStore = (\n redis: Redis,\n options: RedisOneTimeTokenStoreOptions = {},\n): OneTimeTokenStore => {\n const namespace = options.namespace ?? DEFAULT_TOKEN_NAMESPACE;\n const key = (purpose: TokenPurpose, token: string): string =>\n `${namespace}:${purpose}:${digest(token)}`;\n\n const attempt = <T>(what: string, run: () => Promise<T>) =>\n Effect.tryPromise({\n try: run,\n catch: error => new EntifixConnError(what, error),\n });\n\n return {\n issue: (purpose: TokenPurpose, subject: string, ttlSeconds: number) =>\n Effect.gen(function* () {\n const token = mintToken();\n yield* attempt('Redis token issue failed', () =>\n redis.set(key(purpose, token), subject, 'EX', ttlSeconds),\n );\n return token;\n }),\n\n consume: (purpose: TokenPurpose, token: string) =>\n Effect.gen(function* () {\n // Atomic read-and-delete: without it, two clicks on the same emailed\n // link could each read the subject before either deleted it, and a\n // single-use token would quietly be reusable.\n const subject = yield* attempt('Redis token consume failed', () =>\n redis.getdel(key(purpose, token)),\n );\n if (subject === null) {\n return yield* Effect.fail(\n new EntifixLogicError('Token is unknown, expired or already used'),\n );\n }\n return subject;\n }),\n };\n};\n\n/** Provides {@link OneTimeTokenStoreTag} from a {@link RedisTag} connection. */\nexport const RedisOneTimeTokenStoreLayer = (\n options: RedisOneTimeTokenStoreOptions = {},\n) =>\n Layer.effect(\n OneTimeTokenStoreTag,\n Effect.map(RedisTag, redis => makeRedisOneTimeTokenStore(redis, options)),\n );\n"],"names":["createHash","randomBytes","OneTimeTokenStoreTag","EntifixConnError","EntifixLogicError","Effect","Layer","RedisTag","DEFAULT_TOKEN_NAMESPACE","mintToken","toString","digest","token","update","makeRedisOneTimeTokenStore","redis","options","namespace","key","purpose","attempt","what","run","tryPromise","try","catch","error","issue","subject","ttlSeconds","gen","set","consume","getdel","fail","RedisOneTimeTokenStoreLayer","effect","map"],"mappings":"AAAA,SAASA,UAAU,EAAEC,WAAW,QAAQ,cAAc;AAEtD,SAEEC,oBAAoB,QAEf,oBAAoB;AAC3B,SAASC,gBAAgB,EAAEC,iBAAiB,QAAQ,gBAAgB;AACpE,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGvC,SAASC,QAAQ,QAAQ,0CAA0C;AAEnE,sEAAsE,GACtE,OAAO,MAAMC,0BAA0B,MAAM;AAM7C,qEAAqE,GACrE,MAAMC,YAAY,IAAcR,YAAY,IAAIS,QAAQ,CAAC;AAEzD;;;;;;CAMC,GACD,MAAMC,SAAS,CAACC,QACdZ,WAAW,UAAUa,MAAM,CAACD,OAAOD,MAAM,CAAC;AAE5C;;;;;CAKC,GACD,OAAO,MAAMG,6BAA6B,CACxCC,OACAC,UAAyC,CAAC,CAAC;QAEzBA;IAAlB,MAAMC,aAAYD,qBAAAA,QAAQC,SAAS,YAAjBD,qBAAqBR;IACvC,MAAMU,MAAM,CAACC,SAAuBP,QAClC,GAAGK,UAAU,CAAC,EAAEE,QAAQ,CAAC,EAAER,OAAOC,QAAQ;IAE5C,MAAMQ,UAAU,CAAIC,MAAcC,MAChCjB,OAAOkB,UAAU,CAAC;YAChBC,KAAKF;YACLG,OAAOC,CAAAA,QAAS,IAAIvB,iBAAiBkB,MAAMK;QAC7C;IAEF,OAAO;QACLC,OAAO,CAACR,SAAuBS,SAAiBC,aAC9CxB,OAAOyB,GAAG,CAAC;gBACT,MAAMlB,QAAQH;gBACd,OAAOW,QAAQ,4BAA4B,IACzCL,MAAMgB,GAAG,CAACb,IAAIC,SAASP,QAAQgB,SAAS,MAAMC;gBAEhD,OAAOjB;YACT;QAEFoB,SAAS,CAACb,SAAuBP,QAC/BP,OAAOyB,GAAG,CAAC;gBACT,qEAAqE;gBACrE,mEAAmE;gBACnE,8CAA8C;gBAC9C,MAAMF,UAAU,OAAOR,QAAQ,8BAA8B,IAC3DL,MAAMkB,MAAM,CAACf,IAAIC,SAASP;gBAE5B,IAAIgB,YAAY,MAAM;oBACpB,OAAO,OAAOvB,OAAO6B,IAAI,CACvB,IAAI9B,kBAAkB;gBAE1B;gBACA,OAAOwB;YACT;IACJ;AACF,EAAE;AAEF,8EAA8E,GAC9E,OAAO,MAAMO,8BAA8B,CACzCnB,UAAyC,CAAC,CAAC,GAE3CV,MAAM8B,MAAM,CACVlC,sBACAG,OAAOgC,GAAG,CAAC9B,UAAUQ,CAAAA,QAASD,2BAA2BC,OAAOC,WAChE"}
@@ -0,0 +1,13 @@
1
+ import { type SequenceService, SequenceServiceTag } from '@entifix/transactions';
2
+ import { Layer } from 'effect';
3
+ import type { Redis } from 'ioredis';
4
+ import { RedisTag } from '../redis-connection/redis-connection.js';
5
+ /**
6
+ * Redis-backed {@link SequenceService}. `INCR` is atomic, so concurrent
7
+ * commands across every service instance draw strictly distinct values — the
8
+ * uniqueness guarantee behind incremental codes.
9
+ */
10
+ export declare const makeRedisSequenceService: (redis: Redis) => SequenceService;
11
+ /** Provides {@link SequenceServiceTag} from a {@link RedisTag} connection. */
12
+ export declare const RedisSequenceServiceLayer: Layer.Layer<SequenceServiceTag, never, RedisTag>;
13
+ //# sourceMappingURL=redis-sequence-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-sequence-service.d.ts","sourceRoot":"","sources":["../../src/adapters/redis-sequence-service.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,eAAe,EACpB,kBAAkB,EACnB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,EAAE,QAAQ,EAAE,MAAM,yCAAyC,CAAC;AAKnE;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,KAAK,KAAG,eAOtD,CAAC;AAEH,8EAA8E;AAC9E,eAAO,MAAM,yBAAyB,kDAGrC,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { EntifixConnError } from '@entifix/core';
2
+ import { SequenceServiceTag } from '@entifix/transactions';
3
+ import { Effect, Layer } from 'effect';
4
+ import { RedisTag } from '../redis-connection/redis-connection.js';
5
+ /** Namespace so sequence keys never collide with lock keys. */ const SEQ_PREFIX = 'seq:';
6
+ /**
7
+ * Redis-backed {@link SequenceService}. `INCR` is atomic, so concurrent
8
+ * commands across every service instance draw strictly distinct values — the
9
+ * uniqueness guarantee behind incremental codes.
10
+ */ export const makeRedisSequenceService = (redis)=>({
11
+ next: (name)=>Effect.tryPromise({
12
+ try: ()=>redis.incr(`${SEQ_PREFIX}${name}`),
13
+ catch: (error)=>new EntifixConnError('Redis INCR failed', error, {
14
+ name
15
+ })
16
+ })
17
+ });
18
+ /** Provides {@link SequenceServiceTag} from a {@link RedisTag} connection. */ export const RedisSequenceServiceLayer = Layer.effect(SequenceServiceTag, Effect.map(RedisTag, makeRedisSequenceService));
19
+
20
+ //# sourceMappingURL=redis-sequence-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/redis-sequence-service.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport {\n type SequenceService,\n SequenceServiceTag,\n} from '@entifix/transactions';\nimport { Effect, Layer } from 'effect';\nimport type { Redis } from 'ioredis';\n\nimport { RedisTag } from '../redis-connection/redis-connection.js';\n\n/** Namespace so sequence keys never collide with lock keys. */\nconst SEQ_PREFIX = 'seq:';\n\n/**\n * Redis-backed {@link SequenceService}. `INCR` is atomic, so concurrent\n * commands across every service instance draw strictly distinct values — the\n * uniqueness guarantee behind incremental codes.\n */\nexport const makeRedisSequenceService = (redis: Redis): SequenceService => ({\n next: name =>\n Effect.tryPromise({\n try: () => redis.incr(`${SEQ_PREFIX}${name}`),\n catch: error =>\n new EntifixConnError('Redis INCR failed', error, { name }),\n }),\n});\n\n/** Provides {@link SequenceServiceTag} from a {@link RedisTag} connection. */\nexport const RedisSequenceServiceLayer = Layer.effect(\n SequenceServiceTag,\n Effect.map(RedisTag, makeRedisSequenceService),\n);\n"],"names":["EntifixConnError","SequenceServiceTag","Effect","Layer","RedisTag","SEQ_PREFIX","makeRedisSequenceService","redis","next","name","tryPromise","try","incr","catch","error","RedisSequenceServiceLayer","effect","map"],"mappings":"AAAA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAEEC,kBAAkB,QACb,wBAAwB;AAC/B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGvC,SAASC,QAAQ,QAAQ,0CAA0C;AAEnE,6DAA6D,GAC7D,MAAMC,aAAa;AAEnB;;;;CAIC,GACD,OAAO,MAAMC,2BAA2B,CAACC,QAAmC,CAAA;QAC1EC,MAAMC,CAAAA,OACJP,OAAOQ,UAAU,CAAC;gBAChBC,KAAK,IAAMJ,MAAMK,IAAI,CAAC,GAAGP,aAAaI,MAAM;gBAC5CI,OAAOC,CAAAA,QACL,IAAId,iBAAiB,qBAAqBc,OAAO;wBAAEL;oBAAK;YAC5D;IACJ,CAAA,EAAG;AAEH,4EAA4E,GAC5E,OAAO,MAAMM,4BAA4BZ,MAAMa,MAAM,CACnDf,oBACAC,OAAOe,GAAG,CAACb,UAAUE,2BACrB"}
@@ -0,0 +1,29 @@
1
+ import { type SessionStore, SessionStoreTag } from '@entifix/business';
2
+ import { Layer } from 'effect';
3
+ import type { Redis } from 'ioredis';
4
+ import { RedisTag } from '../redis-connection/redis-connection.js';
5
+ /** Default key prefix; every key this adapter writes lives under it. */
6
+ export declare const DEFAULT_SESSION_NAMESPACE = "session";
7
+ export interface RedisSessionStoreOptions {
8
+ /** Key prefix, so several stores can share one Redis without colliding. */
9
+ readonly namespace?: string;
10
+ }
11
+ /**
12
+ * Redis-backed {@link SessionStore}. The session record is a JSON string under
13
+ * `{ns}:{sid}` with the TTL enforced by Redis (`EX`), so an abandoned session
14
+ * expires on its own; `touch` re-`EXPIRE`s it for a sliding window. A parallel
15
+ * `{ns}:user:{userId}` SET indexes a user's live session ids so every one can
16
+ * be revoked at once (password change, admin kick).
17
+ *
18
+ * The sliding window is bounded: `create` stamps an `absoluteExpiresAt` that
19
+ * `touch` clamps every renewal to, so the Redis TTL can only ever shrink toward
20
+ * that ceiling. This is what keeps "slides while you use it" from degrading
21
+ * into "never expires".
22
+ *
23
+ * Closures over the `redis` client give each method `R = never`, so the result
24
+ * is assignable to {@link SessionStoreTag} without leaking a dependency.
25
+ */
26
+ export declare const makeRedisSessionStore: (redis: Redis, options?: RedisSessionStoreOptions) => SessionStore;
27
+ /** Provides {@link SessionStoreTag} from a {@link RedisTag} connection. */
28
+ export declare const RedisSessionStoreLayer: (options?: RedisSessionStoreOptions) => Layer.Layer<SessionStoreTag, never, RedisTag>;
29
+ //# sourceMappingURL=redis-session-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-session-store.d.ts","sourceRoot":"","sources":["../../src/adapters/redis-session-store.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,YAAY,EACjB,eAAe,EAChB,MAAM,mBAAmB,CAAC;AAM3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,EAAE,QAAQ,EAAE,MAAM,yCAAyC,CAAC;AAEnE,wEAAwE;AACxE,eAAO,MAAM,yBAAyB,YAAY,CAAC;AAEnD,MAAM,WAAW,wBAAwB;IACvC,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAKD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,GAChC,OAAO,KAAK,EACZ,UAAS,wBAA6B,KACrC,YA6NF,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,sBAAsB,GACjC,UAAS,wBAA6B,kDAKrC,CAAC"}
@@ -0,0 +1,154 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { randomBytes } from 'node:crypto';
3
+ import { SessionStoreTag } from '@entifix/business';
4
+ import { EntifixConnError, EntifixLogicError } from '@entifix/core';
5
+ import { Effect, Layer } from 'effect';
6
+ import { RedisTag } from '../redis-connection/redis-connection.js';
7
+ /** Default key prefix; every key this adapter writes lives under it. */ export const DEFAULT_SESSION_NAMESPACE = 'session';
8
+ /** Mint an unguessable opaque session id (256 bits, url-safe). */ const mintSessionId = ()=>randomBytes(32).toString('base64url');
9
+ /**
10
+ * Redis-backed {@link SessionStore}. The session record is a JSON string under
11
+ * `{ns}:{sid}` with the TTL enforced by Redis (`EX`), so an abandoned session
12
+ * expires on its own; `touch` re-`EXPIRE`s it for a sliding window. A parallel
13
+ * `{ns}:user:{userId}` SET indexes a user's live session ids so every one can
14
+ * be revoked at once (password change, admin kick).
15
+ *
16
+ * The sliding window is bounded: `create` stamps an `absoluteExpiresAt` that
17
+ * `touch` clamps every renewal to, so the Redis TTL can only ever shrink toward
18
+ * that ceiling. This is what keeps "slides while you use it" from degrading
19
+ * into "never expires".
20
+ *
21
+ * Closures over the `redis` client give each method `R = never`, so the result
22
+ * is assignable to {@link SessionStoreTag} without leaking a dependency.
23
+ */ export const makeRedisSessionStore = (redis, options = {})=>{
24
+ var _options_namespace;
25
+ const namespace = (_options_namespace = options.namespace) != null ? _options_namespace : DEFAULT_SESSION_NAMESPACE;
26
+ const sessionKey = (sessionId)=>`${namespace}:${sessionId}`;
27
+ const userKey = (userId)=>`${namespace}:user:${String(userId)}`;
28
+ const attempt = (what, run)=>Effect.tryPromise({
29
+ try: run,
30
+ catch: (error)=>new EntifixConnError(what, error)
31
+ });
32
+ /**
33
+ * Read a record back, giving one written before `absoluteExpiresAt` existed a
34
+ * ceiling instead of leaving it undefined.
35
+ *
36
+ * Sessions outlive a deploy. Without this, the first `touch` of an existing
37
+ * session parsed `undefined` into `NaN`, sailed past the `<= 0` check, and
38
+ * asked Redis to `EX NaN` — so everyone signed in at deploy time got a `500`
39
+ * on their next refresh.
40
+ *
41
+ * The record's own `expiresAt` is the right fallback: under the previous
42
+ * policy it was stamped at creation and never moved, so it already *is* the
43
+ * bound that session was created with. Rewriting the record on touch means
44
+ * each one self-heals the first time it is renewed.
45
+ */ const parseRecord = (raw)=>{
46
+ var _record_absoluteExpiresAt;
47
+ const record = JSON.parse(raw);
48
+ return Number.isNaN(Date.parse((_record_absoluteExpiresAt = record.absoluteExpiresAt) != null ? _record_absoluteExpiresAt : '')) ? _extends({}, record, {
49
+ absoluteExpiresAt: record.expiresAt
50
+ }) : record;
51
+ };
52
+ return {
53
+ create: (data, lifetime)=>Effect.gen(function*() {
54
+ const sessionId = mintSessionId();
55
+ const createdAt = new Date();
56
+ // The first window never exceeds the ceiling either — a lifetime whose
57
+ // idle TTL is the longer of the two would otherwise outlive its own cap.
58
+ const ttlSeconds = Math.min(lifetime.idleTtlSeconds, lifetime.absoluteTtlSeconds);
59
+ const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1000);
60
+ const absoluteExpiresAt = new Date(createdAt.getTime() + lifetime.absoluteTtlSeconds * 1000);
61
+ const record = _extends({}, data, {
62
+ sessionId,
63
+ createdAt: createdAt.toISOString(),
64
+ expiresAt: expiresAt.toISOString(),
65
+ absoluteExpiresAt: absoluteExpiresAt.toISOString()
66
+ });
67
+ yield* attempt('Redis session create failed', ()=>redis.set(sessionKey(sessionId), JSON.stringify(record), 'EX', ttlSeconds));
68
+ yield* attempt('Redis session index failed', ()=>redis.sadd(userKey(data.userId), sessionId));
69
+ // The index outlives any single session so a later one is still
70
+ // revocable through it.
71
+ yield* attempt('Redis session index ttl failed', ()=>redis.expire(userKey(data.userId), lifetime.absoluteTtlSeconds));
72
+ return sessionId;
73
+ }),
74
+ read: (sessionId)=>Effect.gen(function*() {
75
+ const raw = yield* attempt('Redis session read failed', ()=>redis.get(sessionKey(sessionId)));
76
+ if (raw === null) {
77
+ return yield* Effect.fail(new EntifixLogicError('Session not found or expired', undefined, {
78
+ sessionId
79
+ }));
80
+ }
81
+ return parseRecord(raw);
82
+ }),
83
+ touch: (sessionId, idleTtlSeconds)=>Effect.gen(function*() {
84
+ const raw = yield* attempt('Redis session touch read failed', ()=>redis.get(sessionKey(sessionId)));
85
+ if (raw === null) {
86
+ return yield* Effect.fail(new EntifixLogicError('Session not found or expired', undefined, {
87
+ sessionId
88
+ }));
89
+ }
90
+ const record = parseRecord(raw);
91
+ const now = Date.now();
92
+ const secondsToCeiling = Math.floor((Date.parse(record.absoluteExpiresAt) - now) / 1000);
93
+ // Past the ceiling the session is over regardless of how active it has
94
+ // been. Dropping the key here rather than waiting for Redis keeps the
95
+ // user index from listing a session that can no longer be renewed.
96
+ if (secondsToCeiling <= 0) {
97
+ yield* attempt('Redis session capped delete failed', ()=>redis.del(sessionKey(sessionId)));
98
+ yield* attempt('Redis session index cleanup failed', ()=>redis.srem(userKey(record.userId), sessionId));
99
+ return yield* Effect.fail(new EntifixLogicError('Session reached its absolute lifetime', undefined, {
100
+ sessionId,
101
+ absoluteExpiresAt: record.absoluteExpiresAt
102
+ }));
103
+ }
104
+ const ttlSeconds = Math.min(idleTtlSeconds, secondsToCeiling);
105
+ const renewed = _extends({}, record, {
106
+ expiresAt: new Date(now + ttlSeconds * 1000).toISOString()
107
+ });
108
+ yield* attempt('Redis session touch failed', ()=>redis.set(sessionKey(sessionId), JSON.stringify(renewed), 'EX', ttlSeconds));
109
+ }),
110
+ listForUser: (userId)=>Effect.gen(function*() {
111
+ const sessionIds = yield* attempt('Redis session index scan failed', ()=>redis.smembers(userKey(userId)));
112
+ const records = [];
113
+ const stale = [];
114
+ for (const sessionId of sessionIds){
115
+ const raw = yield* attempt('Redis session list read failed', ()=>redis.get(sessionKey(sessionId)));
116
+ // The index has no way to learn that Redis expired a record on its
117
+ // own, so it accumulates ids pointing at nothing. Pruning them here
118
+ // keeps a session list from showing rows that are already gone.
119
+ if (raw === null) stale.push(sessionId);
120
+ else records.push(parseRecord(raw));
121
+ }
122
+ for (const sessionId of stale){
123
+ yield* attempt('Redis session index prune failed', ()=>redis.srem(userKey(userId), sessionId));
124
+ }
125
+ return records;
126
+ }),
127
+ revoke: (sessionId)=>Effect.gen(function*() {
128
+ const raw = yield* attempt('Redis session revoke read failed', ()=>redis.get(sessionKey(sessionId)));
129
+ yield* attempt('Redis session revoke failed', ()=>redis.del(sessionKey(sessionId)));
130
+ if (raw !== null) {
131
+ const record = parseRecord(raw);
132
+ yield* attempt('Redis session index cleanup failed', ()=>redis.srem(userKey(record.userId), sessionId));
133
+ }
134
+ }),
135
+ revokeAllForUser: (userId)=>Effect.gen(function*() {
136
+ const sessionIds = yield* attempt('Redis session index scan failed', ()=>redis.smembers(userKey(userId)));
137
+ for (const sessionId of sessionIds){
138
+ yield* attempt('Redis session bulk revoke failed', ()=>redis.del(sessionKey(sessionId)));
139
+ }
140
+ yield* attempt('Redis session index drop failed', ()=>redis.del(userKey(userId)));
141
+ }),
142
+ revokeAllForUserExcept: (userId, keepSessionId)=>Effect.gen(function*() {
143
+ const sessionIds = yield* attempt('Redis session index scan failed', ()=>redis.smembers(userKey(userId)));
144
+ for (const sessionId of sessionIds){
145
+ if (sessionId === keepSessionId) continue;
146
+ yield* attempt('Redis session bulk revoke failed', ()=>redis.del(sessionKey(sessionId)));
147
+ yield* attempt('Redis session index cleanup failed', ()=>redis.srem(userKey(userId), sessionId));
148
+ }
149
+ })
150
+ };
151
+ };
152
+ /** Provides {@link SessionStoreTag} from a {@link RedisTag} connection. */ export const RedisSessionStoreLayer = (options = {})=>Layer.effect(SessionStoreTag, Effect.map(RedisTag, (redis)=>makeRedisSessionStore(redis, options)));
153
+
154
+ //# sourceMappingURL=redis-session-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/redis-session-store.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\n\nimport {\n type SessionData,\n type SessionLifetimeSeconds,\n type SessionRecord,\n type SessionStore,\n SessionStoreTag,\n} from '@entifix/business';\nimport {\n EntifixConnError,\n EntifixLogicError,\n type EntityId,\n} from '@entifix/core';\nimport { Effect, Layer } from 'effect';\nimport type { Redis } from 'ioredis';\n\nimport { RedisTag } from '../redis-connection/redis-connection.js';\n\n/** Default key prefix; every key this adapter writes lives under it. */\nexport const DEFAULT_SESSION_NAMESPACE = 'session';\n\nexport interface RedisSessionStoreOptions {\n /** Key prefix, so several stores can share one Redis without colliding. */\n readonly namespace?: string;\n}\n\n/** Mint an unguessable opaque session id (256 bits, url-safe). */\nconst mintSessionId = (): string => randomBytes(32).toString('base64url');\n\n/**\n * Redis-backed {@link SessionStore}. The session record is a JSON string under\n * `{ns}:{sid}` with the TTL enforced by Redis (`EX`), so an abandoned session\n * expires on its own; `touch` re-`EXPIRE`s it for a sliding window. A parallel\n * `{ns}:user:{userId}` SET indexes a user's live session ids so every one can\n * be revoked at once (password change, admin kick).\n *\n * The sliding window is bounded: `create` stamps an `absoluteExpiresAt` that\n * `touch` clamps every renewal to, so the Redis TTL can only ever shrink toward\n * that ceiling. This is what keeps \"slides while you use it\" from degrading\n * into \"never expires\".\n *\n * Closures over the `redis` client give each method `R = never`, so the result\n * is assignable to {@link SessionStoreTag} without leaking a dependency.\n */\nexport const makeRedisSessionStore = (\n redis: Redis,\n options: RedisSessionStoreOptions = {},\n): SessionStore => {\n const namespace = options.namespace ?? DEFAULT_SESSION_NAMESPACE;\n const sessionKey = (sessionId: string): string => `${namespace}:${sessionId}`;\n const userKey = (userId: EntityId): string =>\n `${namespace}:user:${String(userId)}`;\n\n const attempt = <T>(what: string, run: () => Promise<T>) =>\n Effect.tryPromise({\n try: run,\n catch: error => new EntifixConnError(what, error),\n });\n\n /**\n * Read a record back, giving one written before `absoluteExpiresAt` existed a\n * ceiling instead of leaving it undefined.\n *\n * Sessions outlive a deploy. Without this, the first `touch` of an existing\n * session parsed `undefined` into `NaN`, sailed past the `<= 0` check, and\n * asked Redis to `EX NaN` — so everyone signed in at deploy time got a `500`\n * on their next refresh.\n *\n * The record's own `expiresAt` is the right fallback: under the previous\n * policy it was stamped at creation and never moved, so it already *is* the\n * bound that session was created with. Rewriting the record on touch means\n * each one self-heals the first time it is renewed.\n */\n const parseRecord = (raw: string): SessionRecord => {\n const record = JSON.parse(raw) as SessionRecord;\n return Number.isNaN(Date.parse(record.absoluteExpiresAt ?? ''))\n ? { ...record, absoluteExpiresAt: record.expiresAt }\n : record;\n };\n\n return {\n create: (data: SessionData, lifetime: SessionLifetimeSeconds) =>\n Effect.gen(function* () {\n const sessionId = mintSessionId();\n const createdAt = new Date();\n // The first window never exceeds the ceiling either — a lifetime whose\n // idle TTL is the longer of the two would otherwise outlive its own cap.\n const ttlSeconds = Math.min(\n lifetime.idleTtlSeconds,\n lifetime.absoluteTtlSeconds,\n );\n const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1000);\n const absoluteExpiresAt = new Date(\n createdAt.getTime() + lifetime.absoluteTtlSeconds * 1000,\n );\n const record: SessionRecord = {\n ...data,\n sessionId,\n createdAt: createdAt.toISOString(),\n expiresAt: expiresAt.toISOString(),\n absoluteExpiresAt: absoluteExpiresAt.toISOString(),\n };\n yield* attempt('Redis session create failed', () =>\n redis.set(\n sessionKey(sessionId),\n JSON.stringify(record),\n 'EX',\n ttlSeconds,\n ),\n );\n yield* attempt('Redis session index failed', () =>\n redis.sadd(userKey(data.userId), sessionId),\n );\n // The index outlives any single session so a later one is still\n // revocable through it.\n yield* attempt('Redis session index ttl failed', () =>\n redis.expire(userKey(data.userId), lifetime.absoluteTtlSeconds),\n );\n return sessionId;\n }),\n\n read: (sessionId: string) =>\n Effect.gen(function* () {\n const raw = yield* attempt('Redis session read failed', () =>\n redis.get(sessionKey(sessionId)),\n );\n if (raw === null) {\n return yield* Effect.fail(\n new EntifixLogicError('Session not found or expired', undefined, {\n sessionId,\n }),\n );\n }\n return parseRecord(raw);\n }),\n\n touch: (sessionId: string, idleTtlSeconds: number) =>\n Effect.gen(function* () {\n const raw = yield* attempt('Redis session touch read failed', () =>\n redis.get(sessionKey(sessionId)),\n );\n if (raw === null) {\n return yield* Effect.fail(\n new EntifixLogicError('Session not found or expired', undefined, {\n sessionId,\n }),\n );\n }\n\n const record = parseRecord(raw);\n const now = Date.now();\n const secondsToCeiling = Math.floor(\n (Date.parse(record.absoluteExpiresAt) - now) / 1000,\n );\n // Past the ceiling the session is over regardless of how active it has\n // been. Dropping the key here rather than waiting for Redis keeps the\n // user index from listing a session that can no longer be renewed.\n if (secondsToCeiling <= 0) {\n yield* attempt('Redis session capped delete failed', () =>\n redis.del(sessionKey(sessionId)),\n );\n yield* attempt('Redis session index cleanup failed', () =>\n redis.srem(userKey(record.userId), sessionId),\n );\n return yield* Effect.fail(\n new EntifixLogicError(\n 'Session reached its absolute lifetime',\n undefined,\n {\n sessionId,\n absoluteExpiresAt: record.absoluteExpiresAt,\n },\n ),\n );\n }\n\n const ttlSeconds = Math.min(idleTtlSeconds, secondsToCeiling);\n const renewed: SessionRecord = {\n ...record,\n expiresAt: new Date(now + ttlSeconds * 1000).toISOString(),\n };\n yield* attempt('Redis session touch failed', () =>\n redis.set(\n sessionKey(sessionId),\n JSON.stringify(renewed),\n 'EX',\n ttlSeconds,\n ),\n );\n }),\n\n listForUser: (userId: EntityId) =>\n Effect.gen(function* () {\n const sessionIds = yield* attempt(\n 'Redis session index scan failed',\n () => redis.smembers(userKey(userId)),\n );\n\n const records: SessionRecord[] = [];\n const stale: string[] = [];\n for (const sessionId of sessionIds) {\n const raw = yield* attempt('Redis session list read failed', () =>\n redis.get(sessionKey(sessionId)),\n );\n // The index has no way to learn that Redis expired a record on its\n // own, so it accumulates ids pointing at nothing. Pruning them here\n // keeps a session list from showing rows that are already gone.\n if (raw === null) stale.push(sessionId);\n else records.push(parseRecord(raw));\n }\n for (const sessionId of stale) {\n yield* attempt('Redis session index prune failed', () =>\n redis.srem(userKey(userId), sessionId),\n );\n }\n\n return records;\n }),\n\n revoke: (sessionId: string) =>\n Effect.gen(function* () {\n const raw = yield* attempt('Redis session revoke read failed', () =>\n redis.get(sessionKey(sessionId)),\n );\n yield* attempt('Redis session revoke failed', () =>\n redis.del(sessionKey(sessionId)),\n );\n if (raw !== null) {\n const record = parseRecord(raw);\n yield* attempt('Redis session index cleanup failed', () =>\n redis.srem(userKey(record.userId), sessionId),\n );\n }\n }),\n\n revokeAllForUser: (userId: EntityId) =>\n Effect.gen(function* () {\n const sessionIds = yield* attempt(\n 'Redis session index scan failed',\n () => redis.smembers(userKey(userId)),\n );\n for (const sessionId of sessionIds) {\n yield* attempt('Redis session bulk revoke failed', () =>\n redis.del(sessionKey(sessionId)),\n );\n }\n yield* attempt('Redis session index drop failed', () =>\n redis.del(userKey(userId)),\n );\n }),\n\n revokeAllForUserExcept: (userId: EntityId, keepSessionId: string) =>\n Effect.gen(function* () {\n const sessionIds = yield* attempt(\n 'Redis session index scan failed',\n () => redis.smembers(userKey(userId)),\n );\n for (const sessionId of sessionIds) {\n if (sessionId === keepSessionId) continue;\n yield* attempt('Redis session bulk revoke failed', () =>\n redis.del(sessionKey(sessionId)),\n );\n yield* attempt('Redis session index cleanup failed', () =>\n redis.srem(userKey(userId), sessionId),\n );\n }\n }),\n };\n};\n\n/** Provides {@link SessionStoreTag} from a {@link RedisTag} connection. */\nexport const RedisSessionStoreLayer = (\n options: RedisSessionStoreOptions = {},\n) =>\n Layer.effect(\n SessionStoreTag,\n Effect.map(RedisTag, redis => makeRedisSessionStore(redis, options)),\n );\n"],"names":["randomBytes","SessionStoreTag","EntifixConnError","EntifixLogicError","Effect","Layer","RedisTag","DEFAULT_SESSION_NAMESPACE","mintSessionId","toString","makeRedisSessionStore","redis","options","namespace","sessionKey","sessionId","userKey","userId","String","attempt","what","run","tryPromise","try","catch","error","parseRecord","raw","record","JSON","parse","Number","isNaN","Date","absoluteExpiresAt","expiresAt","create","data","lifetime","gen","createdAt","ttlSeconds","Math","min","idleTtlSeconds","absoluteTtlSeconds","getTime","toISOString","set","stringify","sadd","expire","read","get","fail","undefined","touch","now","secondsToCeiling","floor","del","srem","renewed","listForUser","sessionIds","smembers","records","stale","push","revoke","revokeAllForUser","revokeAllForUserExcept","keepSessionId","RedisSessionStoreLayer","effect","map"],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ export * from './adapters/redis-one-time-token-store.js';
2
+ export * from './adapters/redis-session-store.js';
3
+ export * from './redis-connection/redis-connection.js';
4
+ export * from './redis-connection/redis-health-probe.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,0CAA0C,CAAC;AACzD,cAAc,mCAAmC,CAAC;AAClD,cAAc,wCAAwC,CAAC;AACvD,cAAc,0CAA0C,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from './adapters/redis-one-time-token-store.js';
2
+ export * from './adapters/redis-session-store.js';
3
+ export * from './redis-connection/redis-connection.js';
4
+ export * from './redis-connection/redis-health-probe.js';
5
+
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './adapters/redis-one-time-token-store.js';\nexport * from './adapters/redis-session-store.js';\nexport * from './redis-connection/redis-connection.js';\nexport * from './redis-connection/redis-health-probe.js';\n"],"names":[],"mappings":"AAAA,cAAc,2CAA2C;AACzD,cAAc,oCAAoC;AAClD,cAAc,yCAAyC;AACvD,cAAc,2CAA2C"}
@@ -0,0 +1,29 @@
1
+ import { EntifixConnError } from '@entifix/core';
2
+ import { Context, Layer } from 'effect';
3
+ import { Redis } from 'ioredis';
4
+ declare const RedisTag_base: Context.TagClass<RedisTag, "RedisTag", Redis<"legacy">>;
5
+ /** DI tag carrying a connected ioredis client. */
6
+ export declare class RedisTag extends RedisTag_base {
7
+ }
8
+ export interface RedisSettings {
9
+ readonly uri: string;
10
+ }
11
+ /**
12
+ * A scoped {@link Layer} that opens an ioredis connection on acquire and quits
13
+ * it on release, mirroring `MongoDatabaseLayer`. `lazyConnect` + an explicit
14
+ * `connect()` surfaces a bad URI/credentials at boot instead of on first command.
15
+ *
16
+ * The reconnect policy is spelled out rather than left to defaults, because two
17
+ * defaults are actively wrong for a service with a readiness endpoint:
18
+ *
19
+ * - **`enableOfflineQueue: false`** — the default queues commands while the
20
+ * connection is down, so a health probe *hangs* instead of failing. Readiness
21
+ * has to answer fastest exactly when a datastore is gone.
22
+ * - **an explicit `retryStrategy`** — so a connection that drops (Redis
23
+ * restarted, pod rescheduled) is re-established for the lifetime of the
24
+ * process. Without it the client can settle into a terminal state and the
25
+ * service stays degraded until someone restarts it, even after Redis is back.
26
+ */
27
+ export declare const RedisLayer: (settings: RedisSettings) => Layer.Layer<RedisTag, EntifixConnError>;
28
+ export {};
29
+ //# sourceMappingURL=redis-connection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-connection.d.ts","sourceRoot":"","sources":["../../src/redis-connection/redis-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,OAAO,EAAU,KAAK,EAAY,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;;AAEhC,kDAAkD;AAClD,qBAAa,QAAS,SAAQ,aAA0C;CAAG;AAE3E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAYD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,UAAU,GACrB,UAAU,aAAa,KACtB,KAAK,CAAC,KAAK,CAAC,QAAQ,EAAE,gBAAgB,CAgCtC,CAAC"}
@@ -0,0 +1,43 @@
1
+ import { EntifixConnError } from '@entifix/core';
2
+ import { Context, Effect, Layer, Schedule } from 'effect';
3
+ import { Redis } from 'ioredis';
4
+ /** DI tag carrying a connected ioredis client. */ export class RedisTag extends Context.Tag('RedisTag')() {
5
+ }
6
+ /**
7
+ * How long the initial connect keeps trying before the service gives up.
8
+ *
9
+ * Boot order is not guaranteed: a service can start while Redis is still
10
+ * rolling out (`ensure-infra` brought the cluster up moments earlier), and
11
+ * dying on the first refused connection turns a five-second wait into a dead
12
+ * process that nothing restarts.
13
+ */ const CONNECT_RETRY_WINDOW = '30 seconds';
14
+ /**
15
+ * A scoped {@link Layer} that opens an ioredis connection on acquire and quits
16
+ * it on release, mirroring `MongoDatabaseLayer`. `lazyConnect` + an explicit
17
+ * `connect()` surfaces a bad URI/credentials at boot instead of on first command.
18
+ *
19
+ * The reconnect policy is spelled out rather than left to defaults, because two
20
+ * defaults are actively wrong for a service with a readiness endpoint:
21
+ *
22
+ * - **`enableOfflineQueue: false`** — the default queues commands while the
23
+ * connection is down, so a health probe *hangs* instead of failing. Readiness
24
+ * has to answer fastest exactly when a datastore is gone.
25
+ * - **an explicit `retryStrategy`** — so a connection that drops (Redis
26
+ * restarted, pod rescheduled) is re-established for the lifetime of the
27
+ * process. Without it the client can settle into a terminal state and the
28
+ * service stays degraded until someone restarts it, even after Redis is back.
29
+ */ export const RedisLayer = (settings)=>Layer.scoped(RedisTag, Effect.acquireRelease(Effect.tryPromise({
30
+ try: async ()=>{
31
+ const redis = new Redis(settings.uri, {
32
+ lazyConnect: true,
33
+ maxRetriesPerRequest: 1,
34
+ enableOfflineQueue: false,
35
+ retryStrategy: (times)=>Math.min(times * 200, 5000)
36
+ });
37
+ await redis.connect();
38
+ return redis;
39
+ },
40
+ catch: (error)=>new EntifixConnError('Failed to connect to Redis', error)
41
+ }).pipe(Effect.retry(Schedule.exponential('250 millis').pipe(Schedule.upTo(CONNECT_RETRY_WINDOW)))), (redis)=>Effect.promise(()=>redis.quit().then(()=>undefined).catch(()=>undefined))));
42
+
43
+ //# sourceMappingURL=redis-connection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/redis-connection/redis-connection.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport { Context, Effect, Layer, Schedule } from 'effect';\nimport { Redis } from 'ioredis';\n\n/** DI tag carrying a connected ioredis client. */\nexport class RedisTag extends Context.Tag('RedisTag')<RedisTag, Redis>() {}\n\nexport interface RedisSettings {\n readonly uri: string;\n}\n\n/**\n * How long the initial connect keeps trying before the service gives up.\n *\n * Boot order is not guaranteed: a service can start while Redis is still\n * rolling out (`ensure-infra` brought the cluster up moments earlier), and\n * dying on the first refused connection turns a five-second wait into a dead\n * process that nothing restarts.\n */\nconst CONNECT_RETRY_WINDOW = '30 seconds';\n\n/**\n * A scoped {@link Layer} that opens an ioredis connection on acquire and quits\n * it on release, mirroring `MongoDatabaseLayer`. `lazyConnect` + an explicit\n * `connect()` surfaces a bad URI/credentials at boot instead of on first command.\n *\n * The reconnect policy is spelled out rather than left to defaults, because two\n * defaults are actively wrong for a service with a readiness endpoint:\n *\n * - **`enableOfflineQueue: false`** — the default queues commands while the\n * connection is down, so a health probe *hangs* instead of failing. Readiness\n * has to answer fastest exactly when a datastore is gone.\n * - **an explicit `retryStrategy`** — so a connection that drops (Redis\n * restarted, pod rescheduled) is re-established for the lifetime of the\n * process. Without it the client can settle into a terminal state and the\n * service stays degraded until someone restarts it, even after Redis is back.\n */\nexport const RedisLayer = (\n settings: RedisSettings,\n): Layer.Layer<RedisTag, EntifixConnError> =>\n Layer.scoped(\n RedisTag,\n Effect.acquireRelease(\n Effect.tryPromise({\n try: async () => {\n const redis = new Redis(settings.uri, {\n lazyConnect: true,\n maxRetriesPerRequest: 1,\n enableOfflineQueue: false,\n retryStrategy: times => Math.min(times * 200, 5_000),\n });\n await redis.connect();\n return redis;\n },\n catch: error =>\n new EntifixConnError('Failed to connect to Redis', error),\n }).pipe(\n Effect.retry(\n Schedule.exponential('250 millis').pipe(\n Schedule.upTo(CONNECT_RETRY_WINDOW),\n ),\n ),\n ),\n redis =>\n Effect.promise(() =>\n redis\n .quit()\n .then(() => undefined)\n .catch(() => undefined),\n ),\n ),\n );\n"],"names":["EntifixConnError","Context","Effect","Layer","Schedule","Redis","RedisTag","Tag","CONNECT_RETRY_WINDOW","RedisLayer","settings","scoped","acquireRelease","tryPromise","try","redis","uri","lazyConnect","maxRetriesPerRequest","enableOfflineQueue","retryStrategy","times","Math","min","connect","catch","error","pipe","retry","exponential","upTo","promise","quit","then","undefined"],"mappings":"AAAA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,QAAQ,QAAQ,SAAS;AAC1D,SAASC,KAAK,QAAQ,UAAU;AAEhC,gDAAgD,GAChD,OAAO,MAAMC,iBAAiBL,QAAQM,GAAG,CAAC;AAAgC;AAM1E;;;;;;;CAOC,GACD,MAAMC,uBAAuB;AAE7B;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,aAAa,CACxBC,WAEAP,MAAMQ,MAAM,CACVL,UACAJ,OAAOU,cAAc,CACnBV,OAAOW,UAAU,CAAC;QAChBC,KAAK;YACH,MAAMC,QAAQ,IAAIV,MAAMK,SAASM,GAAG,EAAE;gBACpCC,aAAa;gBACbC,sBAAsB;gBACtBC,oBAAoB;gBACpBC,eAAeC,CAAAA,QAASC,KAAKC,GAAG,CAACF,QAAQ,KAAK;YAChD;YACA,MAAMN,MAAMS,OAAO;YACnB,OAAOT;QACT;QACAU,OAAOC,CAAAA,QACL,IAAI1B,iBAAiB,8BAA8B0B;IACvD,GAAGC,IAAI,CACLzB,OAAO0B,KAAK,CACVxB,SAASyB,WAAW,CAAC,cAAcF,IAAI,CACrCvB,SAAS0B,IAAI,CAACtB,0BAIpBO,CAAAA,QACEb,OAAO6B,OAAO,CAAC,IACbhB,MACGiB,IAAI,GACJC,IAAI,CAAC,IAAMC,WACXT,KAAK,CAAC,IAAMS,cAGrB"}
@@ -0,0 +1,19 @@
1
+ import { HealthRegistryTag } from '@entifix/business';
2
+ import { Layer } from 'effect';
3
+ import { RedisTag } from './redis-connection.js';
4
+ /** Probe name reported by `/api/health/ready` when Redis is unreachable. */
5
+ export declare const REDIS_PROBE_NAME = "redis";
6
+ /**
7
+ * Registers a readiness probe for the Redis connection: `PING`.
8
+ *
9
+ * Sessions live here, so a service that cannot reach Redis can still answer
10
+ * liveness (its process is fine) while readiness reports the truth — which is
11
+ * exactly the split Kubernetes acts on: drain traffic, do not restart.
12
+ *
13
+ * `stores` names the Stores this connection backs, by their register name in
14
+ * `tools/slices/` — `['session']`, `['saga-coordination']`. See
15
+ * {@link MongoHealthProbeLayer} for why the composition root supplies it
16
+ * (ADR 0031).
17
+ */
18
+ export declare const RedisHealthProbeLayer: (stores: readonly string[]) => Layer.Layer<never, never, RedisTag | HealthRegistryTag>;
19
+ //# sourceMappingURL=redis-health-probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-health-probe.d.ts","sourceRoot":"","sources":["../../src/redis-connection/redis-health-probe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEvC,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD,4EAA4E;AAC5E,eAAO,MAAM,gBAAgB,UAAU,CAAC;AAExC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GAChC,QAAQ,SAAS,MAAM,EAAE,KACxB,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,GAAG,iBAAiB,CAgBtD,CAAC"}
@@ -0,0 +1,27 @@
1
+ import { HealthRegistryTag } from '@entifix/business';
2
+ import { Effect, Layer } from 'effect';
3
+ import { RedisTag } from './redis-connection.js';
4
+ /** Probe name reported by `/api/health/ready` when Redis is unreachable. */ export const REDIS_PROBE_NAME = 'redis';
5
+ /**
6
+ * Registers a readiness probe for the Redis connection: `PING`.
7
+ *
8
+ * Sessions live here, so a service that cannot reach Redis can still answer
9
+ * liveness (its process is fine) while readiness reports the truth — which is
10
+ * exactly the split Kubernetes acts on: drain traffic, do not restart.
11
+ *
12
+ * `stores` names the Stores this connection backs, by their register name in
13
+ * `tools/slices/` — `['session']`, `['saga-coordination']`. See
14
+ * {@link MongoHealthProbeLayer} for why the composition root supplies it
15
+ * (ADR 0031).
16
+ */ export const RedisHealthProbeLayer = (stores)=>Layer.effectDiscard(Effect.gen(function*() {
17
+ const redis = yield* RedisTag;
18
+ const registry = yield* HealthRegistryTag;
19
+ yield* registry.register({
20
+ name: REDIS_PROBE_NAME,
21
+ kind: 'datastore',
22
+ targets: stores,
23
+ check: Effect.tryPromise(()=>redis.ping()).pipe(Effect.map((reply)=>reply === 'PONG'), Effect.catchAll(()=>Effect.succeed(false)))
24
+ });
25
+ }));
26
+
27
+ //# sourceMappingURL=redis-health-probe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/redis-connection/redis-health-probe.ts"],"sourcesContent":["import { HealthRegistryTag } from '@entifix/business';\nimport { Effect, Layer } from 'effect';\n\nimport { RedisTag } from './redis-connection.js';\n\n/** Probe name reported by `/api/health/ready` when Redis is unreachable. */\nexport const REDIS_PROBE_NAME = 'redis';\n\n/**\n * Registers a readiness probe for the Redis connection: `PING`.\n *\n * Sessions live here, so a service that cannot reach Redis can still answer\n * liveness (its process is fine) while readiness reports the truth — which is\n * exactly the split Kubernetes acts on: drain traffic, do not restart.\n *\n * `stores` names the Stores this connection backs, by their register name in\n * `tools/slices/` — `['session']`, `['saga-coordination']`. See\n * {@link MongoHealthProbeLayer} for why the composition root supplies it\n * (ADR 0031).\n */\nexport const RedisHealthProbeLayer = (\n stores: readonly string[],\n): Layer.Layer<never, never, RedisTag | HealthRegistryTag> =>\n Layer.effectDiscard(\n Effect.gen(function* () {\n const redis = yield* RedisTag;\n const registry = yield* HealthRegistryTag;\n\n yield* registry.register({\n name: REDIS_PROBE_NAME,\n kind: 'datastore',\n targets: stores,\n check: Effect.tryPromise(() => redis.ping()).pipe(\n Effect.map(reply => reply === 'PONG'),\n Effect.catchAll(() => Effect.succeed(false)),\n ),\n });\n }),\n );\n"],"names":["HealthRegistryTag","Effect","Layer","RedisTag","REDIS_PROBE_NAME","RedisHealthProbeLayer","stores","effectDiscard","gen","redis","registry","register","name","kind","targets","check","tryPromise","ping","pipe","map","reply","catchAll","succeed"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,oBAAoB;AACtD,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAEvC,SAASC,QAAQ,QAAQ,wBAAwB;AAEjD,0EAA0E,GAC1E,OAAO,MAAMC,mBAAmB,QAAQ;AAExC;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,wBAAwB,CACnCC,SAEAJ,MAAMK,aAAa,CACjBN,OAAOO,GAAG,CAAC;QACT,MAAMC,QAAQ,OAAON;QACrB,MAAMO,WAAW,OAAOV;QAExB,OAAOU,SAASC,QAAQ,CAAC;YACvBC,MAAMR;YACNS,MAAM;YACNC,SAASR;YACTS,OAAOd,OAAOe,UAAU,CAAC,IAAMP,MAAMQ,IAAI,IAAIC,IAAI,CAC/CjB,OAAOkB,GAAG,CAACC,CAAAA,QAASA,UAAU,SAC9BnB,OAAOoB,QAAQ,CAAC,IAAMpB,OAAOqB,OAAO,CAAC;QAEzC;IACF,IACA"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The half of this package that needs `@entifix/transactions`: the `LockService`
3
+ * and `SequenceService` implementations.
4
+ *
5
+ * Behind a subpath so a session store or a one-time-token store — neither of
6
+ * which knows anything about a saga — can be taken on its own. What keeps it
7
+ * optional is the `peerDependenciesMeta.optional` entry in the manifest.
8
+ */
9
+ export * from '../adapters/redis-lock-service.js';
10
+ export * from '../adapters/redis-sequence-service.js';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/transactions/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,cAAc,mCAAmC,CAAC;AAClD,cAAc,uCAAuC,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The half of this package that needs `@entifix/transactions`: the `LockService`
3
+ * and `SequenceService` implementations.
4
+ *
5
+ * Behind a subpath so a session store or a one-time-token store — neither of
6
+ * which knows anything about a saga — can be taken on its own. What keeps it
7
+ * optional is the `peerDependenciesMeta.optional` entry in the manifest.
8
+ */ export * from '../adapters/redis-lock-service.js';
9
+ export * from '../adapters/redis-sequence-service.js';
10
+
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/transactions/index.ts"],"sourcesContent":["/**\n * The half of this package that needs `@entifix/transactions`: the `LockService`\n * and `SequenceService` implementations.\n *\n * Behind a subpath so a session store or a one-time-token store — neither of\n * which knows anything about a saga — can be taken on its own. What keeps it\n * optional is the `peerDependenciesMeta.optional` entry in the manifest.\n */\nexport * from '../adapters/redis-lock-service.js';\nexport * from '../adapters/redis-sequence-service.js';\n"],"names":[],"mappings":"AAAA;;;;;;;CAOC,GACD,cAAc,oCAAoC;AAClD,cAAc,wCAAwC"}
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@entifix/redis",
3
+ "version": "0.1.0-0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/r10c-technologies/entifix.git",
8
+ "directory": "packages/ts/redis-client"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "main": "./dist/index.js",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ "./package.json": "./package.json",
19
+ ".": {
20
+ "@entifix/source": "./src/index.ts",
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./transactions": {
26
+ "@entifix/source": "./src/transactions/index.ts",
27
+ "types": "./dist/transactions/index.d.ts",
28
+ "import": "./dist/transactions/index.js",
29
+ "default": "./dist/transactions/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "!**/*.tsbuildinfo",
35
+ "LICENSE"
36
+ ],
37
+ "nx": {
38
+ "tags": [
39
+ "layer:entifix",
40
+ "scope:shared",
41
+ "entifix:client",
42
+ "runtime:datastore",
43
+ "tier:2"
44
+ ],
45
+ "sourceRoot": "packages/ts/redis-client/src",
46
+ "targets": {
47
+ "build": {
48
+ "executor": "@nx/js:swc",
49
+ "outputs": [
50
+ "{options.outputPath}"
51
+ ],
52
+ "options": {
53
+ "outputPath": "packages/ts/redis-client/dist",
54
+ "main": "packages/ts/redis-client/src/index.ts",
55
+ "tsConfig": "packages/ts/redis-client/tsconfig.lib.json",
56
+ "skipTypeCheck": true,
57
+ "stripLeadingPaths": true
58
+ }
59
+ }
60
+ }
61
+ },
62
+ "dependencies": {
63
+ "@swc/helpers": "0.5.23",
64
+ "ioredis": "6.0.0",
65
+ "@entifix/business": "0.1.0-0",
66
+ "@entifix/core": "0.1.0-0"
67
+ },
68
+ "devDependencies": {
69
+ "effect": "3.22.1",
70
+ "@entifix/transactions": "0.1.0-0",
71
+ "@entifix/testing-unit": "0.1.0-0"
72
+ },
73
+ "peerDependencies": {
74
+ "effect": ">=3.22 <4",
75
+ "@entifix/transactions": "0.1.0-0"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "@entifix/transactions": {
79
+ "optional": true
80
+ }
81
+ }
82
+ }