Repository: owncloud/ocis
Pull request #4592 (2022-09-15 07:39:34)

Bump github.com/gofrs/uuid from 4.2.0+incompatible to 4.3.0+incompatible
Bumps [github.com/gofrs/uuid](https://github.com/gofrs/uuid) from 4.2.0+incompatible to 4.3.0+incompatible.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/gofrs/uuid/releases">github.com/gofrs/uuid's releases</a>.</em></p>
<blockquote>
<h2>Update to UUIDv7</h2>
<ul>
<li>Update UUIDv7 to conform with RFC Draft Rev 3 by <a href="https://github.com/convto"><code>@​convto</code></a> (<em><strong>Breaking change to experimental feature</strong></em>)</li>
<li>Update unit test coverage to be 100% by <a href="https://github.com/theckman"><code>@​theckman</code></a>, and <a href="https://github.com/cameracker"><code>@​cameracker</code></a></li>
</ul>
<p>Full Changelog: v4.2.0...v4.3.0</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/gofrs/uuid/commit/e420387fde43193b254a9accdb63f478b19c28b5"><code>e420387</code></a> Enhance defaultHWAddrFunc() and tests to hit 100% coverage (<a href="https://github-redirect.dependabot.com/gofrs/uuid/issues/57">#57</a>)</li>
<li><a href="https://github.com/gofrs/uuid/commit/f267b3d7a0933ad06c18ac7155a2c14cd74f92c3"><code>f267b3d</code></a> update UUIDv7 implementation with RFC Draft Rev 03 spec (<a href="https://github-redirect.dependabot.com/gofrs/uuid/issues/99">#99</a>)</li>
<li><a href="https://github.com/gofrs/uuid/commit/edd511b3edbf88e36fc370b919ed392fd33da1e2"><code>edd511b</code></a> update build to use go 1.19 as primary (<a href="https://github-redirect.dependabot.com/gofrs/uuid/issues/103">#103</a>)</li>
<li><a href="https://github.com/gofrs/uuid/commit/1e02a1d06812be3bd1794b944975ff016cfda6e3"><code>1e02a1d</code></a> Update go.yml to use go 1.18 (<a href="https://github-redirect.dependabot.com/gofrs/uuid/issues/102">#102</a>)</li>
<li><a href="https://github.com/gofrs/uuid/commit/028e8409cdd0ed11a2b5bb3feb1ae2285ebb94fa"><code>028e840</code></a> update readme to document experimental v6 and v7 ids (<a href="https://github-redirect.dependabot.com/gofrs/uuid/issues/94">#94</a>)</li>
<li>See full diff in <a href="https://github.com/gofrs/uuid/compare/v4.2.0...v4.3.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/gofrs/uuid&package-manager=go_modules&previous-version=4.2.0+incompatible&new-version=4.3.0+incompatible)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.drone.env b/.drone.env
index 35be598948f..8c9cf337ab7 100644
--- a/.drone.env
+++ b/.drone.env
@@ -1,5 +1,5 @@
 # The test runner source for API tests
-CORE_COMMITID=67fad9d49fa7f3f42f77e882ee4cbb206464ca5d
+CORE_COMMITID=82a05d010110c0b4b2513c22185eac28b5dce054
 CORE_BRANCH=master
 
 # The test runner source for UI tests
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4eedbe87c10..1b3f2e1996a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,12 +6,23 @@ The following sections list the changes for unreleased.
 
 ## Summary
 
+* Bugfix - Fix the OIDC provider cache: [#4600](https://github.com/owncloud/ocis/pull/4600)
 * Bugfix - Render webdav permissions as string in search report: [#4575](https://github.com/owncloud/ocis/issues/4575)
 * Bugfix - Graph service now forwards trace context: [#4582](https://github.com/owncloud/ocis/pull/4582)
+* Enhancement - Add webURL to space root: [#4588](https://github.com/owncloud/ocis/pull/4588)
 * Enhancement - Allow to configure applications in Web: [#4578](https://github.com/owncloud/ocis/pull/4578)
+* Enhancement - Update reva: [#4588](https://github.com/owncloud/ocis/pull/4588)
 
 ## Details
 
+* Bugfix - Fix the OIDC provider cache: [#4600](https://github.com/owncloud/ocis/pull/4600)
+
+   We've fixed the OIDC provider cache. It never had a cache hit before this fix. Under some
+   circumstances it could cause a painfully slow OCIS if the IDP wellknown endpoint takes some
+   time to respond.
+
+   https://github.com/owncloud/ocis/pull/4600
+
 * Bugfix - Render webdav permissions as string in search report: [#4575](https://github.com/owncloud/ocis/issues/4575)
 
    We now correctly render the `oc:permissions` of resources as a string.
@@ -23,11 +34,23 @@ The following sections list the changes for unreleased.
 
    https://github.com/owncloud/ocis/pull/4582
 
+* Enhancement - Add webURL to space root: [#4588](https://github.com/owncloud/ocis/pull/4588)
+
+   Add the web url to the space root on the graphAPI.
+
+   https://github.com/owncloud/ocis/pull/4588
+
 * Enhancement - Allow to configure applications in Web: [#4578](https://github.com/owncloud/ocis/pull/4578)
 
    We've added the possibility to configure applications in the Web configuration.
 
    https://github.com/owncloud/ocis/pull/4578
+
+* Enhancement - Update reva: [#4588](https://github.com/owncloud/ocis/pull/4588)
+
+   TBD
+
+   https://github.com/owncloud/ocis/pull/4588
 # Changelog for [2.0.0-beta.8] (2022-09-13)
 
 The following sections list the changes for 2.0.0-beta.8.
diff --git a/changelog/unreleased/add-web-url.md b/changelog/unreleased/add-web-url.md
new file mode 100644
index 00000000000..f4826375781
--- /dev/null
+++ b/changelog/unreleased/add-web-url.md
@@ -0,0 +1,5 @@
+Enhancement: Add webURL to space root
+
+Add the web url to the space root on the graphAPI.
+
+https://github.com/owncloud/ocis/pull/4588
diff --git a/changelog/unreleased/fix-oidc-provider-cache.md b/changelog/unreleased/fix-oidc-provider-cache.md
new file mode 100644
index 00000000000..bfc4e222895
--- /dev/null
+++ b/changelog/unreleased/fix-oidc-provider-cache.md
@@ -0,0 +1,6 @@
+Bugfix: Fix the OIDC provider cache
+
+We've fixed the OIDC provider cache. It never had a cache hit before this fix.
+Under some circumstances it could cause a painfully slow OCIS if the IDP wellknown endpoint takes some time to respond.
+
+https://github.com/owncloud/ocis/pull/4600
diff --git a/changelog/unreleased/update-reva-rc1.md b/changelog/unreleased/update-reva-rc1.md
new file mode 100644
index 00000000000..09553bdd55c
--- /dev/null
+++ b/changelog/unreleased/update-reva-rc1.md
@@ -0,0 +1,5 @@
+Enhancement: Update reva
+
+TBD
+
+https://github.com/owncloud/ocis/pull/4588
diff --git a/docs/services/frontend/_index.md b/docs/services/frontend/_index.md
index 097fde9320d..20ace43cb54 100644
--- a/docs/services/frontend/_index.md
+++ b/docs/services/frontend/_index.md
@@ -10,7 +10,28 @@ geekdocCollapseSection: true
 
 ## Abstract
 
+The frontend service provides multiple HTTP endpoints to translate OCS, archiver and approvider requests into CS3 requests.
 
 ## Table of Contents
 
 {{< toc-tree >}}
+
+## OCS
+
+The OCS endpoint implements the open collaboration services API in a backwards compatible manner.
+
+### Sharing
+
+Aggregating share information is one of the most time consuming operations in OCIS. The service fetches a list of either received or created shares and has to stat every resource individually. While stats are fast, the default behavior scales linearly with the number of shares.
+
+To save network trips the sharing implementation can cache the stat requests with an in memory cache or in redis. It will shorten the response time by the network rountrip overhead at the cost of the API only eventually being updated. 
+
+Setting `FRONTEND_OCS_RESOURCE_INFO_CACHE_TTL=60` would cache the stat info for 60 seconds. Increasing this value makes sense for large deployments with thousands of active users that keep the cache up to date. Low frequency usage scenarios should not expect a noticeable improvement.
+
+## Archiver
+
+The archiver endpoint provides bundled downloads of multiple files and folders.
+
+## Appprovider
+
+The appprovider endpoint is used to manage available apps that can be used to open different file types.
\ No newline at end of file
diff --git a/docs/services/sharing/_index.md b/docs/services/sharing/_index.md
index be41c1be9e0..ecdc21f5f42 100644
--- a/docs/services/sharing/_index.md
+++ b/docs/services/sharing/_index.md
@@ -9,7 +9,7 @@ geekdocCollapseSection: true
 
 ## Abstract
 
-This service provides sharing functionality.
+This service implements the CS3 [LinkAPI](https://cs3org.github.io/cs3apis/#cs3.sharing.link.v1beta1.LinkAPI) to manage public links as well as the [CollaborationAPI](https://cs3org.github.io/cs3apis/#cs3.sharing.collaboration.v1beta1.CollaborationAPI) to manage user and group shares.
 
 ## Table of Contents
 
diff --git a/go.mod b/go.mod
index 960889f257b..5374ef13cbf 100644
--- a/go.mod
+++ b/go.mod
@@ -7,11 +7,12 @@ require (
 	github.com/Masterminds/semver v1.5.0
 	github.com/MicahParks/keyfunc v1.2.2
 	github.com/ReneKroon/ttlcache/v2 v2.11.0
+	github.com/armon/go-radix v1.0.0
 	github.com/blevesearch/bleve/v2 v2.3.4
 	github.com/blevesearch/bleve_index_api v1.0.3
 	github.com/coreos/go-oidc/v3 v3.4.0
 	github.com/cs3org/go-cs3apis v0.0.0-20220818202316-e92afdddac6d
-	github.com/cs3org/reva/v2 v2.10.1-0.20220914095102-44dc469bd48b
+	github.com/cs3org/reva/v2 v2.10.1-0.20220915095422-4b099c09a66c
 	github.com/disintegration/imaging v1.6.2
 	github.com/ggwhite/go-masker v1.0.9
 	github.com/go-chi/chi/v5 v5.0.7
@@ -33,7 +34,7 @@ require (
 	github.com/go-micro/plugins/v4/wrapper/monitoring/prometheus v1.1.0
 	github.com/go-micro/plugins/v4/wrapper/trace/opencensus v1.1.0
 	github.com/go-ozzo/ozzo-validation/v4 v4.3.0
-	github.com/gofrs/uuid v4.2.0+incompatible
+	github.com/gofrs/uuid v4.3.0+incompatible
 	github.com/golang-jwt/jwt/v4 v4.4.2
 	github.com/golang/protobuf v1.5.2
 	github.com/gookit/config/v2 v2.1.5
@@ -65,6 +66,7 @@ require (
 	github.com/xhit/go-simple-mail/v2 v2.11.0
 	go-micro.dev/v4 v4.8.1
 	go.etcd.io/bbolt v1.3.6
+	go.etcd.io/etcd/client/v3 v3.5.2
 	go.opencensus.io v0.23.0
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.34.0
 	go.opentelemetry.io/otel v1.10.0
@@ -101,7 +103,6 @@ require (
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 // indirect
 	github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 // indirect
 	github.com/armon/go-metrics v0.3.10 // indirect
-	github.com/armon/go-radix v1.0.0 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
 	github.com/aws/aws-sdk-go v1.44.94 // indirect
 	github.com/beevik/etree v1.1.0 // indirect
@@ -262,7 +263,6 @@ require (
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
 	go.etcd.io/etcd/api/v3 v3.5.2 // indirect
 	go.etcd.io/etcd/client/pkg/v3 v3.5.2 // indirect
-	go.etcd.io/etcd/client/v3 v3.5.2 // indirect
 	go.uber.org/atomic v1.9.0 // indirect
 	go.uber.org/multierr v1.7.0 // indirect
 	go.uber.org/zap v1.19.1 // indirect
diff --git a/go.sum b/go.sum
index 354f2afaf38..ec465678192 100644
--- a/go.sum
+++ b/go.sum
@@ -292,8 +292,8 @@ github.com/crewjam/saml v0.4.6 h1:XCUFPkQSJLvzyl4cW9OvpWUbRf0gE7VUpU8ZnilbeM4=
 github.com/crewjam/saml v0.4.6/go.mod h1:ZBOXnNPFzB3CgOkRm7Nd6IVdkG+l/wF+0ZXLqD96t1A=
 github.com/cs3org/go-cs3apis v0.0.0-20220818202316-e92afdddac6d h1:toyZ7IsXlUdEPZ/IG8fg7hbM8HcLPY0bkX4FKBmgLVI=
 github.com/cs3org/go-cs3apis v0.0.0-20220818202316-e92afdddac6d/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY=
-github.com/cs3org/reva/v2 v2.10.1-0.20220914095102-44dc469bd48b h1:mRtQad2TBNE970FYntYSZJJp+Ol0fzcICf/n/UmHb1g=
-github.com/cs3org/reva/v2 v2.10.1-0.20220914095102-44dc469bd48b/go.mod h1:+BYVpRV8g1hL8wF3+3BunL9BKPsXVyJYmH8COxq/V7Y=
+github.com/cs3org/reva/v2 v2.10.1-0.20220915095422-4b099c09a66c h1:pvbsnSl5WpS6PkSR4glwR8OJGrRdZASajAJtNwp9E+Y=
+github.com/cs3org/reva/v2 v2.10.1-0.20220915095422-4b099c09a66c/go.mod h1:+BYVpRV8g1hL8wF3+3BunL9BKPsXVyJYmH8COxq/V7Y=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8 h1:Z9lwXumT5ACSmJ7WGnFl+OMLLjpz5uR2fyz7dC255FI=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8/go.mod h1:4abs/jPXcmJzYoYGF91JF9Uq9s/KL5n1jvFDix8KcqY=
 github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
@@ -515,8 +515,9 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
 github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
 github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
-github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0=
 github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/gofrs/uuid v4.3.0+incompatible h1:CaSVZxm5B+7o45rtab4jC2G37WGYX1zQfuU2i6DSvnc=
+github.com/gofrs/uuid v4.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
diff --git a/ocis-pkg/config/config.go b/ocis-pkg/config/config.go
index 2fd90f9506f..dd772c2357d 100644
--- a/ocis-pkg/config/config.go
+++ b/ocis-pkg/config/config.go
@@ -57,8 +57,9 @@ type Runtime struct {
 type Config struct {
 	*shared.Commons `yaml:"shared"`
 
-	Tracing *shared.Tracing `yaml:"tracing"`
-	Log     *shared.Log     `yaml:"log"`
+	Tracing    *shared.Tracing    `yaml:"tracing"`
+	Log        *shared.Log        `yaml:"log"`
+	CacheStore *shared.CacheStore `yaml:"cache_store"`
 
 	Mode    Mode // DEPRECATED
 	File    string
diff --git a/ocis-pkg/config/parser/parse.go b/ocis-pkg/config/parser/parse.go
index 81e1ede7b24..3ffe25b075c 100644
--- a/ocis-pkg/config/parser/parse.go
+++ b/ocis-pkg/config/parser/parse.go
@@ -48,6 +48,9 @@ func EnsureDefaults(cfg *config.Config) {
 	if cfg.TokenManager == nil {
 		cfg.TokenManager = &shared.TokenManager{}
 	}
+	if cfg.CacheStore == nil {
+		cfg.CacheStore = &shared.CacheStore{}
+	}
 }
 
 // EnsureCommons copies applicable parts of the oCIS config into the commons part
@@ -81,6 +84,16 @@ func EnsureCommons(cfg *config.Config) {
 		cfg.Commons.Tracing = &shared.Tracing{}
 	}
 
+	if cfg.CacheStore != nil {
+		cfg.Commons.CacheStore = &shared.CacheStore{
+			Type:    cfg.CacheStore.Type,
+			Address: cfg.CacheStore.Address,
+			Size:    cfg.CacheStore.Size,
+		}
+	} else {
+		cfg.Commons.CacheStore = &shared.CacheStore{}
+	}
+
 	// copy token manager to the commons part if set
 	if cfg.TokenManager != nil {
 		cfg.Commons.TokenManager = cfg.TokenManager
diff --git a/ocis-pkg/roles/manager.go b/ocis-pkg/roles/manager.go
index f6a59758a63..651970a9252 100644
--- a/ocis-pkg/roles/manager.go
+++ b/ocis-pkg/roles/manager.go
@@ -2,16 +2,26 @@ package roles
 
 import (
 	"context"
+	"time"
 
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	ocisstore "github.com/owncloud/ocis/v2/ocis-pkg/store"
 	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
 	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+	"go-micro.dev/v4/store"
+	"google.golang.org/protobuf/encoding/protojson"
+)
+
+const (
+	cacheDatabase  = "ocis-pkg"
+	cacheTableName = "ocis-pkg/roles"
+	cacheTTL       = time.Hour
 )
 
 // Manager manages a cache of roles by fetching unknown roles from the settings.RoleService.
 type Manager struct {
 	logger      log.Logger
-	cache       cache
+	cache       store.Store
 	roleService settingssvc.RoleService
 }
 
@@ -19,8 +29,9 @@ type Manager struct {
 func NewManager(o ...Option) Manager {
 	opts := newOptions(o...)
 
+	nStore := ocisstore.GetStore(opts.storeOptions)
 	return Manager{
-		cache:       newCache(opts.size, opts.ttl),
+		cache:       nStore,
 		roleService: opts.roleService,
 	}
 }
@@ -31,10 +42,26 @@ func (m *Manager) List(ctx context.Context, roleIDs []string) []*settingsmsg.Bun
 	result := make([]*settingsmsg.Bundle, 0)
 	lookup := make([]string, 0)
 	for _, roleID := range roleIDs {
-		if hit := m.cache.get(roleID); hit == nil {
+		if records, err := m.cache.Read(roleID, store.ReadFrom(cacheDatabase, cacheTableName)); err != nil {
 			lookup = append(lookup, roleID)
 		} else {
-			result = append(result, hit)
+			role := &settingsmsg.Bundle{}
+			found := false
+			for _, record := range records {
+				if record.Key == roleID {
+					if err := protojson.Unmarshal(record.Value, role); err == nil {
+						// if we can unmarshal the role, append it to the result
+						// otherwise assume the role wasn't found (data was damaged and
+						// we need to get the role again)
+						result = append(result, role)
+						found = true
+						break
+					}
+				}
+			}
+			if !found {
+				lookup = append(lookup, roleID)
+			}
 		}
 	}
 
@@ -49,7 +76,20 @@ func (m *Manager) List(ctx context.Context, roleIDs []string) []*settingsmsg.Bun
 			return nil
 		}
 		for _, role := range res.Bundles {
-			m.cache.set(role.Id, role)
+			jsonbytes, _ := protojson.Marshal(role)
+			record := &store.Record{
+				Key:    role.Id,
+				Value:  jsonbytes,
+				Expiry: cacheTTL,
+			}
+			err := m.cache.Write(
+				record,
+				store.WriteTo(cacheDatabase, cacheTableName),
+				store.WriteTTL(cacheTTL),
+			)
+			if err != nil {
+				m.logger.Debug().Err(err).Msg("failed to cache roles")
+			}
 			result = append(result, role)
 		}
 	}
diff --git a/ocis-pkg/roles/option.go b/ocis-pkg/roles/option.go
index 2e8790f3fb4..772f0ef6477 100644
--- a/ocis-pkg/roles/option.go
+++ b/ocis-pkg/roles/option.go
@@ -1,37 +1,21 @@
 package roles
 
 import (
-	"time"
-
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	ocisstore "github.com/owncloud/ocis/v2/ocis-pkg/store"
 	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
 )
 
 // Options are all the possible options.
 type Options struct {
-	size        int
-	ttl         time.Duration
-	logger      log.Logger
-	roleService settingssvc.RoleService
+	storeOptions ocisstore.OcisStoreOptions
+	logger       log.Logger
+	roleService  settingssvc.RoleService
 }
 
 // Option mutates option
 type Option func(*Options)
 
-// CacheSize configures the size of the cache in items.
-func CacheSize(s int) Option {
-	return func(o *Options) {
-		o.size = s
-	}
-}
-
-// CacheTTL rebuilds the cache after the configured duration.
-func CacheTTL(ttl time.Duration) Option {
-	return func(o *Options) {
-		o.ttl = ttl
-	}
-}
-
 // Logger sets a preconfigured logger
 func Logger(logger log.Logger) Option {
 	return func(o *Options) {
@@ -46,6 +30,12 @@ func RoleService(rs settingssvc.RoleService) Option {
 	}
 }
 
+func StoreOptions(storeOpts ocisstore.OcisStoreOptions) Option {
+	return func(o *Options) {
+		o.storeOptions = storeOpts
+	}
+}
+
 func newOptions(opts ...Option) Options {
 	o := Options{}
 
diff --git a/ocis-pkg/shared/shared_types.go b/ocis-pkg/shared/shared_types.go
index d633f79bfe6..5e78eaa3b68 100644
--- a/ocis-pkg/shared/shared_types.go
+++ b/ocis-pkg/shared/shared_types.go
@@ -34,11 +34,18 @@ type Reva struct {
 	Address string `yaml:"address" env:"REVA_GATEWAY" desc:"The CS3 gateway endpoint."`
 }
 
-// Commons holds configuration that are common to all services. Each service can then decide whether
+type CacheStore struct {
+	Type    string `yaml:"type" env:"OCIS_CACHE_STORE_TYPE" desc:"The type of the cache store. Valid options are \"noop\", \"ocmem\", \"etcd\" and \"memory\""`
+	Address string `yaml:"address" env:"OCIS_CACHE_STORE_ADDRESS" desc:"a comma-separated list of addresses to connect to. Only for etcd"`
+	Size    int    `yaml:"size" env:"OCIS_CACHE_STORE_SIZE" desc:"Maximum size for the cache store. Only ocmem will use this option, in number of items per table. The rest will ignore the option and can grow indefinitely"`
+}
+
+// Commons holds configuration that are common to all extensions. Each extension can then decide whether
 // to overwrite its values.
 type Commons struct {
 	Log               *Log          `yaml:"log"`
 	Tracing           *Tracing      `yaml:"tracing"`
+	CacheStore        *CacheStore   `yaml:"cache_store"`
 	OcisURL           string        `yaml:"ocis_url" env:"OCIS_URL" desc:"URL, where oCIS is reachable for users."`
 	TokenManager      *TokenManager `mask:"struct" yaml:"token_manager"`
 	Reva              *Reva         `yaml:"reva"`
diff --git a/ocis-pkg/store/etcd/etcd.go b/ocis-pkg/store/etcd/etcd.go
new file mode 100644
index 00000000000..d5b7c70809a
--- /dev/null
+++ b/ocis-pkg/store/etcd/etcd.go
@@ -0,0 +1,531 @@
+package etcd
+
+import (
+	"context"
+	"encoding/json"
+	"strings"
+	"time"
+
+	"go-micro.dev/v4/store"
+	clientv3 "go.etcd.io/etcd/client/v3"
+	"go.etcd.io/etcd/client/v3/namespace"
+)
+
+const (
+	prefixNS = ".prefix"
+	suffixNS = ".suffix"
+)
+
+type EtcdStore struct {
+	options store.Options
+	client  *clientv3.Client
+}
+
+// Create a new go-micro store backed by etcd
+func NewEtcdStore(opts ...store.Option) store.Store {
+	es := &EtcdStore{}
+	_ = es.Init(opts...)
+	return es
+}
+
+func (es *EtcdStore) getCtx() (context.Context, context.CancelFunc) {
+	currentCtx := es.options.Context
+	if currentCtx == nil {
+		currentCtx = context.TODO()
+	}
+	ctx, cancel := context.WithTimeout(currentCtx, 10*time.Second)
+	return ctx, cancel
+}
+
+// Setup the etcd client based on the current options. The old client (if any)
+// will be closed.
+// Currently, only the etcd nodes are configurable. If no node is provided,
+// it will use the "127.0.0.1:2379" node.
+// Context timeout is setup to 10 seconds, and dial timeout to 2 seconds
+func (es *EtcdStore) setupClient() {
+	if es.client != nil {
+		es.client.Close()
+	}
+
+	endpoints := []string{"127.0.0.1:2379"}
+	if len(es.options.Nodes) > 0 {
+		endpoints = es.options.Nodes
+	}
+
+	cli, _ := clientv3.New(clientv3.Config{
+		DialTimeout: 2 * time.Second,
+		Endpoints:   endpoints,
+	})
+
+	es.client = cli
+}
+
+// Initialize the go-micro store implementation.
+// Currently, only the nodes are configurable, the rest of the options
+// will be ignored.
+func (es *EtcdStore) Init(opts ...store.Option) error {
+	optList := store.Options{}
+	for _, opt := range opts {
+		opt(&optList)
+	}
+
+	es.options = optList
+	es.setupClient()
+	return nil
+}
+
+// Get the store options
+func (es *EtcdStore) Options() store.Options {
+	return es.options
+}
+
+// Get the effective TTL, as int64 number of seconds. It will prioritize
+// the TTL set in the options, then the expiry time in the options, and
+// finally the one set as part of the record
+func getEffectiveTTL(r *store.Record, opts store.WriteOptions) int64 {
+	// set base ttl duration and expiration time based on the record
+	duration := r.Expiry
+
+	// overwrite ttl duration and expiration time based on options
+	if !opts.Expiry.IsZero() {
+		// options.Expiry is a time.Time, newRecord.Expiry is a time.Duration
+		duration = time.Until(opts.Expiry)
+	}
+
+	// TTL option takes precedence over expiration time
+	if opts.TTL != 0 {
+		duration = opts.TTL
+	}
+
+	// use milliseconds because it returns an int64 instead of a float64
+	return duration.Milliseconds() / 1000
+}
+
+// Write the record into the etcd. The record will be duplicated in order to
+// find it by prefix or by suffix. This means that it will take double space.
+// Note that this is an implementation detail and it will be handled
+// transparently.
+//
+// Database and Table options will be used to provide a different prefix to
+// the key. Each service using this store should use a different database+table
+// combination in order to prevent key collisions.
+//
+// Due to how TTLs are implemented in etcd, the minimum valid TTL seems to
+// be 2 secs. Using lower values or even negative values will force the etcd
+// server to use the minimum value instead.
+// In addition, getting a lease for the TTL and attach it to the target key
+// are 2 different operations that can't be sent as part of a transaction.
+// This means that it's possible to get a lease and have that lease expire
+// before attaching it to the key. Errors are expected to happen if this is
+// the case, and no key will be inserted.
+// According to etcd documentation, the key is guaranteed to be available
+// AT LEAST the TTL duration. This means that the key might be available for
+// a longer period of time in special circumstances.
+//
+// It's recommended to use a minimum TTL of 10 secs or higher (or not to use
+// TTL) in order to prevent problematic scenarios.
+func (es *EtcdStore) Write(r *store.Record, opts ...store.WriteOption) error {
+	wopts := store.WriteOptions{}
+	for _, opt := range opts {
+		opt(&wopts)
+	}
+
+	prefix := buildPrefix(wopts.Database, wopts.Table, prefixNS)
+	suffix := buildPrefix(wopts.Database, wopts.Table, suffixNS)
+
+	kv := es.client.KV
+
+	jsonRecord, err := json.Marshal(r)
+	if err != nil {
+		return err
+	}
+	jsonStringRecord := string(jsonRecord)
+
+	effectiveTTL := getEffectiveTTL(r, wopts)
+	var opOpts []clientv3.OpOption
+
+	if effectiveTTL != 0 {
+		lease := es.client.Lease
+		ctx, cancel := es.getCtx()
+		gResp, gErr := lease.Grant(ctx, getEffectiveTTL(r, wopts))
+		cancel()
+		if gErr != nil {
+			return gErr
+		}
+		opOpts = []clientv3.OpOption{clientv3.WithLease(gResp.ID)}
+	} else {
+		opOpts = []clientv3.OpOption{clientv3.WithLease(0)}
+	}
+
+	ctx, cancel := es.getCtx()
+	_, err = kv.Txn(ctx).Then(
+		clientv3.OpPut(prefix+r.Key, jsonStringRecord, opOpts...),
+		clientv3.OpPut(suffix+reverseString(r.Key), jsonStringRecord, opOpts...),
+	).Commit()
+	cancel()
+
+	return err
+}
+
+// Process a Get response taking into account the provided offset
+func processGetResponse(resp *clientv3.GetResponse, offset int64) ([]*store.Record, error) {
+	result := make([]*store.Record, 0, len(resp.Kvs))
+	for index, kvs := range resp.Kvs {
+		if int64(index) < offset {
+			// skip entries before the offset
+			continue
+		}
+
+		value := &store.Record{}
+		err := json.Unmarshal(kvs.Value, value)
+		if err != nil {
+			return nil, err
+		}
+		result = append(result, value)
+	}
+	return result, nil
+}
+
+// Process a List response taking into account the provided offset.
+// The reverse flag will be used to reverse the keys found. For example,
+// "zyxw" will be reversed to "wxyz". This is used for suffix searches,
+// where the keys are stored reversed and need to be changed
+func processListResponse(resp *clientv3.GetResponse, offset int64, reverse bool) ([]string, error) {
+	result := make([]string, 0, len(resp.Kvs))
+	for index, kvs := range resp.Kvs {
+		if int64(index) < offset {
+			// skip entries before the offset
+			continue
+		}
+
+		targetKey := string(kvs.Key)
+		if reverse {
+			targetKey = reverseString(targetKey)
+		}
+		result = append(result, targetKey)
+	}
+	return result, nil
+}
+
+// Perform an exact key read and return the result
+func (es *EtcdStore) directRead(kv clientv3.KV, key string) ([]*store.Record, error) {
+	ctx, cancel := es.getCtx()
+	resp, err := kv.Get(ctx, key)
+	cancel()
+	if err != nil {
+		return nil, err
+	}
+
+	if len(resp.Kvs) == 0 {
+		return nil, store.ErrNotFound
+	}
+
+	return processGetResponse(resp, 0)
+}
+
+// Perform a prefix read with limit and offset. A limit of 0 will return all
+// results. Usage of offset isn't recommended because those results must still
+// be fethed from the server in order to be discarded.
+func (es *EtcdStore) prefixRead(kv clientv3.KV, key string, limit, offset int64) ([]*store.Record, error) {
+	getOptions := []clientv3.OpOption{
+		clientv3.WithPrefix(),
+	}
+	if limit > 0 {
+		getOptions = append(getOptions, clientv3.WithLimit(limit+offset))
+	}
+
+	ctx, cancel := es.getCtx()
+	resp, err := kv.Get(ctx, key, getOptions...)
+	cancel()
+	if err != nil {
+		return nil, err
+	}
+	return processGetResponse(resp, offset)
+}
+
+// Perform a prefix + suffix read with limit and offset. A limit of 0 will
+// return all results found. Usage of this function is discouraged because
+// we'll have to request a prefix search and match the suffix manually. This
+// means that even with a limit = 3 and offset = 0, there is no guarantee
+// we'll find all the results we need within that range, and we'll likely
+// need to request more data from the server. The number of requests we need
+// to perform is unknown and might cause load.
+func (es *EtcdStore) prefixSuffixRead(kv clientv3.KV, prefix, suffix string, limit, offset int64) ([]*store.Record, error) {
+	firstKeyOut := firstKeyOutOfPrefixString(prefix)
+	getOptions := []clientv3.OpOption{
+		clientv3.WithRange(firstKeyOut),
+	}
+
+	if limit > 0 {
+		// unlikely to find all the entries we need within offset + limit
+		getOptions = append(getOptions, clientv3.WithLimit((limit+offset)*2))
+	}
+
+	var currentRecordOffset int64
+	result := []*store.Record{}
+	initialKey := prefix
+
+	keepGoing := true
+	for keepGoing {
+		ctx, cancel := es.getCtx()
+		resp, respErr := kv.Get(ctx, initialKey, getOptions...)
+		cancel()
+		if respErr != nil {
+			return nil, respErr
+		}
+
+		records, err := processGetResponse(resp, 0)
+		if err != nil {
+			return nil, err
+		}
+		for _, record := range records {
+			if !strings.HasSuffix(record.Key, suffix) {
+				continue
+			}
+
+			if currentRecordOffset < offset {
+				currentRecordOffset++
+				continue
+			}
+
+			if !shouldFinish(int64(len(result)), limit) {
+				result = append(result, record)
+				if shouldFinish(int64(len(result)), limit) {
+					break
+				}
+			}
+		}
+		if !resp.More || shouldFinish(int64(len(result)), limit) {
+			keepGoing = false
+		} else {
+			initialKey = string(append(resp.Kvs[len(resp.Kvs)-1].Key, 0)) // append byte 0 (nul char) to the last key
+		}
+	}
+	return result, nil
+}
+
+// Read records from the etcd server based in the key. Database and Table
+// options are highly recommended, otherwise we'll use a default one (which
+// might not have the requested keys)
+//
+// If no prefix or suffix option is provided, we'll read the record matching
+// the provided key. Note that a list of records will be provided anyway,
+// likely with only one record (the one requested)
+//
+// Prefix and suffix options are supported and should perform fine even with
+// a large amount of data. Note that the limit option should also be included
+// in order to limit the amount of records we need to fetch.
+//
+// Note that using both prefix and suffix options at the same time is possible
+// but discouraged. A prefix search will be send to the etcd server, and from
+// there we'll manually pick the records matching the suffix. This might become
+// very inefficient since we might need to request more data to the etcd
+// multiple times in order to provide the results asked.
+// Usage of the offset option is also discouraged because we'll have to request
+// records that we'll have to skip manually on our side.
+//
+// Don't rely on any particular order of the keys. The records are expected to
+// be sorted by key except if the suffix option (suffix without prefix) is
+// used. In this case, the keys will be sorted based on the reversed key
+func (es *EtcdStore) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
+	ropts := store.ReadOptions{}
+	for _, opt := range opts {
+		opt(&ropts)
+	}
+
+	prefix := buildPrefix(ropts.Database, ropts.Table, prefixNS)
+	suffix := buildPrefix(ropts.Database, ropts.Table, suffixNS)
+
+	kv := es.client.KV
+	preKv := namespace.NewKV(kv, prefix)
+	sufKv := namespace.NewKV(kv, suffix)
+
+	if ropts.Prefix && ropts.Suffix {
+		return es.prefixSuffixRead(preKv, key, key, int64(ropts.Limit), int64(ropts.Offset))
+	}
+
+	if ropts.Prefix {
+		return es.prefixRead(preKv, key, int64(ropts.Limit), int64(ropts.Offset))
+	}
+
+	if ropts.Suffix {
+		return es.prefixRead(sufKv, reverseString(key), int64(ropts.Limit), int64(ropts.Offset))
+	}
+
+	return es.directRead(preKv, key)
+}
+
+// Delete the record containing the key provided. Database and Table
+// options are highly recommended, otherwise we'll use a default one (which
+// might not have the requested keys)
+//
+// Since the Write method inserts 2 entries for a given key, those both
+// entries will also be removed using the same key. This is handled
+// transparently.
+func (es *EtcdStore) Delete(key string, opts ...store.DeleteOption) error {
+	dopts := store.DeleteOptions{}
+	for _, opt := range opts {
+		opt(&dopts)
+	}
+
+	prefix := buildPrefix(dopts.Database, dopts.Table, prefixNS)
+	suffix := buildPrefix(dopts.Database, dopts.Table, suffixNS)
+
+	kv := es.client.KV
+
+	ctx, cancel := es.getCtx()
+	_, err := kv.Txn(ctx).Then(
+		clientv3.OpDelete(prefix+key),
+		clientv3.OpDelete(suffix+reverseString(key)),
+	).Commit()
+	cancel()
+
+	return err
+}
+
+// List the keys based on the provided prefix. Use the empty string (and no
+// limit nor offset) to list all keys available.
+// Limit and offset options are available to limit the keys we need to return.
+// The reverse option will reverse the keys before returning them. Use it when
+// listing the keys from the suffix KV.
+//
+// Note that values for the keys won't be requested to the etcd server, that's
+// why the reverse option is important
+func (es *EtcdStore) listKeys(kv clientv3.KV, prefixKey string, limit, offset int64, reverse bool) ([]string, error) {
+	getOptions := []clientv3.OpOption{
+		clientv3.WithKeysOnly(),
+		clientv3.WithPrefix(),
+	}
+	if limit > 0 {
+		getOptions = append(getOptions, clientv3.WithLimit(limit+offset))
+	}
+
+	ctx, cancel := es.getCtx()
+	resp, err := kv.Get(ctx, prefixKey, getOptions...)
+	cancel()
+	if err != nil {
+		return nil, err
+	}
+
+	return processListResponse(resp, offset, reverse)
+}
+
+// List the keys matching both prefix and suffix, with the provided limit and
+// offset. Usage of this function is discouraged because we'll have to match
+// the suffix manually on our side, which means we'll likely need to perform
+// additional requests to the etcd server to get more results matching all the
+// requirements.
+func (es *EtcdStore) prefixSuffixList(kv clientv3.KV, prefix, suffix string, limit, offset int64) ([]string, error) {
+	firstKeyOut := firstKeyOutOfPrefixString(prefix)
+	getOptions := []clientv3.OpOption{
+		clientv3.WithKeysOnly(),
+		clientv3.WithRange(firstKeyOut),
+	}
+	if firstKeyOut == "" {
+		// could happen of all bytes are "\xff"
+		getOptions = getOptions[:1] // remove the WithRange option
+	}
+
+	if limit > 0 {
+		// unlikely to find all the entries we need within offset + limit
+		getOptions = append(getOptions, clientv3.WithLimit((limit+offset)*2))
+	}
+
+	var currentRecordOffset int64
+	result := []string{}
+	initialKey := prefix
+
+	keepGoing := true
+	for keepGoing {
+		ctx, cancel := es.getCtx()
+		resp, respErr := kv.Get(ctx, initialKey, getOptions...)
+		cancel()
+		if respErr != nil {
+			return nil, respErr
+		}
+
+		keys, err := processListResponse(resp, 0, false)
+		if err != nil {
+			return nil, err
+		}
+		for _, key := range keys {
+			if !strings.HasSuffix(key, suffix) {
+				continue
+			}
+
+			if currentRecordOffset < offset {
+				currentRecordOffset++
+				continue
+			}
+
+			if !shouldFinish(int64(len(result)), limit) {
+				result = append(result, key)
+				if shouldFinish(int64(len(result)), limit) {
+					break
+				}
+			}
+		}
+		if !resp.More || shouldFinish(int64(len(result)), limit) {
+			keepGoing = false
+		} else {
+			initialKey = string(append(resp.Kvs[len(resp.Kvs)-1].Key, 0)) // append byte 0 (nul char) to the last key
+		}
+	}
+	return result, nil
+}
+
+// List the keys available in the etcd server. Database and Table
+// options are highly recommended, otherwise we'll use a default one (which
+// might not have the requested keys)
+//
+// With the Database and Table options, all the keys returned will be within
+// that database and table. Each service is expected to use a different
+// database + table, so using those options will list only the keys used by
+// that particular service.
+//
+// Prefix and suffix options are available along with the limit and offset
+// ones.
+//
+// Using prefix and suffix options at the same time is discourage because
+// the suffix matching will be done on our side, and we'll likely need to
+// perform multiple requests to get the requested results. Note that using
+// just the suffix option is fine.
+// In addition, using the offset option is also discouraged because we'll
+// need to request additional keys that will be skipped on our side.
+func (es *EtcdStore) List(opts ...store.ListOption) ([]string, error) {
+	lopts := store.ListOptions{}
+	for _, opt := range opts {
+		opt(&lopts)
+	}
+
+	prefix := buildPrefix(lopts.Database, lopts.Table, prefixNS)
+	suffix := buildPrefix(lopts.Database, lopts.Table, suffixNS)
+
+	kv := es.client.KV
+	preKv := namespace.NewKV(kv, prefix)
+	sufKv := namespace.NewKV(kv, suffix)
+
+	if lopts.Prefix != "" && lopts.Suffix != "" {
+		return es.prefixSuffixList(preKv, lopts.Prefix, lopts.Suffix, int64(lopts.Limit), int64(lopts.Offset))
+	}
+
+	if lopts.Prefix != "" {
+		return es.listKeys(preKv, lopts.Prefix, int64(lopts.Limit), int64(lopts.Offset), false)
+	}
+
+	if lopts.Suffix != "" {
+		return es.listKeys(sufKv, reverseString(lopts.Suffix), int64(lopts.Limit), int64(lopts.Offset), true)
+	}
+
+	return es.listKeys(preKv, "", int64(lopts.Limit), int64(lopts.Offset), false)
+}
+
+// Close the client
+func (es *EtcdStore) Close() error {
+	return es.client.Close()
+}
+
+// Return the service name
+func (es *EtcdStore) String() string {
+	return "Etcd"
+}
diff --git a/ocis-pkg/store/etcd/utils.go b/ocis-pkg/store/etcd/utils.go
new file mode 100644
index 00000000000..c88c425015b
--- /dev/null
+++ b/ocis-pkg/store/etcd/utils.go
@@ -0,0 +1,65 @@
+package etcd
+
+import (
+	"strings"
+)
+
+// Returns true if the limit isn't 0 AND is greater or equal to the number
+// of results.
+// If the limit is 0 or the number of items is less than the number of items,
+// it will return false
+func shouldFinish(numberOfResults, limit int64) bool {
+	if limit == 0 || numberOfResults < limit {
+		return false
+	}
+	return true
+}
+
+// Return the first key out of the prefix represented by the parameter,
+// as a byte sequence. Note that it applies to byte sequences and not
+// rune sequences, so it might be ill-suited for multi-byte chars
+func firstKeyOutOfPrefix(src []byte) []byte {
+	dst := make([]byte, len(src))
+	copy(dst, src)
+	var i int
+	for i = len(dst) - 1; i >= 0; i-- {
+		if dst[i] < 255 {
+			dst[i]++
+			break
+		}
+	}
+	return dst[:i+1]
+}
+
+// Return the first key out of the prefix represented by the parameter.
+// This function relies on the firstKeyOutOfPrefix one, which uses a byte
+// sequence, so it might be ill-suited if the string contains multi-byte chars.
+func firstKeyOutOfPrefixString(src string) string {
+	srcBytes := []byte(src)
+	dstBytes := firstKeyOutOfPrefix(srcBytes)
+	return string(dstBytes)
+}
+
+// Reverse the string based on the containing runes
+func reverseString(s string) string {
+	r := []rune(s)
+	for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
+		r[i], r[j] = r[j], r[i]
+	}
+	return string(r)
+}
+
+// Build a string based on the parts, to be used as a prefix. Empty string is
+// expected if no part is passed as parameter.
+// The string will contain all the parts separated by '/'. The last char will
+// also be '/'
+//
+// For example `buildPrefix(P1, P2, P3)` will return  "P1/P2/P3/"
+func buildPrefix(parts ...string) string {
+	var b strings.Builder
+	for _, part := range parts {
+		b.WriteString(part)
+		b.WriteRune('/')
+	}
+	return b.String()
+}
diff --git a/ocis-pkg/store/memory/memstore.go b/ocis-pkg/store/memory/memstore.go
new file mode 100644
index 00000000000..bf8e1d6b951
--- /dev/null
+++ b/ocis-pkg/store/memory/memstore.go
@@ -0,0 +1,513 @@
+package memory
+
+import (
+	"container/list"
+	"context"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/armon/go-radix"
+	"go-micro.dev/v4/store"
+)
+
+// In-memory store implementation using radix tree for fast prefix and suffix
+// searches.
+// Insertions are expected to be a bit slow due to the data structures, but
+// searches are expected to be fast, including exact key search, as well as
+// prefix and suffix searches (based on the number of elements to be returned).
+// Prefix+suffix search isn't optimized and will depend on how many items we
+// need to skip.
+// It's also recommended to use reasonable limits when using prefix or suffix
+// searches because we'll need to traverse the data structures to provide the
+// results. The traversal will stop a soon as we have the required number of
+// results, so it will be faster if we use a short limit.
+//
+// The overall performance will depend on how the radix trees are built.
+// The number of elements won't directly affect the performance but how the
+// keys are dispersed. The more dispersed the keys are, the faster the search
+// will be, regardless of the number of keys. This happens due to the number
+// of hops we need to do to reach the target element.
+// This also mean that if the keys are too similar, the performance might be
+// slower than expected even if the number of elements isn't too big.
+type MemStore struct {
+	preRadix     *radix.Tree
+	sufRadix     *radix.Tree
+	evictionList *list.List
+
+	options store.Options
+
+	lockGlob     sync.RWMutex
+	lockEvicList sync.RWMutex // Read operation will modify the eviction list
+}
+
+type storeRecord struct {
+	Key       string
+	Value     []byte
+	Metadata  map[string]interface{}
+	Expiry    time.Duration
+	ExpiresAt time.Time
+}
+
+type contextKey string
+
+var targetContextKey contextKey
+
+// Prepare a context to be used with the memory implementation. The context
+// is used to set up custom parameters to the specific implementation.
+// In this case, you can configure the maximum capacity for the MemStore
+// implementation as shown below.
+// ```
+// cache := NewMemStore(
+//   store.WithContext(
+//     NewContext(
+//       ctx,
+//       map[string]interface{}{
+//         "maxCap": 50,
+//       },
+//     ),
+//   ),
+// )
+// ```
+//
+// Available options for the MemStore are:
+// * "maxCap" -> 512 (int) The maximum number of elements the cache will hold.
+// Adding additional elements will remove old elements to ensure we aren't over
+// the maximum capacity.
+//
+// For convenience, this can also be used for the MultiMemStore.
+func NewContext(ctx context.Context, storeParams map[string]interface{}) context.Context {
+	return context.WithValue(ctx, targetContextKey, storeParams)
+}
+
+// Create a new MemStore instance
+func NewMemStore(opts ...store.Option) store.Store {
+	m := &MemStore{}
+	_ = m.Init(opts...)
+	return m
+}
+
+// Get the maximum capacity configured. If no maxCap has been configured
+// (via `NewContext`), 512 will be used as maxCap.
+func (m *MemStore) getMaxCap() int {
+	maxCap := 512
+
+	ctx := m.options.Context
+	if ctx == nil {
+		return maxCap
+	}
+
+	ctxValue := ctx.Value(targetContextKey)
+	if ctxValue == nil {
+		return maxCap
+	}
+	additionalOpts := ctxValue.(map[string]interface{})
+
+	confCap, exists := additionalOpts["maxCap"]
+	if exists {
+		maxCap = confCap.(int)
+	}
+	return maxCap
+}
+
+// Initialize the MemStore. If the MemStore was used, this will reset
+// all the internal structures and the new options (passed as parameters)
+// will be used.
+func (m *MemStore) Init(opts ...store.Option) error {
+	optList := store.Options{}
+	for _, opt := range opts {
+		opt(&optList)
+	}
+
+	m.lockGlob.Lock()
+	defer m.lockGlob.Unlock()
+
+	m.preRadix = radix.New()
+	m.sufRadix = radix.New()
+	m.evictionList = list.New()
+	m.options = optList
+
+	return nil
+}
+
+// Get the options being used
+func (m *MemStore) Options() store.Options {
+	m.lockGlob.RLock()
+	defer m.lockGlob.RUnlock()
+
+	return m.options
+}
+
+// Write the record in the MemStore.
+// Note that Database and Table options will be ignored.
+// Expiration options will take the following precedence:
+// TTL option > expiration option > TTL record
+//
+// New elements will take the last position in the eviction list. Updating
+// an element will also move the element to the last position.
+//
+// Although not recommended, new elements might be inserted with an
+// already-expired date
+func (m *MemStore) Write(r *store.Record, opts ...store.WriteOption) error {
+	var element *list.Element
+
+	wopts := store.WriteOptions{}
+	for _, opt := range opts {
+		opt(&wopts)
+	}
+	cRecord := toStoreRecord(r, wopts)
+
+	m.lockGlob.Lock()
+	defer m.lockGlob.Unlock()
+
+	ele, exists := m.preRadix.Get(cRecord.Key)
+	if exists {
+		element = ele.(*list.Element)
+		element.Value = cRecord
+
+		m.evictionList.MoveToBack(element)
+	} else {
+		if m.evictionList.Len() >= m.getMaxCap() {
+			elementToDelete := m.evictionList.Front()
+			if elementToDelete != nil {
+				recordToDelete := elementToDelete.Value.(*storeRecord)
+				_, _ = m.preRadix.Delete(recordToDelete.Key)
+				_, _ = m.sufRadix.Delete(recordToDelete.Key)
+				m.evictionList.Remove(elementToDelete)
+			}
+		}
+		element = m.evictionList.PushBack(cRecord)
+		_, _ = m.preRadix.Insert(cRecord.Key, element)
+		_, _ = m.sufRadix.Insert(reverseString(cRecord.Key), element)
+	}
+	return nil
+}
+
+// Read the key from the MemStore. A list of records will be returned even if
+// you're asking for the exact key (only one record is expected in that case).
+//
+// Reading the exact element will move such element to the last position of
+// the eviction list. This WON'T apply for prefix and / or suffix reads.
+//
+// This method guarantees that no expired element will be returned. For the
+// case of exact read, the element will be removed and a "not found" error
+// will be returned.
+// For prefix and suffix reads, all the elements that we traverse through
+// will be removed. This includes the elements we need to skip as well as
+// the elements that might have gotten into the the result. Note that the
+// elements that are over the limit won't be touched
+//
+// All read options are supported except Database and Table.
+//
+// For prefix and prefix+suffix options, the records will be returned in
+// alphabetical order on the keys.
+// For the suffix option (just suffix, no prefix), the records will be
+// returned in alphabetical order after reversing the keys. This means,
+// reverse all the keys and then sort them alphabetically. This just affects
+// the sorting order; the keys will be returned as expected.
+// This means that ["aboz", "caaz", "ziuz"] will be sorted as ["caaz", "aboz", "ziuz"]
+// for the key "z" as suffix.
+//
+// Note that offset are supported but not recommended. There is no direct access
+// to the record X. We'd need to skip all the records until we reach the specified
+// offset, which could be problematic.
+// Performance for prefix and suffix searches should be good assuming we limit
+// the number of results we need to return.
+func (m *MemStore) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
+	var element *list.Element
+
+	ropts := store.ReadOptions{}
+	for _, opt := range opts {
+		opt(&ropts)
+	}
+
+	if !ropts.Prefix && !ropts.Suffix {
+		m.lockGlob.RLock()
+		ele, exists := m.preRadix.Get(key)
+		if !exists {
+			m.lockGlob.RUnlock()
+			return nil, store.ErrNotFound
+		}
+
+		element = ele.(*list.Element)
+		record := element.Value.(*storeRecord)
+		if record.Expiry != 0 && record.ExpiresAt.Before(time.Now()) {
+			// record expired -> need to delete
+			m.lockGlob.RUnlock()
+			m.lockGlob.Lock()
+			defer m.lockGlob.Unlock()
+
+			m.evictionList.Remove(element)
+			_, _ = m.preRadix.Delete(key)
+			_, _ = m.sufRadix.Delete(reverseString(key))
+			return nil, store.ErrNotFound
+		}
+
+		m.lockEvicList.Lock()
+		m.evictionList.MoveToBack(element)
+		m.lockEvicList.Unlock()
+
+		foundRecords := []*store.Record{
+			fromStoreRecord(record),
+		}
+		m.lockGlob.RUnlock()
+
+		return foundRecords, nil
+	}
+
+	records := []*store.Record{}
+	expiredElements := make(map[string]*list.Element)
+
+	m.lockGlob.RLock()
+	if ropts.Prefix && ropts.Suffix {
+		// if we need to check both prefix and suffix, go through the
+		// prefix tree and skip elements without the right suffix. We
+		// don't need to check the suffix tree because the elements
+		// must be in both trees
+		m.preRadix.WalkPrefix(key, m.radixTreeCallBackCheckSuffix(ropts.Offset, ropts.Limit, key, &records, expiredElements))
+	} else {
+		if ropts.Prefix {
+			m.preRadix.WalkPrefix(key, m.radixTreeCallBack(ropts.Offset, ropts.Limit, &records, expiredElements))
+		}
+		if ropts.Suffix {
+			m.sufRadix.WalkPrefix(reverseString(key), m.radixTreeCallBack(ropts.Offset, ropts.Limit, &records, expiredElements))
+		}
+	}
+	m.lockGlob.RUnlock()
+
+	// if there are expired elements, get a write lock and delete the expired elements
+	if len(expiredElements) > 0 {
+		m.lockGlob.Lock()
+		for key, element := range expiredElements {
+			m.evictionList.Remove(element)
+			_, _ = m.preRadix.Delete(key)
+			_, _ = m.sufRadix.Delete(reverseString(key))
+		}
+		m.lockGlob.Unlock()
+	}
+	return records, nil
+}
+
+// Remove the record based on the key. It won't return any error if it's missing
+//
+// Database and Table options aren't supported
+func (m *MemStore) Delete(key string, opts ...store.DeleteOption) error {
+	m.lockGlob.Lock()
+	defer m.lockGlob.Unlock()
+
+	ele, exists := m.preRadix.Get(key)
+	if exists {
+		element := ele.(*list.Element)
+		m.evictionList.Remove(element)
+		_, _ = m.preRadix.Delete(key)
+		_, _ = m.sufRadix.Delete(reverseString(key))
+	}
+	return nil
+}
+
+// List the keys currently used in the MemStore
+//
+// All options are supported except Database and Table
+//
+// For prefix and prefix+suffix options, the keys will be returned in
+// alphabetical order.
+// For the suffix option (just suffix, no prefix), the keys will be
+// returned in alphabetical order after reversing the keys. This means,
+// reverse all the keys and then sort them alphabetically. This just affects
+// the sorting order; the keys will be returned as expected.
+// This means that ["aboz", "caaz", "ziuz"] will be sorted as ["caaz", "aboz", "ziuz"]
+func (m *MemStore) List(opts ...store.ListOption) ([]string, error) {
+	records := []string{}
+	expiredElements := make(map[string]*list.Element)
+
+	lopts := store.ListOptions{}
+	for _, opt := range opts {
+		opt(&lopts)
+	}
+
+	if lopts.Prefix == "" && lopts.Suffix == "" {
+		m.lockGlob.RLock()
+		m.preRadix.Walk(m.radixTreeCallBackKeysOnly(lopts.Offset, lopts.Limit, &records, expiredElements))
+		m.lockGlob.RUnlock()
+
+		// if there are expired elements, get a write lock and delete the expired elements
+		if len(expiredElements) > 0 {
+			m.lockGlob.Lock()
+			for key, element := range expiredElements {
+				m.evictionList.Remove(element)
+				_, _ = m.preRadix.Delete(key)
+				_, _ = m.sufRadix.Delete(reverseString(key))
+			}
+			m.lockGlob.Unlock()
+		}
+		return records, nil
+	}
+
+	m.lockGlob.RLock()
+	if lopts.Prefix != "" && lopts.Suffix != "" {
+		// if we need to check both prefix and suffix, go through the
+		// prefix tree and skip elements without the right suffix. We
+		// don't need to check the suffix tree because the elements
+		// must be in both trees
+		m.preRadix.WalkPrefix(lopts.Prefix, m.radixTreeCallBackKeysOnlyWithSuffix(lopts.Offset, lopts.Limit, lopts.Suffix, &records, expiredElements))
+	} else {
+		if lopts.Prefix != "" {
+			m.preRadix.WalkPrefix(lopts.Prefix, m.radixTreeCallBackKeysOnly(lopts.Offset, lopts.Limit, &records, expiredElements))
+		}
+		if lopts.Suffix != "" {
+			m.sufRadix.WalkPrefix(reverseString(lopts.Suffix), m.radixTreeCallBackKeysOnly(lopts.Offset, lopts.Limit, &records, expiredElements))
+		}
+	}
+	m.lockGlob.RUnlock()
+
+	// if there are expired elements, get a write lock and delete the expired elements
+	if len(expiredElements) > 0 {
+		m.lockGlob.Lock()
+		for key, element := range expiredElements {
+			m.evictionList.Remove(element)
+			_, _ = m.preRadix.Delete(key)
+			_, _ = m.sufRadix.Delete(reverseString(key))
+		}
+		m.lockGlob.Unlock()
+	}
+	return records, nil
+}
+
+func (m *MemStore) Close() error {
+	return nil
+}
+
+func (m *MemStore) String() string {
+	return "RadixMemStore"
+}
+
+func (m *MemStore) Len() (int, bool) {
+	eLen := m.evictionList.Len()
+	pLen := m.preRadix.Len()
+	sLen := m.sufRadix.Len()
+	if eLen == pLen && eLen == sLen {
+		return eLen, true
+	}
+	return 0, false
+}
+
+func (m *MemStore) radixTreeCallBack(offset, limit uint, result *[]*store.Record, expiredElements map[string]*list.Element) radix.WalkFn {
+	currentIndex := new(uint) // needs to be a pointer so the value persist across callback calls
+	maxIndex := new(uint)     // needs to be a pointer so the value persist across callback calls
+	*maxIndex = offset + limit
+	return func(key string, value interface{}) bool {
+		element := value.(*list.Element)
+		record := element.Value.(*storeRecord)
+
+		if record.Expiry != 0 && record.ExpiresAt.Before(time.Now()) {
+			// record has expired -> add element to the expiredElements map
+			// and jump directly to the next element without increasing the index
+			expiredElements[record.Key] = element
+			return false
+		}
+
+		if *currentIndex >= offset && (*currentIndex < *maxIndex || *maxIndex == offset) {
+			// if it's within expected range, add a copy to the results
+			*result = append(*result, fromStoreRecord(record))
+		}
+
+		*currentIndex++
+
+		if *currentIndex < *maxIndex || *maxIndex == offset {
+			return false
+		}
+		return true
+	}
+}
+
+func (m *MemStore) radixTreeCallBackCheckSuffix(offset, limit uint, presuf string, result *[]*store.Record, expiredElements map[string]*list.Element) radix.WalkFn {
+	currentIndex := new(uint) // needs to be a pointer so the value persist across callback calls
+	maxIndex := new(uint)     // needs to be a pointer so the value persist across callback calls
+	*maxIndex = offset + limit
+	return func(key string, value interface{}) bool {
+		if !strings.HasSuffix(key, presuf) {
+			return false
+		}
+
+		element := value.(*list.Element)
+		record := element.Value.(*storeRecord)
+
+		if record.Expiry != 0 && record.ExpiresAt.Before(time.Now()) {
+			// record has expired -> add element to the expiredElements map
+			// and jump directly to the next element without increasing the index
+			expiredElements[record.Key] = element
+			return false
+		}
+
+		if *currentIndex >= offset && (*currentIndex < *maxIndex || *maxIndex == offset) {
+			*result = append(*result, fromStoreRecord(record))
+		}
+
+		*currentIndex++
+
+		if *currentIndex < *maxIndex || *maxIndex == offset {
+			return false
+		}
+		return true
+	}
+}
+
+func (m *MemStore) radixTreeCallBackKeysOnly(offset, limit uint, result *[]string, expiredElements map[string]*list.Element) radix.WalkFn {
+	currentIndex := new(uint) // needs to be a pointer so the value persist across callback calls
+	maxIndex := new(uint)     // needs to be a pointer so the value persist across callback calls
+	*maxIndex = offset + limit
+	return func(key string, value interface{}) bool {
+		element := value.(*list.Element)
+		record := element.Value.(*storeRecord)
+
+		if record.Expiry != 0 && record.ExpiresAt.Before(time.Now()) {
+			// record has expired -> add element to the expiredElements map
+			// and jump directly to the next element without increasing the index
+			expiredElements[record.Key] = element
+			return false
+		}
+
+		if *currentIndex >= offset && (*currentIndex < *maxIndex || *maxIndex == offset) {
+			*result = append(*result, record.Key)
+		}
+
+		*currentIndex++
+
+		if *currentIndex < *maxIndex || *maxIndex == offset {
+			return false
+		}
+		return true
+	}
+}
+
+func (m *MemStore) radixTreeCallBackKeysOnlyWithSuffix(offset, limit uint, presuf string, result *[]string, expiredElements map[string]*list.Element) radix.WalkFn {
+	currentIndex := new(uint) // needs to be a pointer so the value persist across callback calls
+	maxIndex := new(uint)     // needs to be a pointer so the value persist across callback calls
+	*maxIndex = offset + limit
+	return func(key string, value interface{}) bool {
+		if !strings.HasSuffix(key, presuf) {
+			return false
+		}
+
+		element := value.(*list.Element)
+		record := element.Value.(*storeRecord)
+
+		if record.Expiry != 0 && record.ExpiresAt.Before(time.Now()) {
+			// record has expired -> add element to the expiredElements map
+			// and jump directly to the next element without increasing the index
+			expiredElements[record.Key] = element
+			return false
+		}
+
+		if *currentIndex >= offset && (*currentIndex < *maxIndex || *maxIndex == offset) {
+			*result = append(*result, record.Key)
+		}
+
+		*currentIndex++
+
+		if *currentIndex < *maxIndex || *maxIndex == offset {
+			return false
+		}
+		return true
+	}
+}
diff --git a/ocis-pkg/store/memory/multimemstore.go b/ocis-pkg/store/memory/multimemstore.go
new file mode 100644
index 00000000000..89756c994ca
--- /dev/null
+++ b/ocis-pkg/store/memory/multimemstore.go
@@ -0,0 +1,158 @@
+package memory
+
+import (
+	"sync"
+
+	"go-micro.dev/v4/store"
+)
+
+// In-memory store implementation using multiple MemStore to provide support
+// for multiple databases and tables.
+// Each table will be mapped to its own MemStore, which will be completely
+// isolated from the rest. In particular, each MemStore will have its own
+// capacity, so it's possible to have 10 MemStores with full capacity (512
+// by default)
+//
+// The options will be the same for all MemStores unless they're explicitly
+// initialized otherwise.
+//
+// Since each MemStore is isolated, the required synchronization caused by
+// concurrency will be minimal if the threads use different tables
+type MultiMemStore struct {
+	storeMap     map[string]*MemStore
+	storeMapLock sync.RWMutex
+	genOpts      []store.Option
+}
+
+// Create a new MultiMemStore. A new MemStore will be mapped based on the options.
+// A default MemStore will be mapped if no Database and Table aren't used.
+func NewMultiMemStore(opts ...store.Option) store.Store {
+	m := &MultiMemStore{
+		storeMap: make(map[string]*MemStore),
+		genOpts:  opts,
+	}
+	_ = m.Init(opts...)
+	return m
+}
+
+func (m *MultiMemStore) getMemStore(prefix string) *MemStore {
+	m.storeMapLock.RLock()
+	mStore, exists := m.storeMap[prefix]
+
+	if exists {
+		m.storeMapLock.RUnlock()
+		return mStore
+	}
+
+	m.storeMapLock.RUnlock()
+
+	// if not exists
+	newStore := NewMemStore(m.genOpts...).(*MemStore)
+
+	m.storeMapLock.Lock()
+	m.storeMap[prefix] = newStore
+	m.storeMapLock.Unlock()
+	return newStore
+}
+
+// Initialize the mapped MemStore based on the Database and Table values
+// from the options with the same options. The target MemStore will be
+// reinitialized if needed.
+func (m *MultiMemStore) Init(opts ...store.Option) error {
+	optList := store.Options{}
+	for _, opt := range opts {
+		opt(&optList)
+	}
+
+	prefix := optList.Database + "/" + optList.Table
+
+	mStore := m.getMemStore(prefix)
+	return mStore.Init(opts...)
+}
+
+// Get the options used to create the MultiMemStore.
+// Specific options for each MemStore aren't available
+func (m *MultiMemStore) Options() store.Options {
+	optList := store.Options{}
+	for _, opt := range m.genOpts {
+		opt(&optList)
+	}
+	return optList
+}
+
+// Write the record in the target MemStore based on the Database and Table
+// values from the options. A default MemStore will be used if no Database
+// and Table options are provided.
+// The write options will be forwarded to the target MemStore
+func (m *MultiMemStore) Write(r *store.Record, opts ...store.WriteOption) error {
+	wopts := store.WriteOptions{}
+	for _, opt := range opts {
+		opt(&wopts)
+	}
+
+	prefix := wopts.Database + "/" + wopts.Table
+
+	mStore := m.getMemStore(prefix)
+	return mStore.Write(r, opts...)
+}
+
+// Read the matching records in the target MemStore based on the Database and Table
+// values from the options. A default MemStore will be used if no Database
+// and Table options are provided.
+// The read options will be forwarded to the target MemStore.
+//
+// The expectations regarding the results (sort order, eviction policies, etc)
+// will be the same as the target MemStore
+func (m *MultiMemStore) Read(key string, opts ...store.ReadOption) ([]*store.Record, error) {
+	ropts := store.ReadOptions{}
+	for _, opt := range opts {
+		opt(&ropts)
+	}
+
+	prefix := ropts.Database + "/" + ropts.Table
+
+	mStore := m.getMemStore(prefix)
+	return mStore.Read(key, opts...)
+}
+
+// Delete the matching records in the target MemStore based on the Database and Table
+// values from the options. A default MemStore will be used if no Database
+// and Table options are provided.
+//
+// Matching records from other Tables won't be affected. In fact, we won't
+// access to other Tables
+func (m *MultiMemStore) Delete(key string, opts ...store.DeleteOption) error {
+	dopts := store.DeleteOptions{}
+	for _, opt := range opts {
+		opt(&dopts)
+	}
+
+	prefix := dopts.Database + "/" + dopts.Table
+
+	mStore := m.getMemStore(prefix)
+	return mStore.Delete(key, opts...)
+}
+
+// List the keys in the target MemStore based on the Database and Table
+// values from the options. A default MemStore will be used if no Database
+// and Table options are provided.
+// The list options will be forwarded to the target MemStore.
+func (m *MultiMemStore) List(opts ...store.ListOption) ([]string, error) {
+	lopts := store.ListOptions{}
+	for _, opt := range opts {
+		opt(&lopts)
+	}
+
+	prefix := lopts.Database + "/" + lopts.Table
+
+	mStore := m.getMemStore(prefix)
+	return mStore.List(opts...)
+}
+
+func (m *MultiMemStore) Close() error {
+	return nil
+}
+
+func (m *MultiMemStore) String() string {
+	return "MultiRadixMemStore"
+}
diff --git a/ocis-pkg/store/memory/utils.go b/ocis-pkg/store/memory/utils.go
new file mode 100644
index 00000000000..773567f6c45
--- /dev/null
+++ b/ocis-pkg/store/memory/utils.go
@@ -0,0 +1,63 @@
+package memory
+
+import (
+	"time"
+
+	"go-micro.dev/v4/store"
+)
+
+func toStoreRecord(src *store.Record, options store.WriteOptions) *storeRecord {
+	newRecord := &storeRecord{}
+	newRecord.Key = src.Key
+	newRecord.Value = make([]byte, len(src.Value))
+	copy(newRecord.Value, src.Value)
+
+	// set base ttl duration and expiration time based on the record
+	newRecord.Expiry = src.Expiry
+	if src.Expiry != 0 {
+		newRecord.ExpiresAt = time.Now().Add(src.Expiry)
+	}
+
+	// overwrite ttl duration and expiration time based on options
+	if !options.Expiry.IsZero() {
+		// options.Expiry is a time.Time, newRecord.Expiry is a time.Duration
+		newRecord.Expiry = time.Until(options.Expiry)
+		newRecord.ExpiresAt = options.Expiry
+	}
+
+	// TTL option takes precedence over expiration time
+	if options.TTL != 0 {
+		newRecord.Expiry = options.TTL
+		newRecord.ExpiresAt = time.Now().Add(options.TTL)
+	}
+
+	newRecord.Metadata = make(map[string]interface{})
+	for k, v := range src.Metadata {
+		newRecord.Metadata[k] = v
+	}
+	return newRecord
+}
+
+func fromStoreRecord(src *storeRecord) *store.Record {
+	newRecord := &store.Record{}
+	newRecord.Key = src.Key
+	newRecord.Value = make([]byte, len(src.Value))
+	copy(newRecord.Value, src.Value)
+	if src.Expiry != 0 {
+		newRecord.Expiry = time.Until(src.ExpiresAt)
+	}
+
+	newRecord.Metadata = make(map[string]interface{})
+	for k, v := range src.Metadata {
+		newRecord.Metadata[k] = v
+	}
+	return newRecord
+}
+
+func reverseString(s string) string {
+	r := []rune(s)
+	for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
+		r[i], r[j] = r[j], r[i]
+	}
+	return string(r)
+}
diff --git a/ocis-pkg/store/store.go b/ocis-pkg/store/store.go
new file mode 100644
index 00000000000..f41cc7285c9
--- /dev/null
+++ b/ocis-pkg/store/store.go
@@ -0,0 +1,91 @@
+package store
+
+import (
+	"context"
+	"strings"
+
+	"github.com/owncloud/ocis/v2/ocis-pkg/store/etcd"
+	"github.com/owncloud/ocis/v2/ocis-pkg/store/memory"
+	"go-micro.dev/v4/store"
+)
+
+var (
+	storeEnv        = "OCIS_STORE"
+	storeAddressEnv = "OCIS_STORE_ADDRESS"
+	storeOCMemSize  = "OCIS_STORE_OCMEM_SIZE"
+)
+
+var ocMemStore *store.Store
+
+type OcisStoreOptions struct {
+	Type    string
+	Address string
+	Size    int
+}
+
+// Get the configured key-value store to be used.
+//
+// Each microservice (or whatever piece is using the store) should use the
+// options available in the interface's operations to choose the right database
+// and table to prevent collisions with other microservices.
+// Recommended approach is to use "services" or "ocis-pkg" for the database,
+// and "services/<service-name>/" or "ocis-pkg/<pkg>/" for the package name.
+//
+// So far, only the name of the store and the node addresses are configurable
+// via environment variables.
+// Available options for "OCIS_STORE" are:
+// * "noop", for a noop store (it does nothing)
+// * "etcd", for etcd
+// * "ocmem", custom in-memory implementation, with fixed size and optimized prefix
+// and suffix search
+// * "memory", for a in-memory implementation, which is the default if noone matches
+//
+// "OCIS_STORE_ADDRESS" is a comma-separated list of nodes that the store
+// will use. This is currently usable only with the etcd implementation. If it
+// isn't provided, "127.0.0.1:2379" will be the only node used.
+//
+// "OCIS_STORE_OCMEM_SIZE" will configure the maximum capacity of the cache for
+// the "ocmem" implementation, in number of items that the cache can hold per table.
+// You can use "OCIS_STORE_OCMEM_SIZE=5000" so the cache will hold up to 5000 elements.
+// The parameter only affects to the "ocmem" implementation, the rest will ignore it.
+// If an invalid value is used, the default will be used instead, so up to 512 elements
+// the cache will hold.
+func GetStore(ocisOpts OcisStoreOptions) store.Store {
+	var s store.Store
+
+	addresses := strings.Split(ocisOpts.Address, ",")
+	opts := []store.Option{
+		store.Nodes(addresses...),
+	}
+
+	switch ocisOpts.Type {
+	case "noop":
+		s = store.NewNoopStore(opts...)
+	case "etcd":
+		s = etcd.NewEtcdStore(opts...)
+	case "ocmem":
+		if ocMemStore == nil {
+			var memStore store.Store
+
+			sizeNum := ocisOpts.Size
+			if sizeNum <= 0 {
+				memStore = memory.NewMultiMemStore()
+			} else {
+				memStore = memory.NewMultiMemStore(
+					store.WithContext(
+						memory.NewContext(
+							context.Background(),
+							map[string]interface{}{
+								"maxCap": sizeNum,
+							},
+						)),
+				)
+			}
+			ocMemStore = &memStore
+		}
+		s = *ocMemStore
+	default:
+		s = store.NewMemoryStore(opts...)
+	}
+	return s
+}
diff --git a/services/graph/pkg/config/cachestore.go b/services/graph/pkg/config/cachestore.go
new file mode 100644
index 00000000000..f396ed70011
--- /dev/null
+++ b/services/graph/pkg/config/cachestore.go
@@ -0,0 +1,8 @@
+package config
+
+// CacheStore defines the available configuration for the cache store
+type CacheStore struct {
+	Type    string `yaml:"type" env:"OCIS_CACHE_STORE_TYPE;GRAPH_CACHE_STORE_TYPE" desc:"The type of the cache store. Valid options are \"noop\", \"ocmem\", \"etcd\" and \"memory\""`
+	Address string `yaml:"address" env:"OCIS_CACHE_STORE_ADDRESS;GRAPH_CACHE_STORE_ADDRESS" desc:"a comma-separated list of addresses to connect to. Only for etcd"`
+	Size    int    `yaml:"size" env:"OCIS_CACHE_STORE_SIZE;GRAPH_CACHE_STORE_SIZE" desc:"Maximum size for the cache store. Only ocmem will use this option, in number of items per table. The rest will ignore the option and can grow indefinitely"`
+}
diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go
index 6fea8abc129..8e42344e9fd 100644
--- a/services/graph/pkg/config/config.go
+++ b/services/graph/pkg/config/config.go
@@ -12,9 +12,10 @@ type Config struct {
 
 	Service Service `yaml:"-"`
 
-	Tracing *Tracing `yaml:"tracing"`
-	Log     *Log     `yaml:"log"`
-	Debug   Debug    `yaml:"debug"`
+	Tracing    *Tracing    `yaml:"tracing"`
+	Log        *Log        `yaml:"log"`
+	CacheStore *CacheStore `yaml:"cache_store"`
+	Debug      Debug       `yaml:"debug"`
 
 	HTTP HTTP `yaml:"http"`
 
diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go
index d9f34b54a74..a4648a5578f 100644
--- a/services/graph/pkg/config/defaults/defaultconfig.go
+++ b/services/graph/pkg/config/defaults/defaultconfig.go
@@ -96,6 +96,16 @@ func EnsureDefaults(cfg *config.Config) {
 		cfg.Tracing = &config.Tracing{}
 	}
 
+	if cfg.CacheStore == nil && cfg.Commons != nil && cfg.Commons.CacheStore != nil {
+		cfg.CacheStore = &config.CacheStore{
+			Type:    cfg.Commons.CacheStore.Type,
+			Address: cfg.Commons.CacheStore.Address,
+			Size:    cfg.Commons.CacheStore.Size,
+		}
+	} else if cfg.CacheStore == nil {
+		cfg.CacheStore = &config.CacheStore{}
+	}
+
 	if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
 		cfg.TokenManager = &config.TokenManager{
 			JWTSecret: cfg.Commons.TokenManager.JWTSecret,
diff --git a/services/graph/pkg/service/v0/drives.go b/services/graph/pkg/service/v0/drives.go
index a704c8ab038..8ff4c8c048b 100644
--- a/services/graph/pkg/service/v0/drives.go
+++ b/services/graph/pkg/service/v0/drives.go
@@ -525,7 +525,6 @@ func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, spa
 		Id:   libregraph.PtrString(spaceID),
 		Name: &space.Name,
 		//"createdDateTime": "string (timestamp)", // TODO read from StorageSpace ... needs Opaque for now
-		//"description": "string", // TODO read from StorageSpace ... needs Opaque for now
 		DriveType: &space.SpaceType,
 		Root: &libregraph.DriveItem{
 			Id:          libregraph.PtrString(storagespace.FormatResourceID(spaceRid)),
@@ -572,15 +571,23 @@ func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, spa
 	}
 
 	if baseURL != nil {
-		// TODO read from StorageSpace ... needs Opaque for now
-		// TODO how do we build the url?
-		// for now: read from request
 		webDavURL := *baseURL
 		webDavURL.Path = path.Join(webDavURL.Path, spaceID)
 		drive.Root.WebDavUrl = libregraph.PtrString(webDavURL.String())
 	}
 
-	// TODO The public space has no owner ... should we even show it?
+	webURL, err := url.Parse(g.config.Commons.OcisURL)
+	if err != nil {
+		g.logger.Error().
+			Err(err).
+			Str("url", g.config.Commons.OcisURL).
+			Msg("failed to parse base url")
+		return nil, err
+	}
+
+	webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(spaceRid))
+	drive.WebUrl = libregraph.PtrString(webURL.String())
+
 	if space.Owner != nil && space.Owner.Id != nil {
 		drive.Owner = &libregraph.IdentitySet{
 			User: &libregraph.Identity{
@@ -604,7 +611,6 @@ func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, spa
 			Total: &t,
 		}
 	}
-	// FIXME use coowner from https://github.com/owncloud/open-graph-api
 
 	return drive, nil
 }
diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go
index a60e61583a6..5d39a507ae4 100644
--- a/services/graph/pkg/service/v0/service.go
+++ b/services/graph/pkg/service/v0/service.go
@@ -6,7 +6,6 @@ import (
 	"io/ioutil"
 	"net/http"
 	"strconv"
-	"time"
 
 	"github.com/ReneKroon/ttlcache/v2"
 	"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
@@ -16,6 +15,7 @@ import (
 	ocisldap "github.com/owncloud/ocis/v2/ocis-pkg/ldap"
 	"github.com/owncloud/ocis/v2/ocis-pkg/roles"
 	"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
+	"github.com/owncloud/ocis/v2/ocis-pkg/store"
 	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/identity/ldap"
@@ -144,9 +144,13 @@ func NewService(opts ...Option) Service {
 
 	roleManager := options.RoleManager
 	if roleManager == nil {
+		storeOptions := store.OcisStoreOptions{
+			Type:    options.Config.CacheStore.Type,
+			Address: options.Config.CacheStore.Address,
+			Size:    options.Config.CacheStore.Size,
+		}
 		m := roles.NewManager(
-			roles.CacheSize(1024),
-			roles.CacheTTL(time.Hour),
+			roles.StoreOptions(storeOptions),
 			roles.Logger(options.Logger),
 			roles.RoleService(svc.roleService),
 		)
diff --git a/services/idp/public/index.html b/services/idp/public/index.html
index 8dccae78b8d..a8307ad824d 100644
--- a/services/idp/public/index.html
+++ b/services/idp/public/index.html
@@ -5,7 +5,6 @@
     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
     <meta name="theme-color" content="#1b223d">
     <link rel="shortcut icon" href="%PUBLIC_URL%/static/favicon.ico" type="image/x-icon">
-    <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&display=swap" rel="stylesheet">
     <meta property="csp-nonce" content="__CSP_NONCE__">
     <title>Sign in - ownCloud</title>
   </head>
diff --git a/services/idp/src/app.css b/services/idp/src/app.css
index 531d4a056b0..a2636504c1d 100644
--- a/services/idp/src/app.css
+++ b/services/idp/src/app.css
@@ -11,7 +11,7 @@ html {
 }
 
 body {
-  font-family: Inter, 'Open Sans', sans-serif;
+  font-family: Inter, sans-serif;
 }
 
 strong {
diff --git a/services/notifications/README.md b/services/notifications/README.md
new file mode 100644
index 00000000000..439247e162d
--- /dev/null
+++ b/services/notifications/README.md
@@ -0,0 +1,4 @@
+#### Notification service
+
+The notification service is responsible for sending emails to users informing them about events that happened. To do this it hooks into the event system and listens for certain events that the users need to be informed about.
+
diff --git a/services/ocs/pkg/config/cachestore.go b/services/ocs/pkg/config/cachestore.go
new file mode 100644
index 00000000000..af8fea315cc
--- /dev/null
+++ b/services/ocs/pkg/config/cachestore.go
@@ -0,0 +1,8 @@
+package config
+
+// CacheStore defines the available configuration for the cache store
+type CacheStore struct {
+	Type    string `yaml:"type" env:"OCIS_CACHE_STORE_TYPE;OCS_CACHE_STORE_TYPE" desc:"The type of the cache store. Valid options are \"noop\", \"ocmem\", \"etcd\" and \"memory\""`
+	Address string `yaml:"address" env:"OCIS_CACHE_STORE_ADDRESS;OCS_CACHE_STORE_ADDRESS" desc:"a comma-separated list of addresses to connect to. Only for etcd"`
+	Size    int    `yaml:"size" env:"OCIS_CACHE_STORE_SIZE;OCS_CACHE_STORE_SIZE" desc:"Maximum size for the cache store. Only ocmem will use this option, in number of items per table. The rest will ignore the option and can grow indefinitely"`
+}
diff --git a/services/ocs/pkg/config/config.go b/services/ocs/pkg/config/config.go
index 5ec5d1f0c85..9729fa19d73 100644
--- a/services/ocs/pkg/config/config.go
+++ b/services/ocs/pkg/config/config.go
@@ -12,9 +12,10 @@ type Config struct {
 
 	Service Service `yaml:"-"`
 
-	Tracing *Tracing `yaml:"tracing"`
-	Log     *Log     `yaml:"log"`
-	Debug   Debug    `yaml:"debug"`
+	Tracing    *Tracing    `yaml:"tracing"`
+	Log        *Log        `yaml:"log"`
+	CacheStore *CacheStore `yaml:"cache_store"`
+	Debug      Debug       `yaml:"debug"`
 
 	HTTP HTTP `yaml:"http"`
 
diff --git a/services/ocs/pkg/config/defaults/defaultconfig.go b/services/ocs/pkg/config/defaults/defaultconfig.go
index 4bac1a18cb7..7e9c03ea94b 100644
--- a/services/ocs/pkg/config/defaults/defaultconfig.go
+++ b/services/ocs/pkg/config/defaults/defaultconfig.go
@@ -69,6 +69,16 @@ func EnsureDefaults(cfg *config.Config) {
 		cfg.Tracing = &config.Tracing{}
 	}
 
+	if cfg.CacheStore == nil && cfg.Commons != nil && cfg.Commons.CacheStore != nil {
+		cfg.CacheStore = &config.CacheStore{
+			Type:    cfg.Commons.CacheStore.Type,
+			Address: cfg.Commons.CacheStore.Address,
+			Size:    cfg.Commons.CacheStore.Size,
+		}
+	} else if cfg.CacheStore == nil {
+		cfg.CacheStore = &config.CacheStore{}
+	}
+
 	if cfg.Reva == nil && cfg.Commons != nil && cfg.Commons.Reva != nil {
 		cfg.Reva = &config.Reva{
 			Address: cfg.Commons.Reva.Address,
diff --git a/services/ocs/pkg/service/v0/service.go b/services/ocs/pkg/service/v0/service.go
index 6a568ee35f8..3442948c9ca 100644
--- a/services/ocs/pkg/service/v0/service.go
+++ b/services/ocs/pkg/service/v0/service.go
@@ -2,10 +2,10 @@ package svc
 
 import (
 	"net/http"
-	"time"
 
 	"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
 	"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
+	"github.com/owncloud/ocis/v2/ocis-pkg/store"
 
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/chi/v5/middleware"
@@ -42,9 +42,13 @@ func NewService(opts ...Option) Service {
 	}
 	roleManager := options.RoleManager
 	if roleManager == nil {
+		storeOptions := store.OcisStoreOptions{
+			Type:    options.Config.CacheStore.Type,
+			Address: options.Config.CacheStore.Address,
+			Size:    options.Config.CacheStore.Size,
+		}
 		m := roles.NewManager(
-			roles.CacheSize(1024),
-			roles.CacheTTL(time.Hour*24*7),
+			roles.StoreOptions(storeOptions),
 			roles.Logger(options.Logger),
 			roles.RoleService(roleService),
 		)
diff --git a/services/proxy/pkg/middleware/oidc_auth.go b/services/proxy/pkg/middleware/oidc_auth.go
index c89f83d5ccd..f0dabbd7522 100644
--- a/services/proxy/pkg/middleware/oidc_auth.go
+++ b/services/proxy/pkg/middleware/oidc_auth.go
@@ -32,9 +32,9 @@ type OIDCProvider interface {
 
 // NewOIDCAuthenticator returns a ready to use authenticator which can handle OIDC authentication.
 func NewOIDCAuthenticator(logger log.Logger, tokenCacheTTL int, oidcHTTPClient *http.Client, oidcIss string, providerFunc func() (OIDCProvider, error),
-	jwksOptions config.JWKS, accessTokenVerifyMethod string) OIDCAuthenticator {
+	jwksOptions config.JWKS, accessTokenVerifyMethod string) *OIDCAuthenticator {
 	tokenCache := osync.NewCache(tokenCacheTTL)
-	return OIDCAuthenticator{
+	return &OIDCAuthenticator{
 		Logger:                  logger,
 		tokenCache:              &tokenCache,
 		TokenCacheTTL:           time.Duration(tokenCacheTTL),
@@ -66,7 +66,7 @@ type OIDCAuthenticator struct {
 	JWKS     *keyfunc.JWKS
 }
 
-func (m OIDCAuthenticator) getClaims(token string, req *http.Request) (map[string]interface{}, error) {
+func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[string]interface{}, error) {
 	var claims map[string]interface{}
 	hit := m.tokenCache.Load(token)
 	if hit == nil {
@@ -168,7 +168,7 @@ type jwksJSON struct {
 	JWKSURL string `json:"jwks_uri"`
 }
 
-func (m OIDCAuthenticator) getKeyfunc() *keyfunc.JWKS {
+func (m *OIDCAuthenticator) getKeyfunc() *keyfunc.JWKS {
 	m.jwksLock.Lock()
 	defer m.jwksLock.Unlock()
 	if m.JWKS == nil {
@@ -219,7 +219,7 @@ func (m OIDCAuthenticator) getKeyfunc() *keyfunc.JWKS {
 	return m.JWKS
 }
 
-func (m OIDCAuthenticator) getProvider() OIDCProvider {
+func (m *OIDCAuthenticator) getProvider() OIDCProvider {
 	m.providerLock.Lock()
 	defer m.providerLock.Unlock()
 	if m.provider == nil {
@@ -240,7 +240,7 @@ func (m OIDCAuthenticator) getProvider() OIDCProvider {
 }
 
 // Authenticate implements the authenticator interface to authenticate requests via oidc auth.
-func (m OIDCAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
+func (m *OIDCAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
 	// there is no bearer token on the request,
 	if !m.shouldServe(r) || isPublicPath(r.URL.Path) {
 		// The authentication of public path requests is handled by another authenticator.


Test patch:
diff --git a/ocis-pkg/store/memory/memstore_test.go b/ocis-pkg/store/memory/memstore_test.go
new file mode 100644
index 00000000000..28fc5ca47fc
--- /dev/null
+++ b/ocis-pkg/store/memory/memstore_test.go
@@ -0,0 +1,1506 @@
+package memory
+
+import (
+	"context"
+	"encoding/hex"
+	"hash/fnv"
+	"math/rand"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"sync/atomic"
+
+	"go-micro.dev/v4/store"
+)
+
+func TestWriteAndRead(t *testing.T) {
+	cache := NewMemStore()
+	data := map[string]string{
+		"abaya":      "v329487",
+		"abaaz":      "v398342",
+		"abayakjdkj": "v989898",
+		"zzzz":       "viaooouyenbdnya",
+		"abazzz":     "v57869nbdnya",
+		"mbmbmb":     "viuyenbdnya",
+		"zozzz":      "vooouyenbdnya",
+		"zzaz":       "viaooouyenbdnya",
+		"mbzzaamb":   "viunya",
+	}
+
+	for key, value := range data {
+		record := &store.Record{
+			Key:   key,
+			Value: []byte(value),
+		}
+		_ = cache.Write(record)
+	}
+
+	t.Run("Plain", func(t *testing.T) {
+		readPlain(t, cache)
+	})
+	t.Run("Prefix", func(t *testing.T) {
+		readPrefix(t, cache)
+	})
+	t.Run("Suffix", func(t *testing.T) {
+		readSuffix(t, cache)
+	})
+	t.Run("PrefixSuffix", func(t *testing.T) {
+		readPrefixSuffix(t, cache)
+	})
+	t.Run("PrefixLimitOffset", func(t *testing.T) {
+		readPrefixLimitOffset(t, cache)
+	})
+	t.Run("SuffixLimitOffset", func(t *testing.T) {
+		readSuffixLimitOffset(t, cache)
+	})
+	t.Run("PrefixSuffixLimitOffset", func(t *testing.T) {
+		readPrefixSuffixLimitOffset(t, cache)
+	})
+}
+
+func readPlain(t *testing.T, cache store.Store) {
+	// expected data in the cache
+	data := map[string]string{
+		"abaya":      "v329487",
+		"abaaz":      "v398342",
+		"abayakjdkj": "v989898",
+		"zzzz":       "viaooouyenbdnya",
+		"abazzz":     "v57869nbdnya",
+		"mbmbmb":     "viuyenbdnya",
+		"zozzz":      "vooouyenbdnya",
+		"zzaz":       "viaooouyenbdnya",
+		"mbzzaamb":   "viunya",
+	}
+	for key, value := range data {
+		records, _ := cache.Read(key)
+		if len(records) != 1 {
+			t.Fatalf("Plain read for key %s returned %d records", key, len(records))
+		}
+		if key != records[0].Key {
+			t.Errorf("Plain read for key %s returned got wrong key %s", key, records[0].Key)
+		}
+		v := string(records[0].Value)
+		if value != v {
+			t.Errorf("Plain read for key %s returned different value, expected %s, got %s", key, value, v)
+		}
+	}
+}
+
+func readPrefix(t *testing.T, cache store.Store) {
+	pref1 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "abaya", Value: "v329487"},
+		{Key: "abayakjdkj", Value: "v989898"},
+	}
+
+	pref2 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "zozzz", Value: "vooouyenbdnya"},
+		{Key: "zzaz", Value: "viaooouyenbdnya"},
+		{Key: "zzzz", Value: "viaooouyenbdnya"},
+	}
+
+	records, _ := cache.Read("abaya", store.ReadPrefix())
+	if len(records) != 2 {
+		t.Fatalf("Prefix read for \"abaya\" returned %d records, expected 2", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref1[index].Key != record.Key {
+			t.Errorf("Unexpected key for prefix \"abaya\", index %d, expected %s, got %s", index, pref1[index].Key, record.Key)
+		}
+		if pref1[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for prefix \"abaya\", index %d, expected %s, got %s", index, pref1[index].Value, record.Value)
+		}
+	}
+
+	records, _ = cache.Read("z", store.ReadPrefix())
+	if len(records) != 3 {
+		t.Fatalf("Prefix read for \"z\" returned %d records, expected 3", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref2[index].Key != record.Key {
+			t.Errorf("Unexpected key for prefix \"z\", index %d, expected %s, got %s", index, pref2[index].Key, record.Key)
+		}
+		if pref2[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for prefix \"z\", index %d, expected %s, got %s", index, pref2[index].Value, record.Value)
+		}
+	}
+}
+
+func readSuffix(t *testing.T, cache store.Store) {
+	pref1 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "abaaz", Value: "v398342"},
+		{Key: "zzaz", Value: "viaooouyenbdnya"},
+	}
+	pref2 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "abaaz", Value: "v398342"},
+		{Key: "zzaz", Value: "viaooouyenbdnya"},
+		{Key: "abazzz", Value: "v57869nbdnya"},
+		{Key: "zozzz", Value: "vooouyenbdnya"},
+		{Key: "zzzz", Value: "viaooouyenbdnya"},
+	}
+
+	records, _ := cache.Read("az", store.ReadSuffix())
+	if len(records) != 2 {
+		t.Fatalf("Suffix read for \"az\" returned %d records, expected 2", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref1[index].Key != record.Key {
+			t.Errorf("Unexpected key for suffix \"az\", index %d, expected %s, got %s", index, pref1[index].Key, record.Key)
+		}
+		if pref1[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for suffix \"az\", index %d, expected %s, got %s", index, pref1[index].Value, record.Value)
+		}
+	}
+
+	records, _ = cache.Read("z", store.ReadSuffix())
+	if len(records) != 5 {
+		t.Fatalf("Suffix read for \"z\" returned %d records, expected 5", len(records))
+	}
+	for index, record := range records {
+		if pref2[index].Key != record.Key {
+			t.Errorf("Unexpected key for suffix \"z\", index %d, expected %s, got %s", index, pref2[index].Key, record.Key)
+		}
+		if pref2[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for suffix \"z\", index %d, expected %s, got %s", index, pref2[index].Value, record.Value)
+		}
+	}
+}
+
+func readPrefixSuffix(t *testing.T, cache store.Store) {
+	pref1 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "zozzz", Value: "vooouyenbdnya"},
+		{Key: "zzaz", Value: "viaooouyenbdnya"},
+		{Key: "zzzz", Value: "viaooouyenbdnya"},
+	}
+	pref2 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "mbmbmb", Value: "viuyenbdnya"},
+		{Key: "mbzzaamb", Value: "viunya"},
+	}
+
+	records, _ := cache.Read("z", store.ReadPrefix(), store.ReadSuffix())
+	if len(records) != 3 {
+		t.Fatalf("Prefix-Suffix read for \"z\" returned %d records, expected 3", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref1[index].Key != record.Key {
+			t.Errorf("Unexpected key for prefix-suffix \"z\", index %d, expected %s, got %s", index, pref1[index].Key, record.Key)
+		}
+		if pref1[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for prefix-suffix \"z\", index %d, expected %s, got %s", index, pref1[index].Value, record.Value)
+		}
+	}
+
+	records, _ = cache.Read("mb", store.ReadPrefix(), store.ReadSuffix())
+	if len(records) != 2 {
+		t.Fatalf("Prefix-Suffix read for \"mb\" returned %d records, expected 2", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref2[index].Key != record.Key {
+			t.Errorf("Unexpected key for prefix-suffix \"mb\", index %d, expected %s, got %s", index, pref2[index].Key, record.Key)
+		}
+		if pref2[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for prefix-suffix \"mb\", index %d, expected %s, got %s", index, pref2[index].Value, record.Value)
+		}
+	}
+}
+
+func readPrefixLimitOffset(t *testing.T, cache store.Store) {
+	pref1 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "abaaz", Value: "v398342"},
+		{Key: "abaya", Value: "v329487"},
+	}
+	pref2 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "abayakjdkj", Value: "v989898"},
+		{Key: "abazzz", Value: "v57869nbdnya"},
+	}
+
+	records, _ := cache.Read("aba", store.ReadPrefix(), store.ReadLimit(2))
+	if len(records) != 2 {
+		t.Fatalf("Limit prefix read for \"aba\" returned %d records, expected 2", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref1[index].Key != record.Key {
+			t.Errorf("Unexpected key for limit prefix \"aba\", index %d, expected %s, got %s", index, pref1[index].Key, record.Key)
+		}
+		if pref1[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for limit prefix \"aba\", index %d, expected %s, got %s", index, pref1[index].Value, record.Value)
+		}
+	}
+
+	records, _ = cache.Read("aba", store.ReadPrefix(), store.ReadLimit(2), store.ReadOffset(2))
+	if len(records) != 2 {
+		t.Fatalf("Offset-limit prefix read for \"aba\" returned %d records, expected 2", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref2[index].Key != record.Key {
+			t.Errorf("Unexpected key for offset-limit prefix \"aba\", index %d, expected %s, got %s", index, pref2[index].Key, record.Key)
+		}
+		if pref2[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for offset-limit prefix \"aba\", index %d, expected %s, got %s", index, pref2[index].Value, record.Value)
+		}
+	}
+}
+
+func readSuffixLimitOffset(t *testing.T, cache store.Store) {
+	pref1 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "abaaz", Value: "v398342"},
+		{Key: "zzaz", Value: "viaooouyenbdnya"},
+	}
+	pref2 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "abazzz", Value: "v57869nbdnya"},
+		{Key: "zozzz", Value: "vooouyenbdnya"},
+	}
+
+	records, _ := cache.Read("z", store.ReadSuffix(), store.ReadLimit(2))
+	if len(records) != 2 {
+		t.Fatalf("Limit suffix read for \"z\" returned %d records, expected 2", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref1[index].Key != record.Key {
+			t.Errorf("Unexpected key for limit suffix \"z\", index %d, expected %s, got %s", index, pref1[index].Key, record.Key)
+		}
+		if pref1[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for limit suffix \"z\", index %d, expected %s, got %s", index, pref1[index].Value, record.Value)
+		}
+	}
+
+	records, _ = cache.Read("z", store.ReadSuffix(), store.ReadLimit(2), store.ReadOffset(2))
+	if len(records) != 2 {
+		t.Fatalf("Offset-limit suffix read for \"z\" returned %d records, expected 2", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref2[index].Key != record.Key {
+			t.Errorf("Unexpected key for offset-limit suffix \"z\", index %d, expected %s, got %s", index, pref2[index].Key, record.Key)
+		}
+		if pref2[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for offset-limit suffix \"z\", index %d, expected %s, got %s", index, pref2[index].Value, record.Value)
+		}
+	}
+}
+
+func readPrefixSuffixLimitOffset(t *testing.T, cache store.Store) {
+	pref1 := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "zzaz", Value: "viaooouyenbdnya"},
+		{Key: "zzzz", Value: "viaooouyenbdnya"},
+	}
+
+	records, _ := cache.Read("z", store.ReadPrefix(), store.ReadSuffix(), store.ReadOffset(1), store.ReadLimit(2))
+	if len(records) != 2 {
+		t.Fatalf("Limit suffix read for \"z\" returned %d records, expected 2", len(records))
+	}
+	for index, record := range records {
+		// it should be sorted alphabetically
+		if pref1[index].Key != record.Key {
+			t.Errorf("Unexpected key for limit suffix \"z\", index %d, expected %s, got %s", index, pref1[index].Key, record.Key)
+		}
+		if pref1[index].Value != string(record.Value) {
+			t.Errorf("Unexpected value for limit suffix \"z\", index %d, expected %s, got %s", index, pref1[index].Value, record.Value)
+		}
+	}
+}
+
+func TestWriteExpiryAndRead(t *testing.T) {
+	cache := NewMemStore()
+
+	data := map[string]string{
+		"abaya":      "v329487",
+		"abaaz":      "v398342",
+		"abayakjdkj": "v989898",
+		"zzaz":       "viaooouyenbdnya",
+		"abazzz":     "v57869nbdnya",
+		"mbmbmb":     "viuyenbdnya",
+		"mbzzaamb":   "viunya",
+		"zozzz":      "vooouyenbdnya",
+	}
+
+	for key, value := range data {
+		record := &store.Record{
+			Key:    key,
+			Value:  []byte(value),
+			Expiry: time.Second * 1000,
+		}
+		_ = cache.Write(record)
+	}
+
+	records, _ := cache.Read("zzaz")
+	if len(records) != 1 {
+		t.Fatalf("Failed read for \"zzaz\" returned %d records, expected 1", len(records))
+	}
+	record := records[0]
+	if record.Expiry < 999*time.Second || record.Expiry > 1000*time.Second {
+		// The expiry will be adjusted on retrieval
+		t.Errorf("Abnormal expiry range: expected %d-%d, got %d", 999*time.Second, 1000*time.Second, record.Expiry)
+	}
+}
+
+func TestWriteExpiryWithExpiryAndRead(t *testing.T) {
+	cache := NewMemStore()
+
+	data := map[string]string{
+		"abaya":      "v329487",
+		"abaaz":      "v398342",
+		"abayakjdkj": "v989898",
+		"zzaz":       "viaooouyenbdnya",
+		"abazzz":     "v57869nbdnya",
+		"mbmbmb":     "viuyenbdnya",
+		"mbzzaamb":   "viunya",
+		"zozzz":      "vooouyenbdnya",
+	}
+
+	for key, value := range data {
+		record := &store.Record{
+			Key:    key,
+			Value:  []byte(value),
+			Expiry: time.Second * 1000,
+		}
+		// write option will override the record data
+		_ = cache.Write(record, store.WriteExpiry(time.Now().Add(time.Hour)))
+	}
+
+	records, _ := cache.Read("zzaz")
+	if len(records) != 1 {
+		t.Fatalf("Failed read for \"zzaz\" returned %d records, expected 1", len(records))
+	}
+	record := records[0]
+	if record.Expiry < 3599*time.Second || record.Expiry > 3600*time.Second {
+		// The expiry will be adjusted on retrieval
+		t.Errorf("Abnormal expiry range: expected %d-%d, got %d", 3599*time.Second, 3600*time.Second, record.Expiry)
+	}
+}
+
+func TestWriteExpiryWithTTLAndRead(t *testing.T) {
+	cache := NewMemStore()
+
+	data := map[string]string{
+		"abaya":      "v329487",
+		"abaaz":      "v398342",
+		"abayakjdkj": "v989898",
+		"zzaz":       "viaooouyenbdnya",
+		"abazzz":     "v57869nbdnya",
+		"mbmbmb":     "viuyenbdnya",
+		"mbzzaamb":   "viunya",
+		"zozzz":      "vooouyenbdnya",
+	}
+
+	for key, value := range data {
+		record := &store.Record{
+			Key:    key,
+			Value:  []byte(value),
+			Expiry: time.Second * 1000,
+		}
+		// write option will override the record data, TTL takes precedence
+		_ = cache.Write(record, store.WriteTTL(20*time.Second), store.WriteExpiry(time.Now().Add(time.Hour)))
+	}
+
+	records, _ := cache.Read("zzaz")
+	if len(records) != 1 {
+		t.Fatalf("Failed read for \"zzaz\" returned %d records, expected 1", len(records))
+	}
+	record := records[0]
+	if record.Expiry < 19*time.Second || record.Expiry > 20*time.Second {
+		// The expiry will be adjusted on retrieval
+		t.Errorf("Abnormal expiry range: expected %d-%d, got %d", 19*time.Second, 20*time.Second, record.Expiry)
+	}
+}
+
+func TestDelete(t *testing.T) {
+	cache := NewMemStore()
+	record := &store.Record{
+		Key:   "record",
+		Value: []byte("value for record"),
+	}
+
+	records, err := cache.Read("record")
+	if err != store.ErrNotFound && len(records) > 0 {
+		t.Fatal("Found key in cache but it shouldn't be there")
+	}
+
+	_ = cache.Write(record)
+	records, err = cache.Read("record")
+	if err != nil {
+		t.Fatal("Key not found in cache after inserting it")
+	}
+	if len(records) != 1 {
+		t.Fatal("Multiple keys found in cache after inserting it")
+	}
+	if records[0].Key != "record" && string(records[0].Value) != "value for record" {
+		t.Fatal("Wrong record retrieved")
+	}
+
+	err = cache.Delete("record")
+	if err != nil {
+		t.Fatal("Error deleting the record")
+	}
+
+	records, err = cache.Read("record")
+	if err != store.ErrNotFound && len(records) > 0 {
+		t.Fatal("Found key in cache but it shouldn't be there")
+	}
+}
+
+func TestList(t *testing.T) {
+	cache := NewMemStore()
+	data := map[string]string{
+		"abaya":      "v329487",
+		"abaaz":      "v398342",
+		"abayakjdkj": "v989898",
+		"zzzz":       "viaooouyenbdnya",
+		"abazzz":     "v57869nbdnya",
+		"mbmbmb":     "viuyenbdnya",
+		"zozzz":      "vooouyenbdnya",
+		"aboyo":      "v889487",
+		"zzaaaz":     "v999487",
+	}
+
+	for key, value := range data {
+		record := &store.Record{
+			Key:   key,
+			Value: []byte(value),
+		}
+		_ = cache.Write(record)
+	}
+
+	t.Run("Plain", func(t *testing.T) {
+		listPlain(t, cache)
+	})
+	t.Run("Prefix", func(t *testing.T) {
+		listPrefix(t, cache)
+	})
+	t.Run("Suffix", func(t *testing.T) {
+		listSuffix(t, cache)
+	})
+	t.Run("PrefixSuffix", func(t *testing.T) {
+		listPrefixSuffix(t, cache)
+	})
+	t.Run("LimitOffset", func(t *testing.T) {
+		listLimitOffset(t, cache)
+	})
+	t.Run("PrefixLimitOffset", func(t *testing.T) {
+		listPrefixLimitOffset(t, cache)
+	})
+	t.Run("SuffixLimitOffset", func(t *testing.T) {
+		listSuffixLimitOffset(t, cache)
+	})
+	t.Run("PrefixSuffixLimitOffset", func(t *testing.T) {
+		listPrefixSuffixLimitOffset(t, cache)
+	})
+}
+
+func listPlain(t *testing.T, cache store.Store) {
+	keys, _ := cache.List()
+	expectedKeys := []string{"abaaz", "abaya", "abayakjdkj", "abazzz", "aboyo", "mbmbmb", "zozzz", "zzaaaz", "zzzz"}
+	if len(keys) != len(expectedKeys) {
+		t.Fatalf("Wrong number of keys, expected %d, got %d", len(expectedKeys), len(keys))
+	}
+
+	for index, key := range keys {
+		if key != expectedKeys[index] {
+			t.Errorf("Wrong key in the list in index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func listPrefix(t *testing.T, cache store.Store) {
+	keys, _ := cache.List(store.ListPrefix("aba"))
+	expectedKeys := []string{"abaaz", "abaya", "abayakjdkj", "abazzz"}
+	if len(keys) != len(expectedKeys) {
+		t.Fatalf("Wrong number of keys, expected %d, got %d", len(expectedKeys), len(keys))
+	}
+
+	for index, key := range keys {
+		if key != expectedKeys[index] {
+			t.Errorf("Wrong key in the list in index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func listSuffix(t *testing.T, cache store.Store) {
+	keys, _ := cache.List(store.ListSuffix("z"))
+	expectedKeys := []string{"zzaaaz", "abaaz", "abazzz", "zozzz", "zzzz"}
+	if len(keys) != len(expectedKeys) {
+		t.Fatalf("Wrong number of keys, expected %d, got %d", len(expectedKeys), len(keys))
+	}
+
+	for index, key := range keys {
+		if key != expectedKeys[index] {
+			t.Errorf("Wrong key in the list in index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func listPrefixSuffix(t *testing.T, cache store.Store) {
+	keys, _ := cache.List(store.ListPrefix("ab"), store.ListSuffix("z"))
+	expectedKeys := []string{"abaaz", "abazzz"}
+	if len(keys) != len(expectedKeys) {
+		t.Fatalf("Wrong number of keys, expected %d, got %d", len(expectedKeys), len(keys))
+	}
+
+	for index, key := range keys {
+		if key != expectedKeys[index] {
+			t.Errorf("Wrong key in the list in index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func listLimitOffset(t *testing.T, cache store.Store) {
+	keys, _ := cache.List(store.ListLimit(3), store.ListOffset(2))
+	expectedKeys := []string{"abayakjdkj", "abazzz", "aboyo"}
+	if len(keys) != len(expectedKeys) {
+		t.Fatalf("Wrong number of keys, expected %d, got %d", len(expectedKeys), len(keys))
+	}
+
+	for index, key := range keys {
+		if key != expectedKeys[index] {
+			t.Errorf("Wrong key in the list in index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func listPrefixLimitOffset(t *testing.T, cache store.Store) {
+	keys, _ := cache.List(store.ListPrefix("aba"), store.ListLimit(2), store.ListOffset(1))
+	expectedKeys := []string{"abaya", "abayakjdkj"}
+	if len(keys) != len(expectedKeys) {
+		t.Fatalf("Wrong number of keys, expected %d, got %d", len(expectedKeys), len(keys))
+	}
+
+	for index, key := range keys {
+		if key != expectedKeys[index] {
+			t.Errorf("Wrong key in the list in index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func listSuffixLimitOffset(t *testing.T, cache store.Store) {
+	keys, _ := cache.List(store.ListSuffix("z"), store.ListLimit(2), store.ListOffset(1))
+	expectedKeys := []string{"abaaz", "abazzz"}
+	if len(keys) != len(expectedKeys) {
+		t.Fatalf("Wrong number of keys, expected %d, got %d", len(expectedKeys), len(keys))
+	}
+
+	for index, key := range keys {
+		if key != expectedKeys[index] {
+			t.Errorf("Wrong key in the list in index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func listPrefixSuffixLimitOffset(t *testing.T, cache store.Store) {
+	keys, _ := cache.List(store.ListPrefix("a"), store.ListSuffix("z"), store.ListLimit(2), store.ListOffset(1))
+	expectedKeys := []string{"abazzz"} // only 2 available, and we skip the first one
+	if len(keys) != len(expectedKeys) {
+		t.Fatalf("Wrong number of keys, expected %d, got %d", len(expectedKeys), len(keys))
+	}
+
+	for index, key := range keys {
+		if key != expectedKeys[index] {
+			t.Errorf("Wrong key in the list in index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func TestEvictWriteUpdate(t *testing.T) {
+	cache := NewMemStore(
+		store.WithContext(
+			NewContext(
+				context.Background(),
+				map[string]interface{}{
+					"maxCap": 3,
+				},
+			)),
+	)
+
+	for i := 0; i < 3; i++ {
+		v := strconv.Itoa(i)
+		record := &store.Record{
+			Key:   v,
+			Value: []byte(v),
+		}
+		_ = cache.Write(record)
+	}
+
+	// update first item
+	updatedRecord := &store.Record{
+		Key:   "0",
+		Value: []byte("zero"),
+	}
+	_ = cache.Write(updatedRecord)
+
+	// new record, to force eviction
+	newRecord := &store.Record{
+		Key:   "new",
+		Value: []byte("newNew"),
+	}
+	_ = cache.Write(newRecord)
+
+	records, _ := cache.Read("", store.ReadPrefix())
+	if len(records) != 3 {
+		t.Fatalf("Wrong number of record returned, expected 3, got %d", len(records))
+	}
+
+	expectedKV := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "0", Value: "zero"},
+		{Key: "2", Value: "2"},
+		{Key: "new", Value: "newNew"},
+	}
+
+	for index, record := range records {
+		if record.Key != expectedKV[index].Key {
+			t.Errorf("Wrong key for index %d, expected %s, got %s", index, expectedKV[index].Key, record.Key)
+		}
+		if string(record.Value) != expectedKV[index].Value {
+			t.Errorf("Wrong value  for index %d, expected %s, got %s", index, expectedKV[index].Value, string(record.Value))
+		}
+	}
+}
+
+func TestEvictRead(t *testing.T) {
+	cache := NewMemStore(
+		store.WithContext(
+			NewContext(
+				context.Background(),
+				map[string]interface{}{
+					"maxCap": 3,
+				},
+			)),
+	)
+
+	for i := 0; i < 3; i++ {
+		v := strconv.Itoa(i)
+		record := &store.Record{
+			Key:   v,
+			Value: []byte(v),
+		}
+		_ = cache.Write(record)
+	}
+
+	// Read first item
+	_, _ = cache.Read("0")
+
+	// new record, to force eviction
+	newRecord := &store.Record{
+		Key:   "new",
+		Value: []byte("newNew"),
+	}
+	_ = cache.Write(newRecord)
+
+	records, _ := cache.Read("", store.ReadPrefix())
+	if len(records) != 3 {
+		t.Fatalf("Wrong number of record returned, expected 3, got %d", len(records))
+	}
+
+	expectedKV := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "0", Value: "0"},
+		{Key: "2", Value: "2"},
+		{Key: "new", Value: "newNew"},
+	}
+
+	for index, record := range records {
+		if record.Key != expectedKV[index].Key {
+			t.Errorf("Wrong key for index %d, expected %s, got %s", index, expectedKV[index].Key, record.Key)
+		}
+		if string(record.Value) != expectedKV[index].Value {
+			t.Errorf("Wrong value  for index %d, expected %s, got %s", index, expectedKV[index].Value, string(record.Value))
+		}
+	}
+}
+
+func TestEvictReadPrefix(t *testing.T) {
+	cache := NewMemStore(
+		store.WithContext(
+			NewContext(
+				context.Background(),
+				map[string]interface{}{
+					"maxCap": 3,
+				},
+			)),
+	)
+
+	for i := 0; i < 3; i++ {
+		v := strconv.Itoa(i)
+		record := &store.Record{
+			Key:   v,
+			Value: []byte(v),
+		}
+		_ = cache.Write(record)
+	}
+
+	// Read prefix won't change evcition list
+	_, _ = cache.Read("0", store.ReadPrefix())
+
+	// new record, to force eviction
+	newRecord := &store.Record{
+		Key:   "new",
+		Value: []byte("newNew"),
+	}
+	_ = cache.Write(newRecord)
+
+	records, _ := cache.Read("", store.ReadPrefix())
+	if len(records) != 3 {
+		t.Fatalf("Wrong number of record returned, expected 3, got %d", len(records))
+	}
+
+	expectedKV := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "1", Value: "1"},
+		{Key: "2", Value: "2"},
+		{Key: "new", Value: "newNew"},
+	}
+
+	for index, record := range records {
+		if record.Key != expectedKV[index].Key {
+			t.Errorf("Wrong key for index %d, expected %s, got %s", index, expectedKV[index].Key, record.Key)
+		}
+		if string(record.Value) != expectedKV[index].Value {
+			t.Errorf("Wrong value  for index %d, expected %s, got %s", index, expectedKV[index].Value, string(record.Value))
+		}
+	}
+}
+
+func TestEvictReadSuffix(t *testing.T) {
+	cache := NewMemStore(
+		store.WithContext(
+			NewContext(
+				context.Background(),
+				map[string]interface{}{
+					"maxCap": 3,
+				},
+			)),
+	)
+
+	for i := 0; i < 3; i++ {
+		v := strconv.Itoa(i)
+		record := &store.Record{
+			Key:   v,
+			Value: []byte(v),
+		}
+		_ = cache.Write(record)
+	}
+
+	// Read suffix won't change evcition list
+	_, _ = cache.Read("0", store.ReadSuffix())
+
+	// new record, to force eviction
+	newRecord := &store.Record{
+		Key:   "new",
+		Value: []byte("newNew"),
+	}
+	_ = cache.Write(newRecord)
+
+	records, _ := cache.Read("", store.ReadPrefix())
+	if len(records) != 3 {
+		t.Fatalf("Wrong number of record returned, expected 3, got %d", len(records))
+	}
+
+	expectedKV := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "1", Value: "1"},
+		{Key: "2", Value: "2"},
+		{Key: "new", Value: "newNew"},
+	}
+
+	for index, record := range records {
+		if record.Key != expectedKV[index].Key {
+			t.Errorf("Wrong key for index %d, expected %s, got %s", index, expectedKV[index].Key, record.Key)
+		}
+		if string(record.Value) != expectedKV[index].Value {
+			t.Errorf("Wrong value  for index %d, expected %s, got %s", index, expectedKV[index].Value, string(record.Value))
+		}
+	}
+}
+
+func TestEvictList(t *testing.T) {
+	cache := NewMemStore(
+		store.WithContext(
+			NewContext(
+				context.Background(),
+				map[string]interface{}{
+					"maxCap": 3,
+				},
+			)),
+	)
+
+	for i := 0; i < 3; i++ {
+		v := strconv.Itoa(i)
+		record := &store.Record{
+			Key:   v,
+			Value: []byte(v),
+		}
+		_ = cache.Write(record)
+	}
+
+	// List won't change evcition list
+	_, _ = cache.List()
+
+	// new record, to force eviction
+	newRecord := &store.Record{
+		Key:   "new",
+		Value: []byte("newNew"),
+	}
+	_ = cache.Write(newRecord)
+
+	records, _ := cache.Read("", store.ReadPrefix())
+	if len(records) != 3 {
+		t.Fatalf("Wrong number of record returned, expected 3, got %d", len(records))
+	}
+
+	expectedKV := []struct {
+		Key   string
+		Value string
+	}{
+		{Key: "1", Value: "1"},
+		{Key: "2", Value: "2"},
+		{Key: "new", Value: "newNew"},
+	}
+
+	for index, record := range records {
+		if record.Key != expectedKV[index].Key {
+			t.Errorf("Wrong key for index %d, expected %s, got %s", index, expectedKV[index].Key, record.Key)
+		}
+		if string(record.Value) != expectedKV[index].Value {
+			t.Errorf("Wrong value  for index %d, expected %s, got %s", index, expectedKV[index].Value, string(record.Value))
+		}
+	}
+}
+
+func TestExpireReadPrefix(t *testing.T) {
+	cache := NewMemStore()
+
+	record := &store.Record{}
+	for i := 0; i < 20; i++ {
+		v := strconv.Itoa(i)
+		record.Key = v
+		record.Value = []byte(v)
+		if i%2 == 0 {
+			record.Expiry = time.Duration(i) * time.Minute
+		} else {
+			record.Expiry = time.Duration(-i) * time.Minute
+		}
+		_ = cache.Write(record)
+	}
+
+	records, _ := cache.Read("", store.ReadPrefix())
+	if len(records) != 10 {
+		t.Fatalf("Wrong number of records, expected 10, got %d", len(records))
+	}
+
+	var expKeys []string
+	for i := 0; i < 20; i++ {
+		if i%2 == 0 {
+			expKeys = append(expKeys, strconv.Itoa(i))
+		}
+	}
+	sort.Strings(expKeys)
+
+	expKeyIndex := 0
+	for _, record := range records {
+		if record.Key != expKeys[expKeyIndex] {
+			t.Fatalf("Wrong expected key, expected %s, got %s", expKeys[expKeyIndex], record.Key)
+		}
+		expKeyIndex++
+	}
+}
+
+func TestExpireReadSuffix(t *testing.T) {
+	cache := NewMemStore()
+
+	record := &store.Record{}
+	for i := 0; i < 20; i++ {
+		v := strconv.Itoa(i)
+		record.Key = v
+		record.Value = []byte(v)
+		if i%2 == 0 {
+			record.Expiry = time.Duration(i) * time.Minute
+		} else {
+			record.Expiry = time.Duration(-i) * time.Minute
+		}
+		_ = cache.Write(record)
+	}
+
+	records, _ := cache.Read("", store.ReadSuffix())
+	if len(records) != 10 {
+		t.Fatalf("Wrong number of records, expected 10, got %d", len(records))
+	}
+
+	var expKeys []string
+	for i := 0; i < 20; i++ {
+		if i%2 == 0 {
+			expKeys = append(expKeys, strconv.Itoa(i))
+		}
+	}
+	sort.Slice(expKeys, func(i, j int) bool {
+		return reverseString(expKeys[i]) < reverseString(expKeys[j])
+	})
+
+	expKeyIndex := 0
+	for _, record := range records {
+		if record.Key != expKeys[expKeyIndex] {
+			t.Fatalf("Wrong expected key, expected %s, got %s", expKeys[expKeyIndex], record.Key)
+		}
+		expKeyIndex++
+	}
+}
+
+func TestExpireList(t *testing.T) {
+	cache := NewMemStore()
+
+	record := &store.Record{}
+	for i := 0; i < 20; i++ {
+		v := strconv.Itoa(i)
+		record.Key = v
+		record.Value = []byte(v)
+		if i%2 == 0 {
+			record.Expiry = time.Duration(i) * time.Minute
+		} else {
+			record.Expiry = time.Duration(-i) * time.Minute
+		}
+		_ = cache.Write(record)
+	}
+
+	keys, _ := cache.List()
+	if len(keys) != 10 {
+		t.Fatalf("Wrong number of records, expected 10, got %d", len(keys))
+	}
+
+	var expKeys []string
+	for i := 0; i < 20; i++ {
+		if i%2 == 0 {
+			expKeys = append(expKeys, strconv.Itoa(i))
+		}
+	}
+	sort.Strings(expKeys)
+
+	expKeyIndex := 0
+	for _, key := range keys {
+		if key != expKeys[expKeyIndex] {
+			t.Fatalf("Wrong expected key, expected %s, got %s", expKeys[expKeyIndex], key)
+		}
+		expKeyIndex++
+	}
+}
+
+func TestExpireListPrefix(t *testing.T) {
+	cache := NewMemStore()
+
+	record := &store.Record{}
+	for i := 0; i < 20; i++ {
+		v := strconv.Itoa(i)
+		record.Key = v
+		record.Value = []byte(v)
+		if i%2 == 0 {
+			record.Expiry = time.Duration(i) * time.Minute
+		} else {
+			record.Expiry = time.Duration(-i) * time.Minute
+		}
+		_ = cache.Write(record)
+	}
+
+	keys, _ := cache.List(store.ListPrefix("1"))
+	if len(keys) != 5 {
+		t.Fatalf("Wrong number of records, expected 5, got %d", len(keys))
+	}
+
+	var expKeys []string
+	for i := 0; i < 20; i++ {
+		v := strconv.Itoa(i)
+		if i%2 == 0 && strings.HasPrefix(v, "1") {
+			expKeys = append(expKeys, v)
+		}
+	}
+	sort.Strings(expKeys)
+
+	expKeyIndex := 0
+	for _, key := range keys {
+		if key != expKeys[expKeyIndex] {
+			t.Fatalf("Wrong expected key, expected %s, got %s", expKeys[expKeyIndex], key)
+		}
+		expKeyIndex++
+	}
+}
+
+func TestExpireListSuffix(t *testing.T) {
+	cache := NewMemStore()
+
+	record := &store.Record{}
+	for i := 0; i < 20; i++ {
+		v := strconv.Itoa(i)
+		record.Key = v
+		record.Value = []byte(v)
+		if i%2 == 0 {
+			record.Expiry = time.Duration(i) * time.Minute
+		} else {
+			record.Expiry = time.Duration(-i) * time.Minute
+		}
+		_ = cache.Write(record)
+	}
+
+	keys, _ := cache.List(store.ListSuffix("8"))
+	if len(keys) != 2 {
+		t.Fatalf("Wrong number of records, expected 2, got %d", len(keys))
+	}
+
+	var expKeys []string
+	for i := 0; i < 20; i++ {
+		v := strconv.Itoa(i)
+		if i%2 == 0 && strings.HasSuffix(v, "8") {
+			expKeys = append(expKeys, v)
+		}
+	}
+	sort.Slice(expKeys, func(i, j int) bool {
+		return reverseString(expKeys[i]) < reverseString(expKeys[j])
+	})
+
+	expKeyIndex := 0
+	for _, key := range keys {
+		if key != expKeys[expKeyIndex] {
+			t.Fatalf("Wrong expected key, expected %s, got %s", expKeys[expKeyIndex], key)
+		}
+		expKeyIndex++
+	}
+}
+
+func TestConcurrentWrite(t *testing.T) {
+	nThreads := []int{3, 10, 50}
+
+	for _, threads := range nThreads {
+		t.Run("T"+strconv.Itoa(threads), func(t *testing.T) {
+			cache := NewMemStore(
+				store.WithContext(
+					NewContext(
+						context.Background(),
+						map[string]interface{}{
+							"maxCap": 50000,
+						},
+					)),
+			)
+
+			var wg sync.WaitGroup
+			var index int64
+
+			wg.Add(threads)
+			for i := 0; i < threads; i++ {
+				go func(cache store.Store, ind *int64) {
+					j := atomic.AddInt64(ind, 1) - 1
+					for j < 100000 {
+						v := strconv.FormatInt(j, 10)
+						record := &store.Record{
+							Key:   v,
+							Value: []byte(v),
+						}
+						_ = cache.Write(record)
+						j = atomic.AddInt64(ind, 1) - 1
+					}
+					wg.Done()
+				}(cache, &index)
+			}
+			wg.Wait()
+
+			records, _ := cache.Read("", store.ReadPrefix())
+			if len(records) != 50000 {
+				t.Fatalf("Wrong number of records, expected 50000, got %d", len(records))
+			}
+			for _, record := range records {
+				if record.Key != string(record.Value) {
+					t.Fatalf("Wrong record found, key %s, value %s", record.Key, string(record.Value))
+				}
+			}
+		})
+	}
+}
+
+func BenchmarkWrite(b *testing.B) {
+	cacheSizes := []int{512, 1024, 10000, 50000, 1000000}
+
+	for _, size := range cacheSizes {
+		cache := NewMemStore(
+			store.WithContext(
+				NewContext(
+					context.Background(),
+					map[string]interface{}{
+						"maxCap": size,
+					},
+				)),
+		)
+		record := &store.Record{}
+
+		b.Run("CacheSize"+strconv.Itoa(size), func(b *testing.B) {
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				// records will be copied, so it's safe to overwrite the previous record
+				v := strconv.Itoa(i)
+				record.Key = v
+				record.Value = []byte(v)
+				_ = cache.Write(record)
+			}
+		})
+	}
+}
+
+func BenchmarkRead(b *testing.B) {
+	cacheSizes := []int{512, 1024, 10000, 50000, 1000000}
+
+	for _, size := range cacheSizes {
+		cache := NewMemStore(
+			store.WithContext(
+				NewContext(
+					context.Background(),
+					map[string]interface{}{
+						"maxCap": size,
+					},
+				)),
+		)
+		record := &store.Record{}
+
+		for i := 0; i < size; i++ {
+			// records will be copied, so it's safe to overwrite the previous record
+			v := strconv.Itoa(i)
+			record.Key = v
+			record.Value = []byte(v)
+			_ = cache.Write(record)
+		}
+		b.Run("CacheSize"+strconv.Itoa(size), func(b *testing.B) {
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				v := strconv.Itoa(i)
+				_, _ = cache.Read(v)
+			}
+		})
+	}
+}
+
+func BenchmarkWriteMedKey(b *testing.B) {
+	cacheSizes := []int{512, 1024, 10000, 50000, 1000000}
+
+	h := fnv.New128()
+	for _, size := range cacheSizes {
+		cache := NewMemStore(
+			store.WithContext(
+				NewContext(
+					context.Background(),
+					map[string]interface{}{
+						"maxCap": size,
+					},
+				)),
+		)
+		record := &store.Record{}
+
+		b.Run("CacheSize"+strconv.Itoa(size), func(b *testing.B) {
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				h.Reset()
+				v := strconv.Itoa(i)
+				bys := []byte(v)
+				h.Write(bys)
+				// records will be copied, so it's safe to overwrite the previous record
+				record.Key = hex.EncodeToString(h.Sum(nil))
+				record.Value = bys
+				_ = cache.Write(record)
+			}
+		})
+	}
+}
+
+func BenchmarkReadMedKey(b *testing.B) {
+	cacheSizes := []int{512, 1024, 10000, 50000, 1000000}
+
+	h := fnv.New128()
+	for _, size := range cacheSizes {
+		cache := NewMemStore(
+			store.WithContext(
+				NewContext(
+					context.Background(),
+					map[string]interface{}{
+						"maxCap": size,
+					},
+				)),
+		)
+		record := &store.Record{}
+
+		for i := 0; i < size; i++ {
+			h.Reset()
+			v := strconv.Itoa(i)
+			bys := []byte(v)
+			h.Write(bys)
+			// records will be copied, so it's safe to overwrite the previous record
+			record.Key = hex.EncodeToString(h.Sum(nil))
+			record.Value = bys
+			_ = cache.Write(record)
+		}
+		b.Run("CacheSize"+strconv.Itoa(size), func(b *testing.B) {
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				h.Reset()
+				v := strconv.Itoa(i)
+				bys := []byte(v)
+				h.Write(bys)
+				_, _ = cache.Read(hex.EncodeToString(h.Sum(nil)))
+			}
+		})
+	}
+}
+
+func BenchmarkReadMedKeyPrefix(b *testing.B) {
+	cacheSizes := []int{512, 1024, 10000, 50000, 1000000}
+
+	h := fnv.New128()
+	for _, size := range cacheSizes {
+		cache := NewMemStore(
+			store.WithContext(
+				NewContext(
+					context.Background(),
+					map[string]interface{}{
+						"maxCap": size,
+					},
+				)),
+		)
+		record := &store.Record{}
+
+		for i := 0; i < size; i++ {
+			h.Reset()
+			v := strconv.Itoa(i)
+			bys := []byte(v)
+			h.Write(bys)
+			// records will be copied, so it's safe to overwrite the previous record
+			record.Key = hex.EncodeToString(h.Sum(nil))
+			record.Value = bys
+			_ = cache.Write(record)
+		}
+		b.Run("CacheSize"+strconv.Itoa(size), func(b *testing.B) {
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				h.Reset()
+				v := strconv.Itoa(i)
+				bys := []byte(v)
+				h.Write(bys)
+				_, _ = cache.Read(hex.EncodeToString(h.Sum(nil))[:10], store.ReadPrefix(), store.ReadLimit(50))
+			}
+		})
+	}
+}
+
+func BenchmarkReadMedKeySuffix(b *testing.B) {
+	cacheSizes := []int{512, 1024, 10000, 50000, 1000000}
+
+	h := fnv.New128()
+	for _, size := range cacheSizes {
+		cache := NewMemStore(
+			store.WithContext(
+				NewContext(
+					context.Background(),
+					map[string]interface{}{
+						"maxCap": size,
+					},
+				)),
+		)
+		record := &store.Record{}
+
+		for i := 0; i < size; i++ {
+			h.Reset()
+			v := strconv.Itoa(i)
+			bys := []byte(v)
+			h.Write(bys)
+			// records will be copied, so it's safe to overwrite the previous record
+			record.Key = hex.EncodeToString(h.Sum(nil))
+			record.Value = bys
+			_ = cache.Write(record)
+		}
+		b.Run("CacheSize"+strconv.Itoa(size), func(b *testing.B) {
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				h.Reset()
+				v := strconv.Itoa(i)
+				bys := []byte(v)
+				h.Write(bys)
+				_, _ = cache.Read(hex.EncodeToString(h.Sum(nil))[23:], store.ReadSuffix(), store.ReadLimit(50))
+			}
+		})
+	}
+}
+
+func concurrentStoreBench(b *testing.B, threads int) {
+	benchTest := map[string]int{
+		"DefCap": 512,
+		"LimCap": 3,
+		"BigCap": 1000000,
+	}
+	for testname, size := range benchTest {
+		b.Run(testname, func(b *testing.B) {
+			cache := NewMemStore(
+				store.WithContext(
+					NewContext(
+						context.Background(),
+						map[string]interface{}{
+							"maxCap": size,
+						},
+					)),
+			)
+
+			b.SetParallelism(threads)
+			b.ResetTimer()
+			b.RunParallel(func(pb *testing.PB) {
+				h := fnv.New128()
+				record := &store.Record{}
+				for pb.Next() {
+					h.Reset()
+					v := strconv.Itoa(rand.Int()) //nolint:gosec
+					bys := []byte(v)
+					h.Write(bys)
+					// records will be copied, so it's safe to overwrite the previous record
+					record.Key = hex.EncodeToString(h.Sum(nil))
+					record.Value = bys
+					_ = cache.Write(record)
+				}
+			})
+		})
+	}
+}
+
+func concurrentRetrieveBench(b *testing.B, threads int) {
+	benchTest := map[string]int{
+		"DefCap": 512,
+		"LimCap": 3,
+		"BigCap": 1000000,
+	}
+	for testname, size := range benchTest {
+		b.Run(testname, func(b *testing.B) {
+			cache := NewMemStore(
+				store.WithContext(
+					NewContext(
+						context.Background(),
+						map[string]interface{}{
+							"maxCap": size,
+						},
+					)),
+			)
+
+			record := &store.Record{}
+			for i := 0; i < size; i++ {
+				v := strconv.Itoa(i)
+				record.Key = v
+				record.Value = []byte(v)
+				_ = cache.Write(record)
+			}
+
+			b.SetParallelism(threads)
+			b.ResetTimer()
+			b.RunParallel(func(pb *testing.PB) {
+				for pb.Next() {
+					v := strconv.Itoa(rand.Intn(size * 2)) //nolint:gosec
+					_, _ = cache.Read(v)
+				}
+			})
+		})
+	}
+}
+
+func concurrentRemoveBench(b *testing.B, threads int) {
+	benchTest := map[string]int{
+		"DefCap": 512,
+		"LimCap": 3,
+		"BigCap": 1000000,
+	}
+	for testname, size := range benchTest {
+		b.Run(testname, func(b *testing.B) {
+			cache := NewMemStore(
+				store.WithContext(
+					NewContext(
+						context.Background(),
+						map[string]interface{}{
+							"maxCap": size,
+						},
+					)),
+			)
+
+			record := &store.Record{}
+			for i := 0; i < size; i++ {
+				v := strconv.Itoa(i)
+				record.Key = v
+				record.Value = []byte(v)
+				_ = cache.Write(record)
+			}
+
+			b.SetParallelism(threads)
+			b.ResetTimer()
+			b.RunParallel(func(pb *testing.PB) {
+				record := &store.Record{}
+				for pb.Next() {
+					v := strconv.Itoa(rand.Intn(size * 2)) //nolint:gosec
+					_ = cache.Delete(v)
+					record.Key = v
+					record.Value = []byte(v)
+					_ = cache.Write(record)
+				}
+			})
+		})
+	}
+}
+
+func BenchmarkConcurrent(b *testing.B) {
+	threads := []int{3, 10, 50}
+	for _, nThreads := range threads {
+		nt := strconv.Itoa(nThreads)
+		b.Run("StoreT"+nt, func(b *testing.B) {
+			concurrentStoreBench(b, nThreads)
+		})
+		b.Run("RetrieveT"+nt, func(b *testing.B) {
+			concurrentRetrieveBench(b, nThreads)
+		})
+		b.Run("RemoveT"+nt, func(b *testing.B) {
+			concurrentRemoveBench(b, nThreads)
+		})
+	}
+}
diff --git a/ocis-pkg/store/memory/multimemstore_test.go b/ocis-pkg/store/memory/multimemstore_test.go
new file mode 100644
index 00000000000..30a9c8851ff
--- /dev/null
+++ b/ocis-pkg/store/memory/multimemstore_test.go
@@ -0,0 +1,172 @@
+package memory
+
+import (
+	"context"
+	"strconv"
+	"testing"
+
+	"go-micro.dev/v4/store"
+)
+
+func TestWriteReadTables(t *testing.T) {
+	cache := NewMultiMemStore()
+
+	record1 := &store.Record{
+		Key:   "sameKey",
+		Value: []byte("from record1"),
+	}
+	record2 := &store.Record{
+		Key:   "sameKey",
+		Value: []byte("from record2"),
+	}
+
+	_ = cache.Write(record1)
+	_ = cache.Write(record2, store.WriteTo("DB02", "Table02"))
+
+	records1, _ := cache.Read("sameKey")
+	if len(records1) != 1 {
+		t.Fatalf("Wrong number of records, expected 1, got %d", len(records1))
+	}
+	if records1[0].Key != "sameKey" {
+		t.Errorf("Wrong key, expected \"sameKey\", got %s", records1[0].Key)
+	}
+	if string(records1[0].Value) != "from record1" {
+		t.Errorf("Wrong value, expected \"from record1\", got %s", string(records1[0].Value))
+	}
+
+	records2, _ := cache.Read("sameKey", store.ReadFrom("DB02", "Table02"))
+	if len(records2) != 1 {
+		t.Fatalf("Wrong number of records, expected 1, got %d", len(records2))
+	}
+	if records2[0].Key != "sameKey" {
+		t.Errorf("Wrong key, expected \"sameKey\", got %s", records2[0].Key)
+	}
+	if string(records2[0].Value) != "from record2" {
+		t.Errorf("Wrong value, expected \"from record2\", got %s", string(records2[0].Value))
+	}
+}
+
+func TestDeleteTables(t *testing.T) {
+	cache := NewMultiMemStore()
+
+	record1 := &store.Record{
+		Key:   "sameKey",
+		Value: []byte("from record1"),
+	}
+	record2 := &store.Record{
+		Key:   "sameKey",
+		Value: []byte("from record2"),
+	}
+
+	_ = cache.Write(record1)
+	_ = cache.Write(record2, store.WriteTo("DB02", "Table02"))
+
+	records1, _ := cache.Read("sameKey")
+	if len(records1) != 1 {
+		t.Fatalf("Wrong number of records, expected 1, got %d", len(records1))
+	}
+	if records1[0].Key != "sameKey" {
+		t.Errorf("Wrong key, expected \"sameKey\", got %s", records1[0].Key)
+	}
+	if string(records1[0].Value) != "from record1" {
+		t.Errorf("Wrong value, expected \"from record1\", got %s", string(records1[0].Value))
+	}
+
+	records2, _ := cache.Read("sameKey", store.ReadFrom("DB02", "Table02"))
+	if len(records2) != 1 {
+		t.Fatalf("Wrong number of records, expected 1, got %d", len(records2))
+	}
+	if records2[0].Key != "sameKey" {
+		t.Errorf("Wrong key, expected \"sameKey\", got %s", records2[0].Key)
+	}
+	if string(records2[0].Value) != "from record2" {
+		t.Errorf("Wrong value, expected \"from record2\", got %s", string(records2[0].Value))
+	}
+
+	_ = cache.Delete("sameKey")
+	if _, err := cache.Read("sameKey"); err != store.ErrNotFound {
+		t.Errorf("Key \"sameKey\" still exists after deletion")
+	}
+
+	records2, _ = cache.Read("sameKey", store.ReadFrom("DB02", "Table02"))
+	if len(records2) != 1 {
+		t.Fatalf("Wrong number of records, expected 1, got %d", len(records2))
+	}
+	if records2[0].Key != "sameKey" {
+		t.Errorf("Wrong key, expected \"sameKey\", got %s", records2[0].Key)
+	}
+	if string(records2[0].Value) != "from record2" {
+		t.Errorf("Wrong value, expected \"from record2\", got %s", string(records2[0].Value))
+	}
+}
+
+func TestListTables(t *testing.T) {
+	cache := NewMultiMemStore()
+
+	record1 := &store.Record{
+		Key:   "key001",
+		Value: []byte("from record1"),
+	}
+	record2 := &store.Record{
+		Key:   "key002",
+		Value: []byte("from record2"),
+	}
+
+	_ = cache.Write(record1)
+	_ = cache.Write(record2, store.WriteTo("DB02", "Table02"))
+
+	keys, _ := cache.List(store.ListFrom("DB02", "Table02"))
+	expectedKeys := []string{"key002"}
+	if len(keys) != 1 {
+		t.Fatalf("Wrong number of keys, expected 1, got %d", len(keys))
+	}
+	for index, key := range keys {
+		if expectedKeys[index] != key {
+			t.Errorf("Wrong key for index %d, expected %s, got %s", index, expectedKeys[index], key)
+		}
+	}
+}
+
+func TestWriteSizeLimit(t *testing.T) {
+	cache := NewMultiMemStore(
+		store.WithContext(
+			NewContext(
+				context.Background(),
+				map[string]interface{}{
+					"maxCap": 2,
+				},
+			),
+		),
+	)
+
+	record := &store.Record{}
+	for i := 0; i < 4; i++ {
+		v := strconv.Itoa(i)
+		record.Key = v
+		record.Value = []byte(v)
+		_ = cache.Write(record)
+		_ = cache.Write(record, store.WriteTo("DB02", "Table02"))
+	}
+
+	keys1, _ := cache.List()
+	expectedKeys1 := []string{"2", "3"}
+	if len(keys1) != 2 {
+		t.Fatalf("Wrong number of keys, expected 2, got %d", len(keys1))
+	}
+	for index, key := range keys1 {
+		if expectedKeys1[index] != key {
+			t.Errorf("Wrong key for index %d, expected %s, got %s", index, expectedKeys1[index], key)
+		}
+	}
+
+	keys2, _ := cache.List(store.ListFrom("DB02", "Table02"))
+	expectedKeys2 := []string{"2", "3"}
+	if len(keys2) != 2 {
+		t.Fatalf("Wrong number of keys, expected 2, got %d", len(keys2))
+	}
+	for index, key := range keys2 {
+		if expectedKeys2[index] != key {
+			t.Errorf("Wrong key for index %d, expected %s, got %s", index, expectedKeys2[index], key)
+		}
+	}
+}
diff --git a/services/graph/pkg/service/v0/graph_test.go b/services/graph/pkg/service/v0/graph_test.go
index 0629e6ff674..fcd969c5821 100644
--- a/services/graph/pkg/service/v0/graph_test.go
+++ b/services/graph/pkg/service/v0/graph_test.go
@@ -17,6 +17,7 @@ import (
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
 	"github.com/owncloud/ocis/v2/services/graph/mocks"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
@@ -39,6 +40,8 @@ var _ = Describe("Graph", func() {
 		cfg = defaults.FullDefaultConfig()
 		cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
 		cfg.TokenManager.JWTSecret = "loremipsum"
+		cfg.Commons = &shared.Commons{}
+		cfg.Commons.OcisURL = "https://ocis.test"
 
 		gatewayClient = &mocks.GatewayClient{}
 		eventsPublisher = mocks.Publisher{}
@@ -108,7 +111,8 @@ var _ = Describe("Graph", func() {
 						"root":{
 							"id":"pro-1$sameID",
 							"webDavUrl":"https://localhost:9200/dav/spaces/pro-1$sameID"
-						}
+						},
+						"webUrl": "https://ocis.test/f/pro-1$sameID"
 					}
 				]
 			}
@@ -178,7 +182,8 @@ var _ = Describe("Graph", func() {
 							"eTag":"101112131415",
 							"id":"pro-1$asameID",
 							"webDavUrl":"https://localhost:9200/dav/spaces/pro-1$asameID"
-						}
+						},
+						"webUrl": "https://ocis.test/f/pro-1$asameID"
 					},
 					{
 						"driveAlias":"bspacetype/bspacename",
@@ -189,7 +194,8 @@ var _ = Describe("Graph", func() {
 							"eTag":"123456789",
 							"id":"pro-1$bsameID",
 							"webDavUrl":"https://localhost:9200/dav/spaces/pro-1$bsameID"
-						}
+						},
+						"webUrl": "https://ocis.test/f/pro-1$bsameID"
 					}
 				]
 			}
diff --git a/tests/acceptance/expected-failures-API-on-OCIS-storage.md b/tests/acceptance/expected-failures-API-on-OCIS-storage.md
index 8a9d4aee201..ba8c9509135 100644
--- a/tests/acceptance/expected-failures-API-on-OCIS-storage.md
+++ b/tests/acceptance/expected-failures-API-on-OCIS-storage.md
@@ -298,13 +298,6 @@ cannot share a folder with create permission
 - [apiTrashbin/trashbinSharingToShares.feature:212](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiTrashbin/trashbinSharingToShares.feature#L212)
 - [apiTrashbin/trashbinSharingToShares.feature:236](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiTrashbin/trashbinSharingToShares.feature#L236)
 
-
-#### [oc:privatelink property not returned in webdav responses](https://github.com/owncloud/product/issues/262)
-
-- [apiWebdavProperties2/getFileProperties.feature:306](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiWebdavProperties2/getFileProperties.feature#L306)
-- [apiWebdavProperties2/getFileProperties.feature:307](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiWebdavProperties2/getFileProperties.feature#L307)
-- [apiWebdavProperties2/getFileProperties.feature:312](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiWebdavProperties2/getFileProperties.feature#L312)
-
 #### [changing user quota gives ocs status 103 / Cannot set quota](https://github.com/owncloud/product/issues/247)
 
 - [apiShareOperationsToShares2/uploadToShare.feature:210](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiShareOperationsToShares2/uploadToShare.feature#L210)
diff --git a/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md b/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
index 5330a6acb7f..58bdd3ad640 100644
--- a/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
+++ b/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
@@ -32,8 +32,8 @@ The expected failures in this file are from features in the owncloud/ocis repo.
 - [apiSpaces/changingFilesShare.feature:12](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/changingFilesShare.feature#L12)
 
 ### [copy to overwrite (file and folder) from Personal to Shares Jail behaves differently](https://github.com/owncloud/ocis/issues/4393)
-- [apiSpaces/copySpaces.feature:487](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/copySpaces.feature#L487)
-- [apiSpaces/copySpaces.feature:501](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/copySpaces.feature#L501)
+- [apiSpaces/copySpaces.feature:488](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/copySpaces.feature#L488)
+- [apiSpaces/copySpaces.feature:502](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/copySpaces.feature#L502)
 
 ### [search doesn't find the project space by name](https://github.com/owncloud/ocis/issues/4506)
 - [apiSpaces/search.feature:95](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/search.feature#L95)
diff --git a/tests/acceptance/expected-failures-webUI-on-OCIS-storage.md b/tests/acceptance/expected-failures-webUI-on-OCIS-storage.md
index c6749c82e2f..aaa5aaf48ed 100644
--- a/tests/acceptance/expected-failures-webUI-on-OCIS-storage.md
+++ b/tests/acceptance/expected-failures-webUI-on-OCIS-storage.md
@@ -74,7 +74,6 @@ Other free text and markdown formatting can be used elsewhere in the document if
 - [webUIPrivateLinks/accessingPrivateLinks.feature:9](https://github.com/owncloud/web/blob/master/tests/acceptance/features/webUIPrivateLinks/accessingPrivateLinks.feature#L9)
 - [webUIPrivateLinks/accessingPrivateLinks.feature:17](https://github.com/owncloud/web/blob/master/tests/acceptance/features/webUIPrivateLinks/accessingPrivateLinks.feature#L17)
 - [webUIPrivateLinks/accessingPrivateLinks.feature:25](https://github.com/owncloud/web/blob/master/tests/acceptance/features/webUIPrivateLinks/accessingPrivateLinks.feature#L25)
-- [webUIPrivateLinks/accessingPrivateLinks.feature:35](https://github.com/owncloud/web/blob/master/tests/acceptance/features/webUIPrivateLinks/accessingPrivateLinks.feature#L35)
 
 ### [Share additional info](https://github.com/owncloud/ocis/issues/1253)
 - [webUISharingInternalUsersShareWithPage/shareWithUsers.feature:140](https://github.com/owncloud/web/blob/master/tests/acceptance/features/webUISharingInternalUsersShareWithPage/shareWithUsers.feature#L140)
diff --git a/tests/acceptance/features/apiSpaces/changeSpaces.feature b/tests/acceptance/features/apiSpaces/changeSpaces.feature
index 2ea08dfb430..629ce61601c 100644
--- a/tests/acceptance/features/apiSpaces/changeSpaces.feature
+++ b/tests/acceptance/features/apiSpaces/changeSpaces.feature
@@ -16,6 +16,7 @@ Feature: Change data of space
     And user "Alice" has created a space "Project Jupiter" of type "project" with quota "20"
     And user "Alice" has shared a space "Project Jupiter" to user "Brian" with role "editor"
     And user "Alice" has shared a space "Project Jupiter" to user "Bob" with role "viewer"
+    And using spaces DAV path
 
 
   Scenario Outline: Only space admin user can change the name of a Space via the Graph API
diff --git a/tests/acceptance/features/apiSpaces/copySpaces.feature b/tests/acceptance/features/apiSpaces/copySpaces.feature
index 11a5b57bfd1..09de24ed33d 100644
--- a/tests/acceptance/features/apiSpaces/copySpaces.feature
+++ b/tests/acceptance/features/apiSpaces/copySpaces.feature
@@ -9,6 +9,7 @@ Feature: copy file
       | username |
       | Alice    |
       | Brian    |
+    And using spaces DAV path
 
 
   Scenario Outline: Copying a file within a same space project with role manager and editor
@@ -527,9 +528,9 @@ Feature: copy file
     Then the HTTP status code should be "204"
     And as "Alice" folder "/Sample-Folder-A/sample-folder-b/sample-folder-c" should exist
     And for user "Alice" folder "BRIAN-FOLDER" of the space "Shares Jail" should contain these entries:
-      | /second-level-folder/third-level-folder/sample-folder-c |
+      | /second-level-folder/third-level-folder/sample-folder-c/ |
     And for user "Brian" folder "BRIAN-FOLDER" of the space "Personal" should contain these files:
-      | /second-level-folder/third-level-folder/sample-folder-c |
+      | /second-level-folder/third-level-folder/sample-folder-c/ |
     And the response when user "Alice" gets the info of the last share should include
       | file_target | /Shares/BRIAN-FOLDER |
 
@@ -594,7 +595,7 @@ Feature: copy file
     And for user "Alice" folder "BRIAN-FOLDER" of the space "Shares Jail" should contain these entries:
       | /second-level-folder/third-level-file.txt/third-level-folder |
     And for user "Alice" folder "BRIAN-FOLDER" of the space "Shares Jail" should not contain these entries:
-      | /second-level-folder/second-level-folder |
+      | /second-level-folder/second-level-folder/ |
     And the response when user "Alice" gets the info of the last share should include
       | file_target | /Shares/BRIAN-FOLDER |
 
@@ -616,7 +617,7 @@ Feature: copy file
     Then the HTTP status code should be "204"
     And as "Alice" folder "/Sample-Folder-A/sample-folder-b/sample-folder-c" should exist
     And for user "Alice" folder "BRIAN-FOLDER" of the space "Shares Jail" should contain these files:
-      | /second-level-folder/third-level-folder/sample-folder-c |
+      | /second-level-folder/third-level-folder/sample-folder-c/ |
     And the response when user "Alice" gets the info of the last share should include
       | file_target | /Shares/BRIAN-FOLDER |
 
@@ -684,8 +685,8 @@ Feature: copy file
     When user "Alice" copies folder "/FOLDER/second-level-folder" from space "Personal" to "/BRIAN-FOLDER/second-level-folder/third-level-file.txt" inside space "Shares Jail" using the WebDAV API
     Then the HTTP status code should be "204"
     And for user "Alice" folder "BRIAN-FOLDER" of the space "Shares Jail" should contain these files:
-      | /second-level-folder/third-level-file.txt                    |
-      | /second-level-folder/third-level-file.txt/third-level-folder |
+      | /second-level-folder/third-level-file.txt/                    |
+      | /second-level-folder/third-level-file.txt/third-level-folder/ |
     And as "Alice" folder "FOLDER/second-level-folder/third-level-folder" should exist
     And for user "Alice" folder "BRIAN-FOLDER" of the space "Shares Jail" should not contain these files:
       | /second-level-folder/second-level-folder |
diff --git a/tests/acceptance/features/apiSpaces/createFileFolderWhenSharesSpaceExist.feature b/tests/acceptance/features/apiSpaces/createFileFolderWhenSharesSpaceExist.feature
index 912225f6adf..2d657526059 100644
--- a/tests/acceptance/features/apiSpaces/createFileFolderWhenSharesSpaceExist.feature
+++ b/tests/acceptance/features/apiSpaces/createFileFolderWhenSharesSpaceExist.feature
@@ -18,12 +18,12 @@ Feature: create file or folder named similar to Shares folder
     When user "Brian" creates folder "<folder_name>" using the WebDAV API
     Then the HTTP status code should be "201"
     And for user "Brian" the space "Personal" should contain these entries:
-      | /<folder_name> |
+      | <folder_name>/ |
     Examples:
       | folder_name |
-      | /Share      |
-      | /shares     |
-      | /Share1     |
+      | Share       |
+      | shares      |
+      | Share1      |
 
   Scenario Outline: create a file with a name similar to Shares
     Given using spaces DAV path
@@ -31,25 +31,25 @@ Feature: create file or folder named similar to Shares folder
     Then the HTTP status code should be "201"
     And the content of file "<file_name>" for user "Brian" should be "some text"
     And for user "Brian" the space "Personal" should contain these entries:
-      | /<file_name> |
+      | <file_name> |
     And for user "Brian" the space "Shares Jail" should contain these entries:
-      | /FOLDER |
+      | FOLDER/ |
     Examples:
       | file_name |
-      | /Share    |
-      | /shares   |
-      | /Share1   |
+      | Share    |
+      | shares   |
+      | Share1   |
 
   Scenario: try to create a folder named Shares
     Given using spaces DAV path
     When user "Brian" creates folder "/Shares" using the WebDAV API
     Then the HTTP status code should be "201"
     And for user "Brian" the space "Shares Jail" should contain these entries:
-      | /FOLDER |
+      | FOLDER/ |
 
   Scenario: try to create a file named Shares
     Given using spaces DAV path
     When user "Brian" uploads file with content "some text" to "/Shares" using the WebDAV API
     Then the HTTP status code should be "201"
     And for user "Brian" the space "Shares Jail" should contain these entries:
-      | /FOLDER |
+      | FOLDER/ |
diff --git a/tests/acceptance/features/apiSpaces/download.feature b/tests/acceptance/features/apiSpaces/download.feature
index 983c8b58afe..af74af5841b 100644
--- a/tests/acceptance/features/apiSpaces/download.feature
+++ b/tests/acceptance/features/apiSpaces/download.feature
@@ -11,6 +11,7 @@ Feature: Download file in project space
             | Alice    |
             | Brian    |
             | Bob    |
+        And using spaces DAV path
         And the administrator has given "Alice" the role "Space Admin" using the settings api
         And user "Alice" has created a space "download file" with the default quota using the GraphApi
         And user "Alice" has uploaded a file inside space "download file" with content "some content" to "file.txt"
diff --git a/tests/acceptance/features/apiSpaces/editPublicLinkOfSpace.feature b/tests/acceptance/features/apiSpaces/editPublicLinkOfSpace.feature
index 29e4a96f902..e065f30815e 100644
--- a/tests/acceptance/features/apiSpaces/editPublicLinkOfSpace.feature
+++ b/tests/acceptance/features/apiSpaces/editPublicLinkOfSpace.feature
@@ -14,6 +14,7 @@ Feature: A manager of the space can edit public link
       | username |
       | Alice    |
       | Brian    |
+    And using spaces DAV path
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "edit space" with the default quota using the GraphApi
     And user "Alice" has created a public link share of the space "edit space" with settings:
diff --git a/tests/acceptance/features/apiSpaces/filePreviews.feature b/tests/acceptance/features/apiSpaces/filePreviews.feature
index 93f9009cb74..6d861d3e832 100644
--- a/tests/acceptance/features/apiSpaces/filePreviews.feature
+++ b/tests/acceptance/features/apiSpaces/filePreviews.feature
@@ -11,6 +11,7 @@ Feature: Preview file in project space
       | Alice    |
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "previews of the files" with the default quota using the GraphApi
+    And using spaces DAV path
 
 
   Scenario Outline: An user can preview created txt files in the project space
diff --git a/tests/acceptance/features/apiSpaces/listSpaces.feature b/tests/acceptance/features/apiSpaces/listSpaces.feature
index f4f3e96a0a4..bf80f38ac79 100644
--- a/tests/acceptance/features/apiSpaces/listSpaces.feature
+++ b/tests/acceptance/features/apiSpaces/listSpaces.feature
@@ -8,6 +8,7 @@ Feature: List and create spaces
 
   Background:
     Given user "Alice" has been created with default attributes and without skeleton files
+    And using spaces DAV path
 
 
   Scenario: An ordinary user can request information about their Space via the Graph API
diff --git a/tests/acceptance/features/apiSpaces/moveSpaces.feature b/tests/acceptance/features/apiSpaces/moveSpaces.feature
index dc7297f6e84..4a04c377314 100644
--- a/tests/acceptance/features/apiSpaces/moveSpaces.feature
+++ b/tests/acceptance/features/apiSpaces/moveSpaces.feature
@@ -9,6 +9,7 @@ Feature: move (rename) file
       | username |
       | Alice    |
       | Brian    |
+    And using spaces DAV path
 
   Scenario Outline: Moving a file within same space project with role manager and editor
     Given the administrator has given "Brian" the role "Space Admin" using the settings api
diff --git a/tests/acceptance/features/apiSpaces/quota.feature b/tests/acceptance/features/apiSpaces/quota.feature
index 781d6b1ce6b..72f29d5bf5a 100644
--- a/tests/acceptance/features/apiSpaces/quota.feature
+++ b/tests/acceptance/features/apiSpaces/quota.feature
@@ -14,6 +14,7 @@ Feature: State of the quota
   Background:
     Given user "Alice" has been created with default attributes and without skeleton files
     And the administrator has given "Alice" the role "Space Admin" using the settings api
+    And using spaces DAV path
 
 
   Scenario Outline: Quota information is returned in the list of spaces returned via the Graph API
diff --git a/tests/acceptance/features/apiSpaces/removeSpaceObjects.feature b/tests/acceptance/features/apiSpaces/removeSpaceObjects.feature
index e7650acb72b..bdcd09eaaf0 100644
--- a/tests/acceptance/features/apiSpaces/removeSpaceObjects.feature
+++ b/tests/acceptance/features/apiSpaces/removeSpaceObjects.feature
@@ -13,6 +13,7 @@ Feature: Remove files, folder
       | username |
       | Alice    |
       | Brian    |
+    And using spaces DAV path
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "delete objects" with the default quota using the GraphApi
     And user "Alice" has created a folder "folderForDeleting/sub1/sub2" in space "delete objects"
diff --git a/tests/acceptance/features/apiSpaces/restoreSpaceObjects.feature b/tests/acceptance/features/apiSpaces/restoreSpaceObjects.feature
index b1c970c6280..74c81fd48a9 100644
--- a/tests/acceptance/features/apiSpaces/restoreSpaceObjects.feature
+++ b/tests/acceptance/features/apiSpaces/restoreSpaceObjects.feature
@@ -14,6 +14,7 @@ Feature: Restore files, folder
             | Brian    |
             | Bob      |
             | Carol    |
+        And using spaces DAV path
         And the administrator has given "Alice" the role "Space Admin" using the settings api
         And user "Alice" creates a space "restore objects" of type "project" with the default quota using the GraphApi
         And user "Alice" has created a folder "newFolder" in space "restore objects"
diff --git a/tests/acceptance/features/apiSpaces/restoreSpaces.feature b/tests/acceptance/features/apiSpaces/restoreSpaces.feature
index cc881478610..e2a4b8db9c4 100644
--- a/tests/acceptance/features/apiSpaces/restoreSpaces.feature
+++ b/tests/acceptance/features/apiSpaces/restoreSpaces.feature
@@ -16,6 +16,7 @@ Feature: Restoring space
       | Bob      |
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "restore a space" of type "project" with quota "10"
+    And using spaces DAV path
 
 
   Scenario: An owner can restore a Space via the Graph API
diff --git a/tests/acceptance/features/apiSpaces/search.feature b/tests/acceptance/features/apiSpaces/search.feature
index ade0b35c12f..8fcd40f5c57 100644
--- a/tests/acceptance/features/apiSpaces/search.feature
+++ b/tests/acceptance/features/apiSpaces/search.feature
@@ -10,12 +10,12 @@ Feature: Search
       | username |
       | Alice    |
       | Brian    |
-    And using new DAV path
+    And using spaces DAV path
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "find data" with the default quota using the GraphApi
     And user "Alice" has created a folder "folder/SubFolder1/subFOLDER2" in space "find data"
     And user "Alice" has uploaded a file inside space "find data" with content "some content" to "folder/SubFolder1/subFOLDER2/insideTheFolder.txt"
-
+    And using new DAV path
 
   Scenario: Alice can find data from the project space
     When user "Alice" searches for "fol" using the WebDAV API
@@ -51,7 +51,7 @@ Feature: Search
       | /SubFolder1/subFOLDER2                     |
       | /SubFolder1/subFOLDER2/insideTheFolder.txt |
     And for user "Brian" the search result should contain space "mountpoint/folder"
-    
+
 
   Scenario: User can find hidden file
     Given user "Alice" has created a folder ".space" in space "find data"
@@ -72,7 +72,7 @@ Feature: Search
       | /SubFolder1/subFOLDER2                     |
       | /SubFolder1/subFOLDER2/insideTheFolder.txt |
 
-  
+
   Scenario: User cannot find declined folder
     Given user "Alice" shares the following entity "folder" inside of space "find data" with user "Brian" with role "viewer"
     And user "Brian" has declined share "/folder" offered by user "Alice"
@@ -90,7 +90,7 @@ Feature: Search
     When user "Alice" searches for "folder" using the WebDAV API
     Then the HTTP status code should be "207"
     And the search result should contain "0" entries
-    
+
 
   Scenario: User can find project space by name
     When user "Alice" searches for "find data" using the WebDAV API
diff --git a/tests/acceptance/features/apiSpaces/shareSpaces.feature b/tests/acceptance/features/apiSpaces/shareSpaces.feature
index 176423df0f7..e95c97f2ee4 100644
--- a/tests/acceptance/features/apiSpaces/shareSpaces.feature
+++ b/tests/acceptance/features/apiSpaces/shareSpaces.feature
@@ -14,6 +14,7 @@ Feature: Share spaces
       | Bob      |
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "share space" with the default quota using the GraphApi
+    And using spaces DAV path
 
 
   Scenario Outline:: A user can share a space to another user
diff --git a/tests/acceptance/features/apiSpaces/shareSpacesViaLink.feature b/tests/acceptance/features/apiSpaces/shareSpacesViaLink.feature
index 643157df974..49b7d2cbb99 100644
--- a/tests/acceptance/features/apiSpaces/shareSpacesViaLink.feature
+++ b/tests/acceptance/features/apiSpaces/shareSpacesViaLink.feature
@@ -11,6 +11,7 @@ Feature: Share spaces via link
             | username |
             | Alice    |
             | Brian    |
+        And using spaces DAV path
         And the administrator has given "Alice" the role "Space Admin" using the settings api
         And user "Alice" has created a space "share space" with the default quota using the GraphApi
         And user "Alice" has uploaded a file inside space "share space" with content "some content" to "test.txt"
diff --git a/tests/acceptance/features/apiSpaces/shareSubItemOfSpace.feature b/tests/acceptance/features/apiSpaces/shareSubItemOfSpace.feature
index 184fae7da6b..228524400d9 100644
--- a/tests/acceptance/features/apiSpaces/shareSubItemOfSpace.feature
+++ b/tests/acceptance/features/apiSpaces/shareSubItemOfSpace.feature
@@ -13,13 +13,15 @@ Feature: Share a file or folder that is inside a space
       | Alice    |
       | Brian    |
       | Bob      |
+    And using spaces DAV path
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "share sub-item" with the default quota using the GraphApi
     And user "Alice" has created a folder "folder" in space "share sub-item"
     And user "Alice" has uploaded a file inside space "share sub-item" with content "some content" to "file.txt"
+  And using new DAV path
 
-
-  Scenario Outline: A manager of the space can share an entity inside project space to another user with role:
+    
+  Scenario Outline: A manager of the space can share an entity inside project space to another user with role
     When user "Alice" shares the following entity "<entity>" inside of space "share sub-item" with user "Brian" with role "<role>"
     Then the HTTP status code should be "200"
     And the OCS status code should be "200"
diff --git a/tests/acceptance/features/apiSpaces/shareSubItemOfSpaceViaPublicLink.feature b/tests/acceptance/features/apiSpaces/shareSubItemOfSpaceViaPublicLink.feature
index 96f74ad2bdd..ca027bcf380 100644
--- a/tests/acceptance/features/apiSpaces/shareSubItemOfSpaceViaPublicLink.feature
+++ b/tests/acceptance/features/apiSpaces/shareSubItemOfSpaceViaPublicLink.feature
@@ -23,6 +23,7 @@ Feature: Share a file or folder that is inside a space via public link
       | username |
       | Alice    |
       | Brian    |
+    And using spaces DAV path
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "share sub-item" with the default quota using the GraphApi
     And user "Alice" has created a folder "folder" in space "share sub-item"
diff --git a/tests/acceptance/features/apiSpaces/uploadSpaces.feature b/tests/acceptance/features/apiSpaces/uploadSpaces.feature
index 9b170787cf9..c987c688d25 100644
--- a/tests/acceptance/features/apiSpaces/uploadSpaces.feature
+++ b/tests/acceptance/features/apiSpaces/uploadSpaces.feature
@@ -14,6 +14,7 @@ Feature: Upload files into a space
       | Bob      |
     And the administrator has given "Alice" the role "Space Admin" using the settings api
     And user "Alice" has created a space "Project Ceres" of type "project" with quota "2000"
+    And using spaces DAV path
 
 
   Scenario Outline: An user creates a folder in the Space via the Graph API
diff --git a/tests/acceptance/features/bootstrap/SpacesContext.php b/tests/acceptance/features/bootstrap/SpacesContext.php
index dac9c6fc2b0..fc999aab846 100644
--- a/tests/acceptance/features/bootstrap/SpacesContext.php
+++ b/tests/acceptance/features/bootstrap/SpacesContext.php
@@ -69,7 +69,10 @@ class SpacesContext implements Context {
 	 * @var ChecksumContext
 	 */
 	private ChecksumContext $checksumContext;
-
+	/**
+	 * @var FilesVersionsContext
+	 */
+	private FilesVersionsContext $filesVersionsContext;
 	/**
 	 * @var string
 	 */
@@ -107,18 +110,6 @@ class SpacesContext implements Context {
 	 */
 	private $storedEtags = [];
 
-	/**
-	 *
-	 * @var string[][]
-	 */
-	private $tokenOfLastLock = [];
-
-	private $etagPropfindBody = '<?xml version="1.0"?>'
-		. '<d:propfind xmlns:d="DAV:" '
-		. 'xmlns:oc="http://owncloud.org/ns" '
-		. 'xmlns:ocs="http://open-collaboration-services.org/ns">'
-		. '<d:prop><d:getetag/></d:prop></d:propfind>';
-
 	/**
 	 * @param string $spaceName
 	 *
@@ -192,22 +183,6 @@ class SpacesContext implements Context {
 	 */
 	private string $responseSpaceId;
 
-	/**
-	 * @param string $responseSpaceId
-	 *
-	 * @return void
-	 */
-	public function setResponseSpaceId(string $responseSpaceId): void {
-		$this->responseSpaceId = $responseSpaceId;
-	}
-
-	/**
-	 * @return string
-	 */
-	public function getResponseSpaceId(): string {
-		return $this->responseSpaceId;
-	}
-
 	/**
 	 * Get SpaceId by Name
 	 *
@@ -285,6 +260,8 @@ class SpacesContext implements Context {
 	 */
 	public function setSpaceIDByName(string $user, string $spaceName): void {
 		$space = $this->getSpaceByName($user, $spaceName);
+		Assert::assertIsArray($space, "Space with name $spaceName not found");
+		Assert::assertNotEmpty($space["root"]["webDavUrl"], "WebDavUrl for space with name $spaceName not found");
 		WebDavHelper::$SPACE_ID_FROM_OCIS = $space['id'];
 	}
 
@@ -340,29 +317,6 @@ class SpacesContext implements Context {
 		return $this->featureContext->getResponse();
 	}
 
-	/**
-	 * Download a file using user password
-	 *
-	 * @param string $fullUrl
-	 * @param string $user
-	 *
-	 * @return void
-	 */
-	public function downloadFileAsUserUsingPassword(
-		string $fullUrl,
-		string $user
-	):void {
-		$this->featureContext->setResponse(
-			HttpRequestHelper::sendRequest(
-				$fullUrl,
-				$this->featureContext->getStepLineRef(),
-				'GET',
-				$user,
-				$this->featureContext->getPasswordForUser($user),
-			)
-		);
-	}
-
 	/**
 	 * The method returns fileId
 	 *
@@ -456,6 +410,7 @@ class SpacesContext implements Context {
 		$this->webDavPropertiesContext = $environment->getContext('WebDavPropertiesContext');
 		$this->favoritesContext = $environment->getContext('FavoritesContext');
 		$this->checksumContext = $environment->getContext('ChecksumContext');
+		$this->filesVersionsContext = $environment->getContext('FilesVersionsContext');
 		// Run the BeforeScenario function in OCSContext to set it up correctly
 		$this->ocsContext->before($scope);
 		$this->baseUrl = \trim($this->featureContext->getBaseUrl(), "/");
@@ -542,31 +497,6 @@ class SpacesContext implements Context {
 		return HttpRequestHelper::sendRequest($fullUrl, $xRequestId, 'PROPFIND', $user, $password, $headers, $body);
 	}
 
-	/**
-	 * Send Put Request to Url
-	 *
-	 * @param string $fullUrl
-	 * @param string $user
-	 * @param string $password
-	 * @param string $xRequestId
-	 * @param array $headers
-	 * @param string $content
-	 *
-	 * @return ResponseInterface
-	 *
-	 * @throws GuzzleException
-	 */
-	public function sendPutRequestToUrl(
-		string $fullUrl,
-		string $user,
-		string $password,
-		string $xRequestId = '',
-		array $headers = [],
-		string $content = ""
-	): ResponseInterface {
-		return HttpRequestHelper::sendRequest($fullUrl, $xRequestId, 'PUT', $user, $password, $headers, $content);
-	}
-
 	/**
 	 * Send POST Request to url
 	 *
@@ -856,24 +786,20 @@ class SpacesContext implements Context {
 		string $spaceName,
 		string $foldersPath = ''
 	): void {
-		$space = $this->getSpaceByName($user, $spaceName);
-		Assert::assertIsArray($space);
-		Assert::assertNotEmpty($spaceId = $space["id"]);
-		Assert::assertNotEmpty($spaceWebDavUrl = $space["root"]["webDavUrl"]);
-		$headers['Depth'] = 'infinity';
+		$this->setSpaceIDByName($user, $spaceName);
 		$this->featureContext->setResponse(
-			$this->sendPropfindRequestToUrl(
-				$spaceWebDavUrl . '/' . $foldersPath,
+			WebDavHelper::propfind(
+				$this->featureContext->getBaseUrl(),
 				$user,
 				$this->featureContext->getPasswordForUser($user),
+				$foldersPath,
+				[],
 				'',
-				$headers
+				'infinity',
+				'files',
+				WebDavHelper::DAV_VERSION_SPACES
 			)
 		);
-		$this->setResponseSpaceId($spaceId);
-		$this->setResponseXml(
-			HttpRequestHelper::parseResponseAsXml($this->featureContext->getResponse())
-		);
 	}
 
 	/**
@@ -890,10 +816,11 @@ class SpacesContext implements Context {
 		string $shouldOrNot,
 		TableNode $expectedFiles
 	): void {
-		$this->propfindResultShouldContainEntries(
+		$this->featureContext->propfindResultShouldContainEntries(
 			$shouldOrNot,
 			$expectedFiles,
 		);
+		WebDavHelper::$SPACE_ID_FROM_OCIS = '';
 	}
 
 	/**
@@ -912,14 +839,18 @@ class SpacesContext implements Context {
 		string $shouldOrNot,
 		TableNode $expectedFiles
 	): void {
+		$spaceCreator = $this->getSpaceCreator($spaceName);
+		$space = $this->getSpaceByName($spaceCreator, $spaceName);
 		$this->theUserListsTheContentOfAPersonalSpaceRootUsingTheWebDAvApi(
-			$this->getSpaceCreator($spaceName),
+			$spaceCreator,
 			$spaceName
 		);
-		$this->propfindResultShouldContainEntries(
+		WebDavHelper::$SPACE_ID_FROM_OCIS = $space['id'];
+		$this->featureContext->propfindResultShouldContainEntries(
 			$shouldOrNot,
 			$expectedFiles,
 		);
+		WebDavHelper::$SPACE_ID_FROM_OCIS = '';
 	}
 
 	/**
@@ -940,14 +871,14 @@ class SpacesContext implements Context {
 		string $shouldOrNot,
 		TableNode $expectedFiles
 	): void {
+		$space = $this->getSpaceByName($user, $spaceName);
 		$this->theUserListsTheContentOfAPersonalSpaceRootUsingTheWebDAvApi(
 			$user,
 			$spaceName
 		);
-		$this->propfindResultShouldContainEntries(
-			$shouldOrNot,
-			$expectedFiles
-		);
+		WebDavHelper::$SPACE_ID_FROM_OCIS = $space['id'];
+		$this->featureContext->propfindResultShouldContainEntries($shouldOrNot, $expectedFiles, $user, 'PROPFIND');
+		WebDavHelper::$SPACE_ID_FROM_OCIS = '';
 	}
 
 	/**
@@ -970,16 +901,21 @@ class SpacesContext implements Context {
 		string $shouldOrNot,
 		TableNode $expectedFiles
 	): void {
+		$space = $this->getSpaceByName($user, $spaceName);
 		$this->theUserListsTheContentOfAPersonalSpaceRootUsingTheWebDAvApi(
 			$user,
 			$spaceName,
 			$folderPath
 		);
-		$this->propfindResultShouldContainEntries(
+		WebDavHelper::$SPACE_ID_FROM_OCIS = $space['id'];
+		$this->featureContext->propfindResultShouldContainEntries(
 			$shouldOrNot,
 			$expectedFiles,
+			$this->featureContext->getActualUsername($user),
+			'PROPFIND',
 			$folderPath
 		);
+		WebDavHelper::$SPACE_ID_FROM_OCIS = '';
 	}
 
 	/**
@@ -1214,43 +1150,6 @@ class SpacesContext implements Context {
 		}
 	}
 
-	/**
-	 * @param string    $shouldOrNot   (not|)
-	 * @param TableNode $expectedFiles
-	 * @param string $folderPath
-	 *
-	 * @return void
-	 *
-	 * @throws Exception
-	 */
-	public function propfindResultShouldContainEntries(
-		string $shouldOrNot,
-		TableNode $expectedFiles,
-		string $folderPath = ''
-	): void {
-		$this->verifyTableNodeColumnsCount($expectedFiles, 1);
-		$elementRows = $expectedFiles->getRows();
-		$should = ($shouldOrNot !== "not");
-
-		foreach ($elementRows as $expectedFile) {
-			$fileFound = $this->findEntryFromPropfindResponse(
-				$expectedFile[0],
-				$folderPath
-			);
-			if ($should) {
-				Assert::assertNotEmpty(
-					$fileFound,
-					"response does not contain the entry '$expectedFile[0]'"
-				);
-			} else {
-				Assert::assertEmpty(
-					$fileFound,
-					"response does contain the entry '$expectedFile[0]' but should not"
-				);
-			}
-		}
-	}
-
 	/**
 	 * Verify that the tableNode contains expected number of columns
 	 *
@@ -1288,72 +1187,6 @@ class SpacesContext implements Context {
 		return \str_replace([" ", "(", ")"], ["%20", "%28", "%29"], $path);
 	}
 
-	/**
-	 * parses a PROPFIND response from $this->response into xml
-	 * and returns found search results if found else returns false
-	 *
-	 * @param  string|null $entryNameToSearch
-	 * @param  string $folderPath
-	 *
-	 * @return array
-	 * string if $entryNameToSearch is given and is found
-	 * array if $entryNameToSearch is not given
-	 * boolean false if $entryNameToSearch is given and is not found
-	 */
-	public function findEntryFromPropfindResponse(
-		string $entryNameToSearch = null,
-		string $folderPath = ''
-	): array {
-		$spaceId = $this->getResponseSpaceId();
-		//if we are using that step the second time in a scenario e.g. 'But ... should not'
-		//then don't parse the result again, because the result is in a ResponseInterface
-		if (empty($this->getResponseXml())) {
-			$this->setResponseXml(
-				HttpRequestHelper::parseResponseAsXml($this->featureContext->getResponse())
-			);
-		}
-		Assert::assertNotEmpty($this->getResponseXml(), __METHOD__ . ' Response is empty');
-		Assert::assertNotEmpty($spaceId, __METHOD__ . ' SpaceId is empty');
-
-		// trim any leading "/" passed by the caller, we can just match the "raw" name
-		$trimmedEntryNameToSearch = \trim($entryNameToSearch, "/");
-
-		// url encode for spaces and brackets that may appear in the filePath
-		$folderPath = $this->escapePath($folderPath);
-
-		// topWebDavPath should be something like /remote.php/webdav/ or
-		// /remote.php/dav/files/alice/
-		$topWebDavPath = "/" . "dav/spaces/" . $spaceId . "/" . $folderPath;
-
-		Assert::assertIsArray(
-			$this->responseXml,
-			__METHOD__ . " responseXml for space $spaceId is not an array"
-		);
-		Assert::assertArrayHasKey(
-			"value",
-			$this->responseXml,
-			__METHOD__ . " responseXml for space $spaceId does not have key 'value'"
-		);
-		$multistatusResults = $this->responseXml["value"];
-		$results = [];
-		if ($multistatusResults !== null) {
-			foreach ($multistatusResults as $multistatusResult) {
-				$entryPath = \urldecode($multistatusResult['value'][0]['value']);
-				$entryName = \str_replace($topWebDavPath, "", $entryPath);
-				$entryName = \rawurldecode($entryName);
-				$entryName = \trim($entryName, "/");
-				if ($trimmedEntryNameToSearch === $entryName) {
-					return $multistatusResult;
-				}
-				$results[] = $entryName;
-			}
-		}
-		if ($entryNameToSearch === null) {
-			return $results;
-		}
-		return [];
-	}
-
 	/**
 	 * @When /^user "([^"]*)" creates a (?:folder|subfolder) "([^"]*)" in space "([^"]*)" using the WebDav Api$/
 	 *
@@ -1423,19 +1256,8 @@ class SpacesContext implements Context {
 		if ($ownerUser === '') {
 			$ownerUser = $user;
 		}
-
-		$space = $this->getSpaceByName($ownerUser, $spaceName);
-
-		$fullUrl = $this->baseUrl . "/dav/spaces/" . $space['id'] . '/' . $folder;
-
-		$this->featureContext->setResponse(
-			$this->sendCreateFolderRequest(
-				$fullUrl,
-				"MKCOL",
-				$user,
-				$this->featureContext->getPasswordForUser($user)
-			)
-		);
+		$this->setSpaceIDByName($ownerUser, $spaceName);
+		$this->featureContext->userCreatesFolder($user, $folder);
 	}
 
 	/**
@@ -1456,20 +1278,8 @@ class SpacesContext implements Context {
 		string $content,
 		string $destination
 	): void {
-		$space = $this->getSpaceByName($user, $spaceName);
-		Assert::assertIsArray($space, "Space with name $spaceName not found");
-		Assert::assertNotEmpty($space["root"]["webDavUrl"], "WebDavUrl for space with name $spaceName not found");
-
-		$this->featureContext->setResponse(
-			$this->sendPutRequestToUrl(
-				$space["root"]["webDavUrl"] . "/" . $destination,
-				$user,
-				$this->featureContext->getPasswordForUser($user),
-				"",
-				[],
-				$content
-			)
-		);
+		$this->setSpaceIDByName($user, $spaceName);
+		$this->featureContext->uploadFileWithContent($user, $content, $destination);
 	}
 
 	/**
@@ -1492,20 +1302,8 @@ class SpacesContext implements Context {
 		string $content,
 		string $destination
 	): void {
-		$space = $this->getSpaceByName($ownerUser, $spaceName);
-		Assert::assertIsArray($space, "Space with name $spaceName not found");
-		Assert::assertNotEmpty($space["root"]["webDavUrl"], "WebDavUrl for space with name $spaceName not found");
-
-		$this->featureContext->setResponse(
-			$this->sendPutRequestToUrl(
-				$space["root"]["webDavUrl"] . "/" . $destination,
-				$user,
-				$this->featureContext->getPasswordForUser($user),
-				"",
-				[],
-				$content
-			)
-		);
+		$this->setSpaceIDByName($ownerUser, $spaceName);
+		$this->featureContext->uploadFileWithContent($user, $content, $destination);
 	}
 
 	/**
@@ -1917,7 +1715,7 @@ class SpacesContext implements Context {
 	):void {
 		$space = $this->getSpaceByName($user, $spaceName);
 		$fullUrl = $space["root"]["webDavUrl"] . '/' . ltrim($fileName, "/");
-		$this->downloadFileAsUserUsingPassword($fullUrl, $user);
+		$this->featureContext->downloadFileAsUserUsingPassword($user, $fileName, $this->featureContext->getPasswordForUser($user));
 		Assert::assertGreaterThanOrEqual(
 			400,
 			$this->featureContext->getResponse()->getStatusCode(),
@@ -2017,22 +1815,7 @@ class SpacesContext implements Context {
 		string $destination
 	): void {
 		$this->theUserListsAllHisAvailableSpacesUsingTheGraphApi($user);
-
-		$space = $this->getSpaceByName($user, $spaceName);
-		Assert::assertIsArray($space, "Space with name $spaceName not found");
-		Assert::assertNotEmpty($space["root"]["webDavUrl"], "WebDavUrl for space with name $spaceName not found");
-
-		$this->featureContext->setResponse(
-			$this->sendPutRequestToUrl(
-				$space["root"]["webDavUrl"] . "/" . $destination,
-				$user,
-				$this->featureContext->getPasswordForUser($user),
-				"",
-				[],
-				$fileContent
-			)
-		);
-
+		$this->theUserUploadsAFileToSpace($user, $spaceName, $fileContent, $destination);
 		$this->featureContext->theHTTPStatusCodeShouldBeOr(201, 204);
 	}
 
@@ -2098,7 +1881,6 @@ class SpacesContext implements Context {
 		];
 
 		$fullUrl = $this->baseUrl . $this->ocsApiUrl;
-
 		$this->featureContext->setResponse(
 			$this->sendPostRequestToUrl(
 				$fullUrl,
@@ -2671,20 +2453,8 @@ class SpacesContext implements Context {
 		string $fileName,
 		string $spaceName
 	): void {
-		$space = $this->getSpaceByName($user, $spaceName);
-		$fullUrl = $this->baseUrl . $this->davSpacesUrl . $space['id'] . '/' . $fileName;
-
-		$this->featureContext->setResponse(
-			HttpRequestHelper::sendRequest(
-				$fullUrl,
-				"",
-				'HEAD',
-				$user,
-				$this->featureContext->getPasswordForUser($user),
-				[],
-				""
-			)
-		);
+		$this->setSpaceIDByName($user, $spaceName);
+		$this->featureContext->downloadFileAsUserUsingPassword($user, $fileName, $this->featureContext->getPasswordForUser($user));
 	}
 
 	/**
@@ -2746,56 +2516,9 @@ class SpacesContext implements Context {
 		string $index,
 		string $spaceName
 	): void {
-		$fileVersion = $this->listFileVersion($user, $fileName, $spaceName);
-		if (!isset($fileVersion[$index])) {
-			Assert::fail(
-				'could not find version of file "' . $fileName . '" with index "' . $index . '"'
-			);
-		}
-		$url = $this->baseUrl . $fileVersion[$index][0];
-
-		$this->featureContext->setResponse(
-			HttpRequestHelper::sendRequest(
-				$url,
-				"",
-				'HEAD',
-				$user,
-				$this->featureContext->getPasswordForUser($user),
-				[],
-				""
-			)
-		);
-	}
-
-	/**
-	 * Method returns an array with url values from the propfind request
-	 * like: /remote.php/dav/meta/spaceUuid%fileUuid/v/fileUuid.REV.2022-05-17T10:39:49.672285951Z
-	 *
-	 * @param  string $user
-	 * @param  string $fileName
-	 * @param  string $spaceName
-	 *
-	 * @return array
-	 * @throws GuzzleException
-	 */
-	public function listFileVersion(
-		string $user,
-		string $fileName,
-		string $spaceName
-	): array {
-		$fileId = $this->getFileId($user, $spaceName, $fileName);
-		$fullUrl = $this->baseUrl . '/remote.php/dav/meta/' . $fileId . '/v';
-
-		$this->featureContext->setResponse(
-			$this->sendPropfindRequestToUrl(
-				$fullUrl,
-				$user,
-				$this->featureContext->getPasswordForUser($user)
-			)
-		);
-
-		$responseXml = HttpRequestHelper::getResponseXml($this->featureContext->getResponse());
-		return $responseXml->xpath("//d:response/d:href");
+		$this->setSpaceIDByName($user, $spaceName);
+		$this->filesVersionsContext->downloadVersion($user, $fileName, $index);
+		WebDavHelper::$SPACE_ID_FROM_OCIS = '';
 	}
 
 	/**
@@ -2809,20 +2532,8 @@ class SpacesContext implements Context {
 	 * @throws GuzzleException
 	 */
 	public function userGetsEtagOfElementInASpace(string $user, string $space, string $path) {
-		$user = $this->featureContext->getActualUsername($user);
-		$space = $this->getSpaceByName($user, $space);
-
-		$fullUrl = $space['root']['webDavUrl'] . '/' . ltrim($path, '/');
-		$response = $this->sendPropfindRequestToUrl(
-			$fullUrl,
-			$user,
-			$this->featureContext->getPasswordForUser($user),
-			$this->featureContext->getStepLineRef(),
-			[],
-			$this->etagPropfindBody
-		);
-		$responseXml = HttpRequestHelper::getResponseXml($response);
-		$this->featureContext->setResponseXmlObject($responseXml);
+		$this->setSpaceIDByName($user, $space);
+		$this->webDavPropertiesContext->storeEtagOfElement($user, $path);
 		return $this->featureContext->getEtagFromResponseXmlObject();
 	}