@entifix/redis 0.9.0 → 0.10.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.
@@ -1 +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"}
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;AAGrC,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,WAqBlD,CAAC;AAEH,0EAA0E;AAC1E,eAAO,MAAM,qBAAqB,8CAGjC,CAAC"}
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { EntifixConnError, EntifixLockError } from '@entifix/core';
2
+ import { Contention } from '@entifix/core';
3
3
  import { LockServiceTag } from '@entifix/transactions';
4
4
  import { Duration, Effect, Layer } from 'effect';
5
+ import { translateRedisError } from '../errors/translate-redis-error.js';
5
6
  import { RedisTag } from '../redis-connection/redis-connection.js';
6
7
  /** How long a held lock survives without renewal — bounds a crashed holder. */ export const LOCK_TTL_MS = 5000;
7
8
  /** Bounded retry budget so contention fails fast rather than hanging. */ export const LOCK_RETRIES = 20;
@@ -19,9 +20,7 @@ export const LOCK_RETRY_DELAY_MS = 100;
19
20
  for(let attempt = 0; attempt < LOCK_RETRIES; attempt += 1){
20
21
  const result = yield* Effect.tryPromise({
21
22
  try: ()=>redis.set(key, token, 'PX', LOCK_TTL_MS, 'NX'),
22
- catch: (error)=>new EntifixLockError('Redis SET NX failed', error, {
23
- key
24
- })
23
+ catch: (error)=>translateRedisError(error)
25
24
  });
26
25
  if (result === 'OK') {
27
26
  return {
@@ -31,16 +30,11 @@ export const LOCK_RETRY_DELAY_MS = 100;
31
30
  }
32
31
  yield* Effect.sleep(Duration.millis(LOCK_RETRY_DELAY_MS));
33
32
  }
34
- return yield* Effect.fail(new EntifixLockError(`Could not acquire lock "${key}"`, undefined, {
35
- key,
36
- retries: LOCK_RETRIES
37
- }));
33
+ return yield* Effect.fail(new Contention());
38
34
  }),
39
35
  release: (handle)=>Effect.tryPromise({
40
36
  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
- })
37
+ catch: (error)=>translateRedisError(error)
44
38
  }).pipe(Effect.asVoid)
45
39
  });
46
40
  /** Provides {@link LockServiceTag} from a {@link RedisTag} connection. */ export const RedisLockServiceLayer = Layer.effect(LockServiceTag, Effect.map(RedisTag, makeRedisLockService));
@@ -1 +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"}
1
+ {"version":3,"sources":["../../src/adapters/redis-lock-service.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport { Contention } from '@entifix/core';\nimport { type LockService, LockServiceTag } from '@entifix/transactions';\nimport { Duration, Effect, Layer } from 'effect';\nimport type { Redis } from 'ioredis';\n\nimport { translateRedisError } from '../errors/translate-redis-error.js';\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 => translateRedisError(error),\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(new Contention());\n }),\n release: handle =>\n Effect.tryPromise({\n try: () => redis.eval(RELEASE_SCRIPT, 1, handle.key, handle.token),\n catch: error => translateRedisError(error),\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","Contention","LockServiceTag","Duration","Effect","Layer","translateRedisError","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","release","handle","eval","pipe","asVoid","RedisLockServiceLayer","effect","map"],"mappings":"AAAA,SAASA,UAAU,QAAQ,cAAc;AAEzC,SAASC,UAAU,QAAQ,gBAAgB;AAC3C,SAA2BC,cAAc,QAAQ,wBAAwB;AACzE,SAASC,QAAQ,EAAEC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGjD,SAASC,mBAAmB,QAAQ,qCAAqC;AACzE,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,MACPX,OAAOY,GAAG,CAAC;gBACT,MAAMC,QAAQjB;gBACd,IAAK,IAAIkB,UAAU,GAAGA,UAAUT,cAAcS,WAAW,EAAG;oBAC1D,MAAMC,SAAS,OAAOf,OAAOgB,UAAU,CAAC;wBACtCC,KAAK,IAAMR,MAAMS,GAAG,CAACP,KAAKE,OAAO,MAAMT,aAAa;wBACpDe,OAAOC,CAAAA,QAASlB,oBAAoBkB;oBACtC;oBACA,IAAIL,WAAW,MAAM;wBACnB,OAAO;4BAAEJ;4BAAKE;wBAAM;oBACtB;oBACA,OAAOb,OAAOqB,KAAK,CAACtB,SAASuB,MAAM,CAAChB;gBACtC;gBACA,OAAO,OAAON,OAAOuB,IAAI,CAAC,IAAI1B;YAChC;QACF2B,SAASC,CAAAA,SACPzB,OAAOgB,UAAU,CAAC;gBAChBC,KAAK,IAAMR,MAAMiB,IAAI,CAACnB,gBAAgB,GAAGkB,OAAOd,GAAG,EAAEc,OAAOZ,KAAK;gBACjEM,OAAOC,CAAAA,QAASlB,oBAAoBkB;YACtC,GAAGO,IAAI,CAAC3B,OAAO4B,MAAM;IACzB,CAAA,EAAG;AAEH,wEAAwE,GACxE,OAAO,MAAMC,wBAAwB5B,MAAM6B,MAAM,CAC/ChC,gBACAE,OAAO+B,GAAG,CAAC5B,UAAUK,uBACrB"}
@@ -1 +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"}
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;AAGrC,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,iBAmCF,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,2BAA2B,GACtC,UAAS,6BAAkC,uDAK1C,CAAC"}
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { OneTimeTokenStoreTag } from '@entifix/business';
3
- import { EntifixConnError, EntifixLogicError } from '@entifix/core';
3
+ import { NotFound } from '@entifix/core';
4
4
  import { Effect, Layer } from 'effect';
5
+ import { translateRedisError } from '../errors/translate-redis-error.js';
5
6
  import { RedisTag } from '../redis-connection/redis-connection.js';
6
7
  /** Default key prefix; every key this adapter writes lives under it. */ export const DEFAULT_TOKEN_NAMESPACE = 'ott';
7
8
  /** 256 bits of randomness — not guessable inside any realistic TTL. */ const mintToken = ()=>randomBytes(32).toString('base64url');
@@ -23,7 +24,7 @@ import { RedisTag } from '../redis-connection/redis-connection.js';
23
24
  const key = (purpose, token)=>`${namespace}:${purpose}:${digest(token)}`;
24
25
  const attempt = (what, run)=>Effect.tryPromise({
25
26
  try: run,
26
- catch: (error)=>new EntifixConnError(what, error)
27
+ catch: (error)=>translateRedisError(error)
27
28
  });
28
29
  return {
29
30
  issue: (purpose, subject, ttlSeconds)=>Effect.gen(function*() {
@@ -37,7 +38,9 @@ import { RedisTag } from '../redis-connection/redis-connection.js';
37
38
  // single-use token would quietly be reusable.
38
39
  const subject = yield* attempt('Redis token consume failed', ()=>redis.getdel(key(purpose, token)));
39
40
  if (subject === null) {
40
- return yield* Effect.fail(new EntifixLogicError('Token is unknown, expired or already used'));
41
+ return yield* Effect.fail(new NotFound({
42
+ entity: 'one-time-token'
43
+ }));
41
44
  }
42
45
  return subject;
43
46
  })
@@ -1 +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"}
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 { NotFound } from '@entifix/core';\nimport { Effect, Layer } from 'effect';\nimport type { Redis } from 'ioredis';\n\nimport { translateRedisError } from '../errors/translate-redis-error.js';\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 => translateRedisError(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(new NotFound({ entity: 'one-time-token' }));\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","NotFound","Effect","Layer","translateRedisError","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","entity","RedisOneTimeTokenStoreLayer","effect","map"],"mappings":"AAAA,SAASA,UAAU,EAAEC,WAAW,QAAQ,cAAc;AAEtD,SAEEC,oBAAoB,QAEf,oBAAoB;AAC3B,SAASC,QAAQ,QAAQ,gBAAgB;AACzC,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGvC,SAASC,mBAAmB,QAAQ,qCAAqC;AACzE,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,MAChClB,OAAOmB,UAAU,CAAC;YAChBC,KAAKF;YACLG,OAAOC,CAAAA,QAASpB,oBAAoBoB;QACtC;IAEF,OAAO;QACLC,OAAO,CAACR,SAAuBS,SAAiBC,aAC9CzB,OAAO0B,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/BR,OAAO0B,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,OAAOxB,OAAO8B,IAAI,CAAC,IAAI/B,SAAS;wBAAEgC,QAAQ;oBAAiB;gBACpE;gBACA,OAAOP;YACT;IACJ;AACF,EAAE;AAEF,8EAA8E,GAC9E,OAAO,MAAMQ,8BAA8B,CACzCpB,UAAyC,CAAC,CAAC,GAE3CX,MAAMgC,MAAM,CACVnC,sBACAE,OAAOkC,GAAG,CAAC/B,UAAUQ,CAAAA,QAASD,2BAA2BC,OAAOC,WAChE"}
@@ -1 +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"}
1
+ {"version":3,"file":"redis-sequence-service.d.ts","sourceRoot":"","sources":["../../src/adapters/redis-sequence-service.ts"],"names":[],"mappings":"AAAA,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;AAGrC,OAAO,EAAE,QAAQ,EAAE,MAAM,yCAAyC,CAAC;AAKnE;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,KAAK,KAAG,eAMtD,CAAC;AAEH,8EAA8E;AAC9E,eAAO,MAAM,yBAAyB,kDAGrC,CAAC"}
@@ -1,6 +1,6 @@
1
- import { EntifixConnError } from '@entifix/core';
2
1
  import { SequenceServiceTag } from '@entifix/transactions';
3
2
  import { Effect, Layer } from 'effect';
3
+ import { translateRedisError } from '../errors/translate-redis-error.js';
4
4
  import { RedisTag } from '../redis-connection/redis-connection.js';
5
5
  /** Namespace so sequence keys never collide with lock keys. */ const SEQ_PREFIX = 'seq:';
6
6
  /**
@@ -10,9 +10,7 @@ import { RedisTag } from '../redis-connection/redis-connection.js';
10
10
  */ export const makeRedisSequenceService = (redis)=>({
11
11
  next: (name)=>Effect.tryPromise({
12
12
  try: ()=>redis.incr(`${SEQ_PREFIX}${name}`),
13
- catch: (error)=>new EntifixConnError('Redis INCR failed', error, {
14
- name
15
- })
13
+ catch: (error)=>translateRedisError(error)
16
14
  })
17
15
  });
18
16
  /** Provides {@link SequenceServiceTag} from a {@link RedisTag} connection. */ export const RedisSequenceServiceLayer = Layer.effect(SequenceServiceTag, Effect.map(RedisTag, makeRedisSequenceService));
@@ -1 +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"}
1
+ {"version":3,"sources":["../../src/adapters/redis-sequence-service.ts"],"sourcesContent":["import {\n type SequenceService,\n SequenceServiceTag,\n} from '@entifix/transactions';\nimport { Effect, Layer } from 'effect';\nimport type { Redis } from 'ioredis';\n\nimport { translateRedisError } from '../errors/translate-redis-error.js';\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 => translateRedisError(error),\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":["SequenceServiceTag","Effect","Layer","translateRedisError","RedisTag","SEQ_PREFIX","makeRedisSequenceService","redis","next","name","tryPromise","try","incr","catch","error","RedisSequenceServiceLayer","effect","map"],"mappings":"AAAA,SAEEA,kBAAkB,QACb,wBAAwB;AAC/B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGvC,SAASC,mBAAmB,QAAQ,qCAAqC;AACzE,SAASC,QAAQ,QAAQ,0CAA0C;AAEnE,6DAA6D,GAC7D,MAAMC,aAAa;AAEnB;;;;CAIC,GACD,OAAO,MAAMC,2BAA2B,CAACC,QAAmC,CAAA;QAC1EC,MAAMC,CAAAA,OACJR,OAAOS,UAAU,CAAC;gBAChBC,KAAK,IAAMJ,MAAMK,IAAI,CAAC,GAAGP,aAAaI,MAAM;gBAC5CI,OAAOC,CAAAA,QAASX,oBAAoBW;YACtC;IACJ,CAAA,EAAG;AAEH,4EAA4E,GAC5E,OAAO,MAAMC,4BAA4Bb,MAAMc,MAAM,CACnDhB,oBACAC,OAAOgB,GAAG,CAACb,UAAUE,2BACrB"}
@@ -1 +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"}
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;AAE3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAGrC,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,YA4MF,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,sBAAsB,GACjC,UAAS,wBAA6B,kDAKrC,CAAC"}
@@ -1,8 +1,9 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { SessionStoreTag } from '@entifix/business';
4
- import { EntifixConnError, EntifixLogicError } from '@entifix/core';
4
+ import { NotFound } from '@entifix/core';
5
5
  import { Effect, Layer } from 'effect';
6
+ import { translateRedisError } from '../errors/translate-redis-error.js';
6
7
  import { RedisTag } from '../redis-connection/redis-connection.js';
7
8
  /** Default key prefix; every key this adapter writes lives under it. */ export const DEFAULT_SESSION_NAMESPACE = 'session';
8
9
  /** Mint an unguessable opaque session id (256 bits, url-safe). */ const mintSessionId = ()=>randomBytes(32).toString('base64url');
@@ -27,7 +28,7 @@ import { RedisTag } from '../redis-connection/redis-connection.js';
27
28
  const userKey = (userId)=>`${namespace}:user:${String(userId)}`;
28
29
  const attempt = (what, run)=>Effect.tryPromise({
29
30
  try: run,
30
- catch: (error)=>new EntifixConnError(what, error)
31
+ catch: (error)=>translateRedisError(error)
31
32
  });
32
33
  /**
33
34
  * Read a record back, giving one written before `absoluteExpiresAt` existed a
@@ -74,8 +75,8 @@ import { RedisTag } from '../redis-connection/redis-connection.js';
74
75
  read: (sessionId)=>Effect.gen(function*() {
75
76
  const raw = yield* attempt('Redis session read failed', ()=>redis.get(sessionKey(sessionId)));
76
77
  if (raw === null) {
77
- return yield* Effect.fail(new EntifixLogicError('Session not found or expired', undefined, {
78
- sessionId
78
+ return yield* Effect.fail(new NotFound({
79
+ entity: 'session'
79
80
  }));
80
81
  }
81
82
  return parseRecord(raw);
@@ -83,8 +84,8 @@ import { RedisTag } from '../redis-connection/redis-connection.js';
83
84
  touch: (sessionId, idleTtlSeconds)=>Effect.gen(function*() {
84
85
  const raw = yield* attempt('Redis session touch read failed', ()=>redis.get(sessionKey(sessionId)));
85
86
  if (raw === null) {
86
- return yield* Effect.fail(new EntifixLogicError('Session not found or expired', undefined, {
87
- sessionId
87
+ return yield* Effect.fail(new NotFound({
88
+ entity: 'session'
88
89
  }));
89
90
  }
90
91
  const record = parseRecord(raw);
@@ -96,9 +97,8 @@ import { RedisTag } from '../redis-connection/redis-connection.js';
96
97
  if (secondsToCeiling <= 0) {
97
98
  yield* attempt('Redis session capped delete failed', ()=>redis.del(sessionKey(sessionId)));
98
99
  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
100
+ return yield* Effect.fail(new NotFound({
101
+ entity: 'session'
102
102
  }));
103
103
  }
104
104
  const ttlSeconds = Math.min(idleTtlSeconds, secondsToCeiling);
@@ -1 +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":";AAAA,SAASA,WAAW,QAAQ,cAAc;AAE1C,SAKEC,eAAe,QACV,oBAAoB;AAC3B,SACEC,gBAAgB,EAChBC,iBAAiB,QAEZ,gBAAgB;AACvB,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGvC,SAASC,QAAQ,QAAQ,0CAA0C;AAEnE,sEAAsE,GACtE,OAAO,MAAMC,4BAA4B,UAAU;AAOnD,gEAAgE,GAChE,MAAMC,gBAAgB,IAAcR,YAAY,IAAIS,QAAQ,CAAC;AAE7D;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMC,wBAAwB,CACnCC,OACAC,UAAoC,CAAC,CAAC;QAEpBA;IAAlB,MAAMC,aAAYD,qBAAAA,QAAQC,SAAS,YAAjBD,qBAAqBL;IACvC,MAAMO,aAAa,CAACC,YAA8B,GAAGF,UAAU,CAAC,EAAEE,WAAW;IAC7E,MAAMC,UAAU,CAACC,SACf,GAAGJ,UAAU,MAAM,EAAEK,OAAOD,SAAS;IAEvC,MAAME,UAAU,CAAIC,MAAcC,MAChCjB,OAAOkB,UAAU,CAAC;YAChBC,KAAKF;YACLG,OAAOC,CAAAA,QAAS,IAAIvB,iBAAiBkB,MAAMK;QAC7C;IAEF;;;;;;;;;;;;;GAaC,GACD,MAAMC,cAAc,CAACC;YAEYC;QAD/B,MAAMA,SAASC,KAAKC,KAAK,CAACH;QAC1B,OAAOI,OAAOC,KAAK,CAACC,KAAKH,KAAK,EAACF,4BAAAA,OAAOM,iBAAiB,YAAxBN,4BAA4B,OACvD,aAAKA;YAAQM,mBAAmBN,OAAOO,SAAS;aAChDP;IACN;IAEA,OAAO;QACLQ,QAAQ,CAACC,MAAmBC,WAC1BlC,OAAOmC,GAAG,CAAC;gBACT,MAAMxB,YAAYP;gBAClB,MAAMgC,YAAY,IAAIP;gBACtB,uEAAuE;gBACvE,yEAAyE;gBACzE,MAAMQ,aAAaC,KAAKC,GAAG,CACzBL,SAASM,cAAc,EACvBN,SAASO,kBAAkB;gBAE7B,MAAMV,YAAY,IAAIF,KAAKO,UAAUM,OAAO,KAAKL,aAAa;gBAC9D,MAAMP,oBAAoB,IAAID,KAC5BO,UAAUM,OAAO,KAAKR,SAASO,kBAAkB,GAAG;gBAEtD,MAAMjB,SAAwB,aACzBS;oBACHtB;oBACAyB,WAAWA,UAAUO,WAAW;oBAChCZ,WAAWA,UAAUY,WAAW;oBAChCb,mBAAmBA,kBAAkBa,WAAW;;gBAElD,OAAO5B,QAAQ,+BAA+B,IAC5CR,MAAMqC,GAAG,CACPlC,WAAWC,YACXc,KAAKoB,SAAS,CAACrB,SACf,MACAa;gBAGJ,OAAOtB,QAAQ,8BAA8B,IAC3CR,MAAMuC,IAAI,CAAClC,QAAQqB,KAAKpB,MAAM,GAAGF;gBAEnC,gEAAgE;gBAChE,wBAAwB;gBACxB,OAAOI,QAAQ,kCAAkC,IAC/CR,MAAMwC,MAAM,CAACnC,QAAQqB,KAAKpB,MAAM,GAAGqB,SAASO,kBAAkB;gBAEhE,OAAO9B;YACT;QAEFqC,MAAM,CAACrC,YACLX,OAAOmC,GAAG,CAAC;gBACT,MAAMZ,MAAM,OAAOR,QAAQ,6BAA6B,IACtDR,MAAM0C,GAAG,CAACvC,WAAWC;gBAEvB,IAAIY,QAAQ,MAAM;oBAChB,OAAO,OAAOvB,OAAOkD,IAAI,CACvB,IAAInD,kBAAkB,gCAAgCoD,WAAW;wBAC/DxC;oBACF;gBAEJ;gBACA,OAAOW,YAAYC;YACrB;QAEF6B,OAAO,CAACzC,WAAmB6B,iBACzBxC,OAAOmC,GAAG,CAAC;gBACT,MAAMZ,MAAM,OAAOR,QAAQ,mCAAmC,IAC5DR,MAAM0C,GAAG,CAACvC,WAAWC;gBAEvB,IAAIY,QAAQ,MAAM;oBAChB,OAAO,OAAOvB,OAAOkD,IAAI,CACvB,IAAInD,kBAAkB,gCAAgCoD,WAAW;wBAC/DxC;oBACF;gBAEJ;gBAEA,MAAMa,SAASF,YAAYC;gBAC3B,MAAM8B,MAAMxB,KAAKwB,GAAG;gBACpB,MAAMC,mBAAmBhB,KAAKiB,KAAK,CACjC,AAAC1B,CAAAA,KAAKH,KAAK,CAACF,OAAOM,iBAAiB,IAAIuB,GAAE,IAAK;gBAEjD,uEAAuE;gBACvE,sEAAsE;gBACtE,mEAAmE;gBACnE,IAAIC,oBAAoB,GAAG;oBACzB,OAAOvC,QAAQ,sCAAsC,IACnDR,MAAMiD,GAAG,CAAC9C,WAAWC;oBAEvB,OAAOI,QAAQ,sCAAsC,IACnDR,MAAMkD,IAAI,CAAC7C,QAAQY,OAAOX,MAAM,GAAGF;oBAErC,OAAO,OAAOX,OAAOkD,IAAI,CACvB,IAAInD,kBACF,yCACAoD,WACA;wBACExC;wBACAmB,mBAAmBN,OAAOM,iBAAiB;oBAC7C;gBAGN;gBAEA,MAAMO,aAAaC,KAAKC,GAAG,CAACC,gBAAgBc;gBAC5C,MAAMI,UAAyB,aAC1BlC;oBACHO,WAAW,IAAIF,KAAKwB,MAAMhB,aAAa,MAAMM,WAAW;;gBAE1D,OAAO5B,QAAQ,8BAA8B,IAC3CR,MAAMqC,GAAG,CACPlC,WAAWC,YACXc,KAAKoB,SAAS,CAACa,UACf,MACArB;YAGN;QAEFsB,aAAa,CAAC9C,SACZb,OAAOmC,GAAG,CAAC;gBACT,MAAMyB,aAAa,OAAO7C,QACxB,mCACA,IAAMR,MAAMsD,QAAQ,CAACjD,QAAQC;gBAG/B,MAAMiD,UAA2B,EAAE;gBACnC,MAAMC,QAAkB,EAAE;gBAC1B,KAAK,MAAMpD,aAAaiD,WAAY;oBAClC,MAAMrC,MAAM,OAAOR,QAAQ,kCAAkC,IAC3DR,MAAM0C,GAAG,CAACvC,WAAWC;oBAEvB,mEAAmE;oBACnE,oEAAoE;oBACpE,gEAAgE;oBAChE,IAAIY,QAAQ,MAAMwC,MAAMC,IAAI,CAACrD;yBACxBmD,QAAQE,IAAI,CAAC1C,YAAYC;gBAChC;gBACA,KAAK,MAAMZ,aAAaoD,MAAO;oBAC7B,OAAOhD,QAAQ,oCAAoC,IACjDR,MAAMkD,IAAI,CAAC7C,QAAQC,SAASF;gBAEhC;gBAEA,OAAOmD;YACT;QAEFG,QAAQ,CAACtD,YACPX,OAAOmC,GAAG,CAAC;gBACT,MAAMZ,MAAM,OAAOR,QAAQ,oCAAoC,IAC7DR,MAAM0C,GAAG,CAACvC,WAAWC;gBAEvB,OAAOI,QAAQ,+BAA+B,IAC5CR,MAAMiD,GAAG,CAAC9C,WAAWC;gBAEvB,IAAIY,QAAQ,MAAM;oBAChB,MAAMC,SAASF,YAAYC;oBAC3B,OAAOR,QAAQ,sCAAsC,IACnDR,MAAMkD,IAAI,CAAC7C,QAAQY,OAAOX,MAAM,GAAGF;gBAEvC;YACF;QAEFuD,kBAAkB,CAACrD,SACjBb,OAAOmC,GAAG,CAAC;gBACT,MAAMyB,aAAa,OAAO7C,QACxB,mCACA,IAAMR,MAAMsD,QAAQ,CAACjD,QAAQC;gBAE/B,KAAK,MAAMF,aAAaiD,WAAY;oBAClC,OAAO7C,QAAQ,oCAAoC,IACjDR,MAAMiD,GAAG,CAAC9C,WAAWC;gBAEzB;gBACA,OAAOI,QAAQ,mCAAmC,IAChDR,MAAMiD,GAAG,CAAC5C,QAAQC;YAEtB;QAEFsD,wBAAwB,CAACtD,QAAkBuD,gBACzCpE,OAAOmC,GAAG,CAAC;gBACT,MAAMyB,aAAa,OAAO7C,QACxB,mCACA,IAAMR,MAAMsD,QAAQ,CAACjD,QAAQC;gBAE/B,KAAK,MAAMF,aAAaiD,WAAY;oBAClC,IAAIjD,cAAcyD,eAAe;oBACjC,OAAOrD,QAAQ,oCAAoC,IACjDR,MAAMiD,GAAG,CAAC9C,WAAWC;oBAEvB,OAAOI,QAAQ,sCAAsC,IACnDR,MAAMkD,IAAI,CAAC7C,QAAQC,SAASF;gBAEhC;YACF;IACJ;AACF,EAAE;AAEF,yEAAyE,GACzE,OAAO,MAAM0D,yBAAyB,CACpC7D,UAAoC,CAAC,CAAC,GAEtCP,MAAMqE,MAAM,CACVzE,iBACAG,OAAOuE,GAAG,CAACrE,UAAUK,CAAAA,QAASD,sBAAsBC,OAAOC,WAC3D"}
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 { type EntityId, NotFound } from '@entifix/core';\nimport { Effect, Layer } from 'effect';\nimport type { Redis } from 'ioredis';\n\nimport { translateRedisError } from '../errors/translate-redis-error.js';\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 => translateRedisError(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(new NotFound({ entity: 'session' }));\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(new NotFound({ entity: 'session' }));\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(new NotFound({ entity: 'session' }));\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","NotFound","Effect","Layer","translateRedisError","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","entity","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,12 @@
1
+ import { type StoreFailure } from '@entifix/core';
2
+ /**
3
+ * What an ioredis error means, as a categorised failure
4
+ * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).
5
+ * Read by name and message, never by class.
6
+ *
7
+ * Redis has no uniqueness a caller can collide with and no transactions that
8
+ * conflict, so there is no `conflict` or `contention` here: the server either
9
+ * answered, did not answer in time, or could not be reached.
10
+ */
11
+ export declare const translateRedisError: (error: unknown, dependency?: string) => StoreFailure;
12
+ //# sourceMappingURL=translate-redis-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translate-redis-error.d.ts","sourceRoot":"","sources":["../../src/errors/translate-redis-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,YAAY,EAGlB,MAAM,eAAe,CAAC;AAEvB;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC9B,OAAO,OAAO,EACd,mBAAoB,KACnB,YASF,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { isFailure, Timeout, Unavailable } from '@entifix/core';
2
+ /**
3
+ * What an ioredis error means, as a categorised failure
4
+ * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).
5
+ * Read by name and message, never by class.
6
+ *
7
+ * Redis has no uniqueness a caller can collide with and no transactions that
8
+ * conflict, so there is no `conflict` or `contention` here: the server either
9
+ * answered, did not answer in time, or could not be reached.
10
+ */ export const translateRedisError = (error, dependency = 'redis')=>{
11
+ if (isFailure(error)) return error;
12
+ const message = typeof error === 'object' && error !== null && 'message' in error ? String(error.message) : '';
13
+ return /timed? ?out/i.test(message) ? new Timeout({
14
+ dependency
15
+ }) : new Unavailable({
16
+ dependency
17
+ });
18
+ };
19
+
20
+ //# sourceMappingURL=translate-redis-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/errors/translate-redis-error.ts"],"sourcesContent":["import {\n isFailure,\n type StoreFailure,\n Timeout,\n Unavailable,\n} from '@entifix/core';\n\n/**\n * What an ioredis error means, as a categorised failure\n * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).\n * Read by name and message, never by class.\n *\n * Redis has no uniqueness a caller can collide with and no transactions that\n * conflict, so there is no `conflict` or `contention` here: the server either\n * answered, did not answer in time, or could not be reached.\n */\nexport const translateRedisError = (\n error: unknown,\n dependency = 'redis',\n): StoreFailure => {\n if (isFailure(error)) return error as StoreFailure;\n const message =\n typeof error === 'object' && error !== null && 'message' in error\n ? String((error as { message: unknown }).message)\n : '';\n return /timed? ?out/i.test(message)\n ? new Timeout({ dependency })\n : new Unavailable({ dependency });\n};\n"],"names":["isFailure","Timeout","Unavailable","translateRedisError","error","dependency","message","String","test"],"mappings":"AAAA,SACEA,SAAS,EAETC,OAAO,EACPC,WAAW,QACN,gBAAgB;AAEvB;;;;;;;;CAQC,GACD,OAAO,MAAMC,sBAAsB,CACjCC,OACAC,aAAa,OAAO;IAEpB,IAAIL,UAAUI,QAAQ,OAAOA;IAC7B,MAAME,UACJ,OAAOF,UAAU,YAAYA,UAAU,QAAQ,aAAaA,QACxDG,OAAO,AAACH,MAA+BE,OAAO,IAC9C;IACN,OAAO,eAAeE,IAAI,CAACF,WACvB,IAAIL,QAAQ;QAAEI;IAAW,KACzB,IAAIH,YAAY;QAAEG;IAAW;AACnC,EAAE"}
@@ -1,4 +1,4 @@
1
- import { EntifixConnError } from '@entifix/core';
1
+ import type { StoreFailure } from '@entifix/core';
2
2
  import { Context, Layer } from 'effect';
3
3
  import { Redis } from 'ioredis';
4
4
  declare const RedisTag_base: Context.ServiceClass<RedisTag, "RedisTag", Redis<"legacy">>;
@@ -24,6 +24,6 @@ export interface RedisSettings {
24
24
  * process. Without it the client can settle into a terminal state and the
25
25
  * service stays degraded until someone restarts it, even after Redis is back.
26
26
  */
27
- export declare const RedisLayer: (settings: RedisSettings) => Layer.Layer<RedisTag, EntifixConnError>;
27
+ export declare const RedisLayer: (settings: RedisSettings) => Layer.Layer<RedisTag, StoreFailure>;
28
28
  export {};
29
29
  //# sourceMappingURL=redis-connection.d.ts.map
@@ -1 +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,aAA8C;CAAG;AAE/E,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"}
1
+ {"version":3,"file":"redis-connection.d.ts","sourceRoot":"","sources":["../../src/redis-connection/redis-connection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,OAAO,EAAU,KAAK,EAAY,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;;AAIhC,kDAAkD;AAClD,qBAAa,QAAS,SAAQ,aAA8C;CAAG;AAE/E,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,YAAY,CA+BlC,CAAC"}
@@ -1,6 +1,6 @@
1
- import { EntifixConnError } from '@entifix/core';
2
1
  import { Context, Effect, Layer, Schedule } from 'effect';
3
2
  import { Redis } from 'ioredis';
3
+ import { translateRedisError } from '../errors/translate-redis-error.js';
4
4
  /** DI tag carrying a connected ioredis client. */ export class RedisTag extends Context.Service()('RedisTag') {
5
5
  }
6
6
  /**
@@ -37,7 +37,7 @@ import { Redis } from 'ioredis';
37
37
  await redis.connect();
38
38
  return redis;
39
39
  },
40
- catch: (error)=>new EntifixConnError('Failed to connect to Redis', error)
40
+ catch: (error)=>translateRedisError(error)
41
41
  }).pipe(Effect.retry(Schedule.exponential('250 millis').pipe(Schedule.upTo({
42
42
  duration: CONNECT_RETRY_WINDOW
43
43
  })))), (redis)=>Effect.promise(()=>redis.quit().then(()=>undefined).catch(()=>undefined))));
@@ -1 +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.Service<RedisTag, Redis>()('RedisTag') {}\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.effect(\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({ duration: 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","Service","CONNECT_RETRY_WINDOW","RedisLayer","settings","effect","acquireRelease","tryPromise","try","redis","uri","lazyConnect","maxRetriesPerRequest","enableOfflineQueue","retryStrategy","times","Math","min","connect","catch","error","pipe","retry","exponential","upTo","duration","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,OAAO,GAAoB;AAAa;AAM9E;;;;;;;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,CAAC;QAAEC,UAAUvB;IAAqB,OAIrDO,CAAAA,QACEb,OAAO8B,OAAO,CAAC,IACbjB,MACGkB,IAAI,GACJC,IAAI,CAAC,IAAMC,WACXV,KAAK,CAAC,IAAMU,cAGrB"}
1
+ {"version":3,"sources":["../../src/redis-connection/redis-connection.ts"],"sourcesContent":["import type { StoreFailure } from '@entifix/core';\nimport { Context, Effect, Layer, Schedule } from 'effect';\nimport { Redis } from 'ioredis';\n\nimport { translateRedisError } from '../errors/translate-redis-error.js';\n\n/** DI tag carrying a connected ioredis client. */\nexport class RedisTag extends Context.Service<RedisTag, Redis>()('RedisTag') {}\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, StoreFailure> =>\n Layer.effect(\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 => translateRedisError(error),\n }).pipe(\n Effect.retry(\n Schedule.exponential('250 millis').pipe(\n Schedule.upTo({ duration: 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":["Context","Effect","Layer","Schedule","Redis","translateRedisError","RedisTag","Service","CONNECT_RETRY_WINDOW","RedisLayer","settings","effect","acquireRelease","tryPromise","try","redis","uri","lazyConnect","maxRetriesPerRequest","enableOfflineQueue","retryStrategy","times","Math","min","connect","catch","error","pipe","retry","exponential","upTo","duration","promise","quit","then","undefined"],"mappings":"AACA,SAASA,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,QAAQ,QAAQ,SAAS;AAC1D,SAASC,KAAK,QAAQ,UAAU;AAEhC,SAASC,mBAAmB,QAAQ,qCAAqC;AAEzE,gDAAgD,GAChD,OAAO,MAAMC,iBAAiBN,QAAQO,OAAO,GAAoB;AAAa;AAM9E;;;;;;;CAOC,GACD,MAAMC,uBAAuB;AAE7B;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,aAAa,CACxBC,WAEAR,MAAMS,MAAM,CACVL,UACAL,OAAOW,cAAc,CACnBX,OAAOY,UAAU,CAAC;QAChBC,KAAK;YACH,MAAMC,QAAQ,IAAIX,MAAMM,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,QAASrB,oBAAoBqB;IACtC,GAAGC,IAAI,CACL1B,OAAO2B,KAAK,CACVzB,SAAS0B,WAAW,CAAC,cAAcF,IAAI,CACrCxB,SAAS2B,IAAI,CAAC;QAAEC,UAAUvB;IAAqB,OAIrDO,CAAAA,QACEd,OAAO+B,OAAO,CAAC,IACbjB,MACGkB,IAAI,GACJC,IAAI,CAAC,IAAMC,WACXV,KAAK,CAAC,IAAMU,cAGrB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/redis",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Redis adapters for sessions and one-time tokens, and optionally the lock and sequence services @entifix/transactions needs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,17 +57,17 @@
57
57
  "dependencies": {
58
58
  "@swc/helpers": "0.5.23",
59
59
  "ioredis": "6.0.0",
60
- "@entifix/business": "0.9.0",
61
- "@entifix/core": "0.9.0"
60
+ "@entifix/business": "0.10.0",
61
+ "@entifix/core": "0.10.0"
62
62
  },
63
63
  "devDependencies": {
64
- "effect": "4.0.5",
65
- "@entifix/testing-unit": "0.9.0",
66
- "@entifix/transactions": "0.9.0"
64
+ "effect": "4.0.6",
65
+ "@entifix/testing-unit": "0.10.0",
66
+ "@entifix/transactions": "0.10.0"
67
67
  },
68
68
  "peerDependencies": {
69
- "effect": "^4.0.5",
70
- "@entifix/transactions": "0.9.0"
69
+ "effect": "^4.0.6",
70
+ "@entifix/transactions": "0.10.0"
71
71
  },
72
72
  "peerDependenciesMeta": {
73
73
  "@entifix/transactions": {