From 83eea2dda99464afc5559455481e4c017566818d Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 14:45:41 -0700 Subject: [PATCH 1/8] chore: track the plugin-redis v3 migration for the redis provider Placeholder commit for a draft PR. Code lands once a plugin-redis 3.0.0 beta is published. From c84170d660a40da157bf64f1d5ecb5ccbcaacda0 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 16:19:05 -0700 Subject: [PATCH 2/8] feat(provider-redis): run on plugin-redis v3's node-redis client plugin-redis v3 moves to node-redis and keeps an ioredis-compatible `.client` only as a deprecated shim, removed in v4. This lets the provider use `connections[name].native` when v3 is installed, while plugin-redis v2 keeps the existing @keyv/redis 3 path untouched - so nobody on v2, or on Node 20, which v3 does not support, sees a change. v3 is detected by capability: its `connection` service has `whenReady`, which the provider now waits on instead of polling the client, and whose rejection is reported as an init failure rather than a hang. On v3 the cache runs on NodeRedisStore, a keyv store that writes exactly what @keyv/redis 3 writes: the `keyv:` keys keyv prefixes, PX lifetimes in milliseconds, the `namespace:keyv` tracking set, and the `sets:` naming used when redis sets are off. Entries cached before an upgrade therefore stay readable and purgeable after it, and after a rollback. @keyv/redis 5 was not usable: it keeps no tracking set, separates keys with "::", and destroys the client it is handed when a connection fails, which would take a shared plugin connection away from every other consumer. The store never closes the client. On a cluster it never issues a multi-key command or a MULTI spanning hash slots (#100): entries are written and tracked in two commands, read one by one, and unlinked key by key, with SREM against the single tracking set still batched. Tests: - scripts/redis-key-continuity.cjs writes with one backend and reads, lists, purges and clears with the other against a real Redis, and compares the raw keys, types and lifetimes each leaves. It fails if the tracking set is renamed or a lifetime is converted. Runs in e2e_redis. - redis-cluster-check covers the v3 store with a recording node-redis client; each check fails when cluster detection is disabled. - provider-smoke drives init() against v2 and v3 stand-ins. Also declares @strapi-community/plugin-redis as a peer (^2 || ^3, prereleases included); the provider has always required it at runtime. --- .github/workflows/tests.yml | 9 + package.json | 3 +- .../provider-rest-cache-redis/package.json | 5 +- .../src/NodeRedisStore.ts | 267 +++++++++++++ .../src/RedisCacheProvider.ts | 69 +++- .../provider-rest-cache-redis/src/index.ts | 53 +++ playgrounds/redis/config/plugins.js | 1 - pnpm-lock.yaml | 363 +++++++++++++++--- scripts/provider-smoke.cjs | 118 ++++++ scripts/redis-cluster-check.cjs | 156 ++++++++ scripts/redis-key-continuity.cjs | 182 +++++++++ 11 files changed, 1172 insertions(+), 54 deletions(-) create mode 100644 packages/provider-rest-cache-redis/src/NodeRedisStore.ts create mode 100644 scripts/redis-key-continuity.cjs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c83dcb94..07e29ae3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -226,3 +226,12 @@ jobs: - name: Run Redis e2e tests working-directory: playgrounds/redis run: pnpm run test:e2e + + # The provider's plugin-redis v2 and v3 backends must share one storage + # layout, or an upgrade orphans every entry cached before it. Needs a + # real Redis, so it runs here rather than in provider_smoke. See + # https://github.com/strapi-community/plugin-rest-cache/pull/239 + - name: Check v2/v3 key continuity + run: pnpm run test:continuity + env: + REDIS_URL: redis://127.0.0.1:6379/15 diff --git a/package.json b/package.json index 7dc043ae..ef9bef63 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "test:admin:ui": "playwright test -c e2e/playwright.config.ts --ui", "docs:api": "typedoc", "docs:build:only": "vitepress build docs", - "build:providers": "pnpm --filter \"./packages/provider-*\" run build" + "build:providers": "pnpm --filter \"./packages/provider-*\" run build", + "test:continuity": "node scripts/redis-key-continuity.cjs" }, "license": "MIT", "devDependencies": { diff --git a/packages/provider-rest-cache-redis/package.json b/packages/provider-rest-cache-redis/package.json index 7de6fafd..9df4f7d1 100644 --- a/packages/provider-rest-cache-redis/package.json +++ b/packages/provider-rest-cache-redis/package.json @@ -74,16 +74,19 @@ "keyv": "^5.5.0" }, "devDependencies": { + "@redis/client": "^6.3.0", "@strapi-community/plugin-redis": "^2.0.0", "@strapi-community/plugin-rest-cache": "workspace:*", "@types/node": "^26.2.0", + "ioredis": "^5.11.1", "npm-run-all": "^4.1.5", "prettier": "^3.9.6", "typescript": "^5.9.3" }, "peerDependencies": { "@strapi-community/plugin-rest-cache": "workspace:^", - "@strapi/strapi": "^5.0.0" + "@strapi/strapi": "^5.0.0", + "@strapi-community/plugin-redis": "^2.0.0 || ^3.0.0-0" }, "engines": { "node": ">=20.0.0 <=26.x.x" diff --git a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts new file mode 100644 index 00000000..670a5314 --- /dev/null +++ b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts @@ -0,0 +1,267 @@ +import { EventEmitter } from 'events'; + +/** A queued MULTI, as node-redis returns it from `multi()`. */ +export interface NodeRedisMulti { + set(key: string, value: string, options?: NodeRedisSetOptions): NodeRedisMulti; + sAdd(key: string, members: string | string[]): NodeRedisMulti; + sRem(key: string, members: string | string[]): NodeRedisMulti; + unlink(keys: string | string[]): NodeRedisMulti; + exec(): Promise; +} + +interface NodeRedisSetOptions { + expiration?: { type: 'PX'; value: number }; +} + +/** + * The node-redis client, as `@strapi-community/plugin-redis` v3 hands it over + * on `connections[name].native` - a standalone client or a cluster. + * + * Described structurally for the same reason RedisClient is: the connection, + * and therefore the @redis/client version, belongs to the redis plugin. + * + * Only the members this store touches are listed. + */ +export interface NodeRedisClient { + isReady: boolean; + get(key: string): Promise; + mGet(keys: string[]): Promise>; + set(key: string, value: string, options?: NodeRedisSetOptions): Promise; + exists(keys: string | string[]): Promise; + sAdd(key: string, members: string | string[]): Promise; + sRem(key: string, members: string | string[]): Promise; + sMembers(key: string): Promise; + unlink(keys: string | string[]): Promise; + keys(pattern: string): Promise; + scan( + cursor: string, + options?: { MATCH?: string; COUNT?: number } + ): Promise<{ cursor: string; keys: string[] }>; + multi(): NodeRedisMulti; +} + +export interface NodeRedisStoreOptions { + /** Whether `client` is a cluster. Decides which commands are slot-safe. */ + isCluster: boolean; + /** As @keyv/redis' option of the same name. Defaults to true. */ + useRedisSets?: boolean; +} + +const UNLINK_CONCURRENCY = 16; +const CHUNK = 1000; + +/** + * A keyv storage adapter over node-redis that stores exactly what @keyv/redis 3 + * stores. + * + * On plugin-redis v2 the provider runs on @keyv/redis 3 and the plugin's + * ioredis client. Entries written that way outlive an upgrade to plugin-redis + * v3, so this store has to find, read and purge them - and @keyv/redis 3 has to + * do the same with this store's entries after a rollback. Every key name, the + * `namespace:` tracking set, and the PX expiry below mirror + * @keyv/redis@3.0.1, including its `useRedisSets: false` key naming. + * + * @keyv/redis 5 also runs on node-redis but is not usable here: it keeps no + * tracking set, separates keys with "::", and destroys the client it was given + * when a connection fails - which, with a shared plugin connection, would take + * it away from every other consumer. + * + * The client belongs to the redis plugin. Nothing here ever closes it. + * + * @see https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +export class NodeRedisStore extends EventEmitter { + readonly ttlSupport = true; + + /** Set by Keyv. Keyv also prefixes every key with it before calling us. */ + namespace?: string; + + readonly opts: { dialect: 'redis'; url: string; useRedisSets: boolean }; + + readonly client: NodeRedisClient; + + readonly isCluster: boolean; + + constructor(client: NodeRedisClient, options: NodeRedisStoreOptions) { + super(); + this.client = client; + this.isCluster = options.isCluster; + // `dialect` is what makes Keyv treat this store as iterable. + this.opts = { + dialect: 'redis', + url: 'redis://', + useRedisSets: options.useRedisSets !== false, + }; + } + + /** The tracking set's key, as @keyv/redis names it. */ + _getNamespace(): string { + return `namespace:${this.namespace}`; + } + + private keyName(key: string): string { + return this.opts.useRedisSets ? key : `sets:${this._getNamespace()}:${key}`; + } + + private static setOptions(ttl?: number): NodeRedisSetOptions | undefined { + // A ttl in milliseconds, written as PX. Never converted. + return typeof ttl === 'number' ? { expiration: { type: 'PX', value: ttl } } : undefined; + } + + async get(key: string): Promise { + const value = await this.client.get(this.keyName(key)); + return value === null ? undefined : value; + } + + async getMany(keys: string[]): Promise> { + const names = keys.map((key) => this.keyName(key)); + // MGET across hash slots is a CROSSSLOT error on a cluster. + if (this.isCluster) { + return Promise.all(names.map((name) => this.client.get(name))); + } + return this.client.mGet(names); + } + + async set(key: string, value: string | undefined, ttl?: number): Promise { + if (value === undefined) { + return; + } + const name = this.keyName(key); + const options = NodeRedisStore.setOptions(ttl); + + if (!this.opts.useRedisSets) { + await this.client.set(name, value, options); + return; + } + + // The entry and the tracking set are two keys in different hash slots, so + // on a cluster they cannot share a MULTI. Write the entry first: a crash in + // between leaves an untracked entry that expires, never a tracked key with + // nothing behind it. + if (this.isCluster) { + await this.client.set(name, value, options); + await this.client.sAdd(this._getNamespace(), name); + return; + } + + await this.client.multi().set(name, value, options).sAdd(this._getNamespace(), name).exec(); + } + + async delete(key: string): Promise { + const name = this.keyName(key); + + if (!this.opts.useRedisSets) { + return (await this.client.unlink(name)) > 0; + } + + if (this.isCluster) { + const removed = await this.client.unlink(name); + await this.client.sRem(this._getNamespace(), name); + return removed > 0; + } + + const [removed] = await this.client + .multi() + .unlink(name) + .sRem(this._getNamespace(), name) + .exec(); + return Number(removed) > 0; + } + + async deleteMany(keys: string[]): Promise { + const results = await Promise.all(keys.map((key) => this.delete(key))); + return results.every(Boolean); + } + + async has(key: string): Promise { + return (await this.client.exists(this.keyName(key))) !== 0; + } + + async clear(): Promise { + if (this.opts.useRedisSets) { + await this.unlinkTracked(await this.client.sMembers(this._getNamespace())); + return; + } + + // As @keyv/redis 3: every key is named under the namespace, so one pattern + // finds them all. + const keys = await this.client.keys(`sets:${this._getNamespace()}:*`); + for (const batch of chunks(keys, CHUNK)) { + await this.client.unlink(batch); + } + } + + /** + * Yields [key, value] for keys under `namespace`, as @keyv/redis 3 does. + * + * SCAN addresses a single node, so on a cluster this would silently return + * part of the keyspace. The provider never needs it with redis sets on - it + * reads the tracking set instead - so refuse rather than mislead. + */ + async *iterator(namespace?: string): AsyncGenerator<[string, string | null]> { + if (this.isCluster) { + throw new Error( + 'REST Cache provider "redis": enumerating keys on a cluster needs useRedisSets enabled' + ); + } + + let cursor = '0'; + do { + const reply = await this.client.scan(cursor, { MATCH: `${namespace}:*` }); + cursor = String(reply.cursor); + if (reply.keys.length > 0) { + const values = await this.client.mGet(reply.keys); + for (let i = 0; i < reply.keys.length; i += 1) { + yield [reply.keys[i], values[i]]; + } + } + } while (cursor !== '0'); + } + + /** + * Remove fully qualified keys and their tracking-set members. + * + * One MULTI per chunk on a standalone server. A cluster rejects a multi-key + * UNLINK or a MULTI spanning hash slots, so there each key is unlinked on its + * own; the tracking set is a single key, so SREM against it stays batched. + * See https://github.com/strapi-community/plugin-rest-cache/issues/100 + */ + async unlinkTracked(qualified: string[]): Promise { + if (!qualified.length) return; + const set = this._getNamespace(); + + if (this.isCluster) { + for (const batch of chunks(qualified, UNLINK_CONCURRENCY)) { + await Promise.all(batch.map((key) => this.client.unlink(key))); + } + for (const batch of chunks(qualified, CHUNK)) { + await this.client.sRem(set, batch); + } + return; + } + + for (const batch of chunks(qualified, CHUNK)) { + await this.client.multi().unlink(batch).sRem(set, batch).exec(); + } + } + + /** Every tracked key, fully qualified. */ + async trackedKeys(): Promise { + return this.client.sMembers(this._getNamespace()); + } + + get ready(): boolean { + return this.client.isReady === true; + } + + /** The client is the redis plugin's, shared with every other consumer. */ + async disconnect(): Promise {} +} + +function chunks(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} diff --git a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts index 436feeb7..6e870958 100644 --- a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts +++ b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts @@ -2,6 +2,8 @@ import { createCache, type Cache } from 'cache-manager'; import { CacheProvider } from '@strapi-community/plugin-rest-cache/types'; import type { CacheKey, Milliseconds } from '@strapi-community/plugin-rest-cache/types'; +import { NodeRedisStore, type NodeRedisClient } from './NodeRedisStore'; + // keyv 4 exports the constructor directly, keyv 5 exports it as `.default`, and // @keyv/redis exposes only `.default` from its CJS build. Accept either shape: // which one we get depends on what else in the host application's dependency @@ -71,6 +73,24 @@ interface KeyvRedisStore { const redisStoreOf = (cache: Cache): KeyvRedisStore => cache.stores[0].opts.store as unknown as KeyvRedisStore; +/** The store, when the cache runs on plugin-redis v3's node-redis client. */ +const nodeRedisStoreOf = (cache: Cache): NodeRedisStore | undefined => { + const store: unknown = cache.stores[0].opts.store; + return store instanceof NodeRedisStore ? store : undefined; +}; + +/** + * plugin-redis v3's node-redis connection, `connections[name].native`. + * + * When given, the cache runs on it through NodeRedisStore. Without it the + * provider runs on @keyv/redis 3 and the ioredis client, as on plugin-redis v2. + * See https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +export interface NativeRedisConnection { + client: NodeRedisClient; + isCluster: boolean; +} + /** Everything accepted under `provider.options` in the plugin configuration. */ export interface RedisCacheProviderOptions { /** Name of a connection declared on the redis plugin. */ @@ -85,7 +105,17 @@ export class RedisCacheProvider extends CacheProvider { private readonly cache: Cache; - constructor(client: RedisClient, options: RedisCacheProviderOptions) { + /** + * @param client the ioredis client: plugin-redis v2's connection, or v3's + * deprecated ioredis-compatible shim. Kept as `client` for anyone typed + * against it; with `native` given it is never used. + * @param native plugin-redis v3's node-redis connection, when there is one. + */ + constructor( + client: RedisClient, + options: RedisCacheProviderOptions, + native?: NativeRedisConnection + ) { super(); const { ttl, ...adapterOptions } = options; @@ -95,7 +125,12 @@ export class RedisCacheProvider extends CacheProvider { ttl, stores: [ new Keyv({ - store: new KeyvRedis(client, adapterOptions), + store: native + ? new NodeRedisStore(native.client, { + isCluster: native.isCluster, + useRedisSets: adapterOptions.useRedisSets !== false, + }) + : new KeyvRedis(client, adapterOptions), }), ], }); @@ -125,6 +160,16 @@ export class RedisCacheProvider extends CacheProvider { async delMany(keys: Array): Promise { if (!keys.length) return; + const nodeStore = nodeRedisStoreOf(this.cache); + if (nodeStore?.opts.useRedisSets && nodeStore.namespace) { + await nodeStore.unlinkTracked(keys.map((key) => `${nodeStore.namespace}:${key}`)); + return; + } + if (nodeStore) { + await this.cache.mdel(keys as string[]); + return; + } + const store = redisStoreOf(this.cache); const namespace = store._getNamespace?.(); @@ -189,6 +234,15 @@ export class RedisCacheProvider extends CacheProvider { // trip here. // // See https://github.com/strapi-community/plugin-rest-cache/issues/131 + const nodeStore = nodeRedisStoreOf(this.cache); + if (nodeStore) { + // The same tracking set, read the same way, as on @keyv/redis below - + // including reading it when redis sets are off, where it is empty. + const prefix = `${nodeStore.namespace}:`; + const members = await nodeStore.trackedKeys(); + return members.map((key) => (key.startsWith(prefix) ? key.slice(prefix.length) : key)); + } + const store = redisStoreOf(this.cache); const namespace = store._getNamespace?.(); @@ -222,6 +276,12 @@ export class RedisCacheProvider extends CacheProvider { } async clear(): Promise { + // NodeRedisStore.clear() is already slot-safe on a cluster. + if (nodeRedisStoreOf(this.cache)) { + await this.cache.clear(); + return; + } + const store = redisStoreOf(this.cache); // @keyv/redis's clear() unlinks every key in a single multi-key command, @@ -237,6 +297,11 @@ export class RedisCacheProvider extends CacheProvider { } get ready(): boolean { + const nodeStore = nodeRedisStoreOf(this.cache); + if (nodeStore) { + return nodeStore.ready; + } + const client = redisStoreOf(this.cache).redis; return client.status === 'ready'; } diff --git a/packages/provider-rest-cache-redis/src/index.ts b/packages/provider-rest-cache-redis/src/index.ts index d7fb5b62..29100784 100644 --- a/packages/provider-rest-cache-redis/src/index.ts +++ b/packages/provider-rest-cache-redis/src/index.ts @@ -3,6 +3,7 @@ import { type RedisCacheProviderOptions, type RedisClient, } from './RedisCacheProvider'; +import type { NodeRedisClient } from './NodeRedisStore'; /** * The Strapi global. @@ -14,11 +15,41 @@ declare const strapi: { log: { info(message: string): void }; }; +/** plugin-redis v3's connection service - the part this provider uses. */ +interface RedisConnectionService { + whenReady( + name: string + ): Promise<{ client: RedisClient; native: NodeRedisClient; type?: string }>; +} + /** Strapi as this provider needs to see it: whatever the redis plugin added. */ interface StrapiWithRedis { + log: { info(message: string): void }; redis?: { connections: Record; }; + plugin?(name: string): { service?(name: string): unknown } | undefined; +} + +/** + * plugin-redis v3's connection service, or undefined on v2. + * + * Detected by capability, not by version: v3 registers a `connection` service + * with `whenReady`, v2 registers none. The provider initialises in bootstrap, + * after every plugin's register, so the service exists by then if it ever will. + * See https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +function v3ConnectionService(strapi: StrapiWithRedis): RedisConnectionService | undefined { + try { + const service = strapi.plugin?.('redis')?.service?.('connection') as + | Partial + | undefined; + return typeof service?.whenReady === 'function' + ? (service as RedisConnectionService) + : undefined; + } catch { + return undefined; + } } function waitForRedis(client: RedisClient): Promise { @@ -60,6 +91,28 @@ export = { } const connectionName = options.connection || 'default'; + + // plugin-redis v3: wait through its own service, which rejects rather than + // hanging, and run on the node-redis client. Its ioredis shim is kept as + // `provider.client` only, for anyone typed against it. + const connections = v3ConnectionService(strapi); + if (connections) { + let connection: Awaited>; + try { + connection = await connections.whenReady(connectionName); + } catch (error) { + throw new Error( + `Could not initialize REST Cache provider "redis": ${(error as Error)?.message}` + ); + } + + strapi.log.info('REST Cache provider "redis": connection established'); + return new RedisCacheProvider(connection.client, options, { + client: connection.native, + isCluster: connection.type === 'cluster', + }); + } + const { client } = strapi.redis.connections[connectionName] ?? {}; if (!client) { diff --git a/playgrounds/redis/config/plugins.js b/playgrounds/redis/config/plugins.js index efd06666..5be4441f 100644 --- a/playgrounds/redis/config/plugins.js +++ b/playgrounds/redis/config/plugins.js @@ -6,7 +6,6 @@ module.exports = ({ env }) => ({ // https://github.com/strapi-community/plugin-rest-cache/issues/119 redis: { config: { - debug: true, connections: { default: { connection: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39aef81f..1741c882 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,7 +127,7 @@ importers: dependencies: '@strapi/strapi': specifier: ^5.0.0 - version: 5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0) + version: 5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0) cache-manager: specifier: ^7.1.1 version: 7.2.9 @@ -161,7 +161,7 @@ importers: version: 3.0.1 '@strapi/strapi': specifier: ^5.0.0 - version: 5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0) + version: 5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0) cache-manager: specifier: ^7.1.1 version: 7.2.9 @@ -169,15 +169,21 @@ importers: specifier: ^5.5.0 version: 5.6.0 devDependencies: + '@redis/client': + specifier: ^6.3.0 + version: 6.3.0(@opentelemetry/api@1.9.0) '@strapi-community/plugin-redis': specifier: ^2.0.0 - version: 2.0.0(@strapi/strapi@5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0)) + version: 2.0.0(@strapi/strapi@5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0)) '@strapi-community/plugin-rest-cache': specifier: workspace:* version: link:../plugin-rest-cache '@types/node': specifier: ^26.2.0 version: 26.2.0 + ioredis: + specifier: ^5.11.1 + version: 5.11.1 npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -2516,6 +2522,18 @@ packages: '@react-dnd/shallowequal@4.0.2': resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==} + '@redis/client@6.3.0': + resolution: {integrity: sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + '@reduxjs/toolkit@1.9.7': resolution: {integrity: sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==} peerDependencies: @@ -12202,6 +12220,10 @@ snapshots: '@react-dnd/shallowequal@4.0.2': {} + '@redis/client@6.3.0(@opentelemetry/api@1.9.0)': + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@reduxjs/toolkit@1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1)': dependencies: immer: 9.0.21 @@ -12475,9 +12497,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@strapi-community/plugin-redis@2.0.0(@strapi/strapi@5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0))': + '@strapi-community/plugin-redis@2.0.0(@strapi/strapi@5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0))': dependencies: - '@strapi/strapi': 5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0) + '@strapi/strapi': 5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0) chalk: 4.1.2 debug: 4.3.5 ioredis: 5.4.1 @@ -12881,6 +12903,204 @@ snapshots: - tedious - ts-toolbelt + '@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(debug@4.3.4)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)': + dependencies: + '@casl/ability': 6.7.5 + '@internationalized/date': 3.12.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) + '@strapi/data-transfer': 5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/permissions': 5.52.0(ts-toolbelt@9.6.0) + '@strapi/types': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/typescript-utils': 5.52.0 + '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) + '@testing-library/dom': 10.4.1 + '@testing-library/react': 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + axios: 1.19.0(debug@4.3.4) + bcryptjs: 2.4.3 + boxen: 5.1.2 + chalk: 4.1.2 + codemirror5: codemirror@5.65.21 + cross-env: 7.0.3 + date-fns: 2.30.0 + execa: 5.1.1 + fast-deep-equal: 3.1.3 + formik: 2.4.9(@types/react@18.3.31)(react@18.3.1) + fractional-indexing: 3.2.0 + fs-extra: 11.3.4 + highlight.js: 10.7.3 + immer: 9.0.21 + inquirer: 9.3.8(@types/node@26.2.0) + invariant: 2.2.4 + is-localhost-ip: 2.0.0 + json-logic-js: 2.0.5 + jsonwebtoken: 9.0.3 + koa: 2.16.4 + koa-compose: 4.1.0 + koa-passport: 6.0.0 + koa-static: 5.0.0 + koa2-ratelimit: 1.1.3 + lodash: 4.18.1 + motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + ora: 5.4.1 + p-map: 4.0.0 + passport-local: 1.0.0 + pluralize: 8.0.0 + qs: 6.15.3 + react: 18.3.1 + react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react@18.3.1) + react-dnd-html5-backend: 16.0.1 + react-dom: 18.3.1(react@18.3.1) + react-intl: 6.6.2(react@18.3.1)(typescript@5.9.3) + react-is: 18.3.1 + react-query: 3.39.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-redux: 8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) + react-router-dom: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-select: 5.8.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-window: 1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rimraf: 6.1.3 + scheduler: 0.23.0 + semver: 7.7.4 + sift: 16.0.1 + sonner: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + styled-components: 6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + typescript: 5.9.3 + use-context-selector: 1.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(scheduler@0.23.0) + yup: 0.32.9 + zod: 3.25.76 + transitivePeerDependencies: + - '@babel/runtime' + - '@cfworker/json-schema' + - '@codemirror/autocomplete' + - '@codemirror/language' + - '@codemirror/lint' + - '@codemirror/search' + - '@codemirror/theme-one-dark' + - '@emotion/is-prop-valid' + - '@types/hoist-non-react-statics' + - '@types/node' + - '@types/react' + - '@types/react-dom' + - better-sqlite3 + - codemirror + - debug + - mongoose + - mysql + - mysql2 + - pg + - pg-native + - react-native + - redis + - redux + - sequelize + - sqlite3 + - supports-color + - tedious + - ts-toolbelt + + '@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)': + dependencies: + '@casl/ability': 6.7.5 + '@internationalized/date': 3.12.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) + '@strapi/data-transfer': 5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/permissions': 5.52.0(ts-toolbelt@9.6.0) + '@strapi/types': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/typescript-utils': 5.52.0 + '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) + '@testing-library/dom': 10.4.1 + '@testing-library/react': 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + axios: 1.19.0(debug@4.4.3) + bcryptjs: 2.4.3 + boxen: 5.1.2 + chalk: 4.1.2 + codemirror5: codemirror@5.65.21 + cross-env: 7.0.3 + date-fns: 2.30.0 + execa: 5.1.1 + fast-deep-equal: 3.1.3 + formik: 2.4.9(@types/react@18.3.31)(react@18.3.1) + fractional-indexing: 3.2.0 + fs-extra: 11.3.4 + highlight.js: 10.7.3 + immer: 9.0.21 + inquirer: 9.3.8(@types/node@26.2.0) + invariant: 2.2.4 + is-localhost-ip: 2.0.0 + json-logic-js: 2.0.5 + jsonwebtoken: 9.0.3 + koa: 2.16.4 + koa-compose: 4.1.0 + koa-passport: 6.0.0 + koa-static: 5.0.0 + koa2-ratelimit: 1.1.3 + lodash: 4.18.1 + motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + ora: 5.4.1 + p-map: 4.0.0 + passport-local: 1.0.0 + pluralize: 8.0.0 + qs: 6.15.3 + react: 18.3.1 + react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react@18.3.1) + react-dnd-html5-backend: 16.0.1 + react-dom: 18.3.1(react@18.3.1) + react-intl: 6.6.2(react@18.3.1)(typescript@5.9.3) + react-is: 18.3.1 + react-query: 3.39.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-redux: 8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) + react-router-dom: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-select: 5.8.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-window: 1.8.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rimraf: 6.1.3 + scheduler: 0.23.0 + semver: 7.7.4 + sift: 16.0.1 + sonner: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + styled-components: 6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + typescript: 5.9.3 + use-context-selector: 1.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(scheduler@0.23.0) + yup: 0.32.9 + zod: 3.25.76 + transitivePeerDependencies: + - '@babel/runtime' + - '@cfworker/json-schema' + - '@codemirror/autocomplete' + - '@codemirror/language' + - '@codemirror/lint' + - '@codemirror/search' + - '@codemirror/theme-one-dark' + - '@emotion/is-prop-valid' + - '@types/hoist-non-react-statics' + - '@types/node' + - '@types/react' + - '@types/react-dom' + - better-sqlite3 + - codemirror + - debug + - mongoose + - mysql + - mysql2 + - pg + - pg-native + - react-native + - redis + - redux + - sequelize + - sqlite3 + - supports-color + - tedious + - ts-toolbelt + '@strapi/cloud-cli@5.52.0(@types/node@26.2.0)(debug@4.4.3)(ts-toolbelt@9.6.0)': dependencies: '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) @@ -13061,7 +13281,7 @@ snapshots: - ts-toolbelt - typescript - '@strapi/content-manager@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': + '@strapi/content-manager@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': dependencies: '@casl/ability': 6.7.5 '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13070,8 +13290,8 @@ snapshots: '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) '@sindresorhus/slugify': 1.1.0 - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/types': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) @@ -13183,11 +13403,11 @@ snapshots: - ts-toolbelt - typescript - '@strapi/content-releases@5.52.0(14eeef076bceb5d9667248dd76e71cc9)': + '@strapi/content-releases@5.52.0(27fcf50f542a73de9fadcf7e47d20367)': dependencies: '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/content-manager': 5.52.0(1a96aef262e20e794254aba29667e0ab) '@strapi/database': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0) '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) @@ -13229,13 +13449,13 @@ snapshots: - ts-toolbelt - typescript - '@strapi/content-releases@5.52.0(27fcf50f542a73de9fadcf7e47d20367)': + '@strapi/content-releases@5.52.0(9cb4f80221d1594906617ba96a746208)': dependencies: '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/content-manager': 5.52.0(1a96aef262e20e794254aba29667e0ab) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/database': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0) - '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/types': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) @@ -13377,7 +13597,7 @@ snapshots: - ts-toolbelt - typescript - '@strapi/content-type-builder@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': + '@strapi/content-type-builder@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': dependencies: '@ai-sdk/react': 2.0.212(react@18.3.1)(zod@3.25.76) '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13386,8 +13606,8 @@ snapshots: '@dnd-kit/utilities': 3.2.2(react@18.3.1) '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) '@sindresorhus/slugify': 1.1.0 - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/generators': 5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) @@ -13618,14 +13838,14 @@ snapshots: - tedious - ts-toolbelt - '@strapi/core@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)': + '@strapi/core@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)': dependencies: '@casl/ability': 6.7.5 '@koa/cors': 5.0.0 '@koa/router': 12.0.2 '@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76) '@paralleldrive/cuid2': 2.2.2 - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.3.4)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(debug@4.3.4)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) '@strapi/database': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0) '@strapi/generators': 5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0) '@strapi/logger': 5.52.0 @@ -13816,6 +14036,51 @@ snapshots: - '@types/react-dom' - codemirror + '@strapi/design-system@2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': + dependencies: + '@codemirror/lang-json': 6.0.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.0 + '@floating-ui/react-dom': 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@internationalized/date': 3.5.4 + '@internationalized/number': 3.5.3 + '@radix-ui/react-accordion': 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-alert-dialog': 1.0.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-avatar': 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-checkbox': 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': 2.0.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': 1.0.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': 1.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': 1.0.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-switch': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': 1.0.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.31)(react@18.3.1) + '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/ui-primitives': 2.2.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@uiw/react-codemirror': 4.22.2(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + lodash: 4.18.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.10(@types/react@18.3.31)(react@18.3.1) + styled-components: 6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@babel/runtime' + - '@codemirror/autocomplete' + - '@codemirror/language' + - '@codemirror/lint' + - '@codemirror/search' + - '@codemirror/theme-one-dark' + - '@types/react' + - '@types/react-dom' + - codemirror + '@strapi/email@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(better-sqlite3@13.0.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(koa@2.16.4)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': dependencies: '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(better-sqlite3@13.0.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) @@ -13851,10 +14116,10 @@ snapshots: - ts-toolbelt - typescript - '@strapi/email@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(koa@2.16.4)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': + '@strapi/email@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/react@18.3.31)(koa@2.16.4)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': dependencies: - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/provider-email-sendmail': 5.52.0 '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) @@ -13939,11 +14204,11 @@ snapshots: - supports-color - ts-toolbelt - '@strapi/i18n@5.52.0(14eeef076bceb5d9667248dd76e71cc9)': + '@strapi/i18n@5.52.0(1c4a628ba1c53947a3fcbb053d3f63e6)': dependencies: '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(better-sqlite3@13.0.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(better-sqlite3@13.0.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(better-sqlite3@13.0.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) @@ -13972,11 +14237,11 @@ snapshots: - ts-toolbelt - typescript - '@strapi/i18n@5.52.0(1c4a628ba1c53947a3fcbb053d3f63e6)': + '@strapi/i18n@5.52.0(27fcf50f542a73de9fadcf7e47d20367)': dependencies: '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) - '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(better-sqlite3@13.0.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(better-sqlite3@13.0.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(better-sqlite3@13.0.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/content-manager': 5.52.0(1a96aef262e20e794254aba29667e0ab) '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) @@ -14005,12 +14270,12 @@ snapshots: - ts-toolbelt - typescript - '@strapi/i18n@5.52.0(27fcf50f542a73de9fadcf7e47d20367)': + '@strapi/i18n@5.52.0(9cb4f80221d1594906617ba96a746208)': dependencies: '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/content-manager': 5.52.0(1a96aef262e20e794254aba29667e0ab) - '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) lodash: 4.18.1 @@ -14205,12 +14470,12 @@ snapshots: - ts-toolbelt - typescript - '@strapi/review-workflows@5.52.0(9f9ad850614c194bf1795af90714f058)': + '@strapi/review-workflows@5.52.0(c22d7aed7579a99ad41ee5882d77be21)': dependencies: '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) - '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) - '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/types': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) @@ -14539,27 +14804,27 @@ snapshots: - webpack-dev-server - webpack-plugin-serve - '@strapi/strapi@5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0)': + '@strapi/strapi@5.52.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(esbuild@0.28.2)(koa@2.16.4)(postcss@8.5.26)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.50.0)(ts-toolbelt@9.6.0)(type-fest@4.41.0)': dependencies: '@pmmmwh/react-refresh-webpack-plugin': 0.5.17(react-refresh@0.14.0)(type-fest@4.41.0)(webpack-hot-middleware@2.26.1)(webpack@5.109.2(esbuild@0.28.2)(postcss@8.5.26)) - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) '@strapi/cloud-cli': 5.52.0(@types/node@26.2.0)(debug@4.4.3)(ts-toolbelt@9.6.0) - '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) - '@strapi/content-releases': 5.52.0(14eeef076bceb5d9667248dd76e71cc9) - '@strapi/content-type-builder': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) - '@strapi/core': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/content-manager': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/content-releases': 5.52.0(9cb4f80221d1594906617ba96a746208) + '@strapi/content-type-builder': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/core': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) '@strapi/data-transfer': 5.52.0(@types/node@26.2.0)(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/database': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0) - '@strapi/email': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(koa@2.16.4)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/email': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/react@18.3.31)(koa@2.16.4)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/generators': 5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0) - '@strapi/i18n': 5.52.0(14eeef076bceb5d9667248dd76e71cc9) + '@strapi/i18n': 5.52.0(9cb4f80221d1594906617ba96a746208) '@strapi/logger': 5.52.0 '@strapi/openapi': 5.52.0 '@strapi/permissions': 5.52.0(ts-toolbelt@9.6.0) - '@strapi/review-workflows': 5.52.0(9f9ad850614c194bf1795af90714f058) + '@strapi/review-workflows': 5.52.0(c22d7aed7579a99ad41ee5882d77be21) '@strapi/types': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/typescript-utils': 5.52.0 - '@strapi/upload': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) + '@strapi/upload': 5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) '@types/nodemon': 1.19.6 '@vitejs/plugin-react-swc': 3.11.0(@swc/helpers@0.5.23)(vite@5.4.21(@types/node@26.2.0)(terser@5.50.0)) @@ -14862,16 +15127,16 @@ snapshots: - ts-toolbelt - typescript - '@strapi/upload@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': + '@strapi/upload@5.52.0(@strapi/admin@5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0)(typescript@5.9.3)': dependencies: '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mux/mux-player-react': 3.1.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1) - '@strapi/admin': 5.52.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) + '@strapi/admin': 5.52.0(@emotion/is-prop-valid@1.4.0)(@strapi/data-transfer@5.52.0(@types/node@26.2.0)(ts-toolbelt@9.6.0)(typescript@5.9.3))(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.2.0)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(ts-toolbelt@9.6.0) '@strapi/database': 5.52.0(better-sqlite3@13.0.3)(ts-toolbelt@9.6.0) - '@strapi/design-system': 2.2.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@strapi/design-system': 2.2.4(@strapi/icons@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/provider-upload-local': 5.52.0(ts-toolbelt@9.6.0) '@strapi/utils': 5.52.0(ts-toolbelt@9.6.0) diff --git a/scripts/provider-smoke.cjs b/scripts/provider-smoke.cjs index 6f847291..cd323bcd 100644 --- a/scripts/provider-smoke.cjs +++ b/scripts/provider-smoke.cjs @@ -81,12 +81,130 @@ async function redisProviderModuleLoads() { assert.strictEqual(typeof RedisCacheProvider, 'function'); } +/** + * The redis provider runs on plugin-redis v3's node-redis connection when v3 is + * installed, and on v2's ioredis client otherwise. Neither path needs a server + * to choose; these drive init() against stand-ins for each plugin version. + * See https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +function redisProviderModule() { + return { + provider: require(path.join(ROOT, 'packages/provider-rest-cache-redis/dist/index.js')), + ...require(path.join(ROOT, 'packages/provider-rest-cache-redis/dist/NodeRedisStore.js')), + }; +} + +const quietStrapi = (extra) => ({ log: { info() {}, warn() {} }, ...extra }); + +/** An ioredis client that must never be used. */ +const untouchable = new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') return undefined; + throw new Error(`touched the ioredis client (.${String(prop)})`); + }, + } +); + +function v3Strapi({ whenReady }) { + return quietStrapi({ + redis: { connections: {} }, + plugin: (name) => + name === 'redis' + ? { service: (svc) => (svc === 'connection' ? { whenReady } : undefined) } + : undefined, + }); +} + +async function redisProviderUsesV3Native() { + const { provider, NodeRedisStore } = redisProviderModule(); + const native = { isReady: true }; + const asked = []; + const strapi = v3Strapi({ + whenReady: async (name) => { + asked.push(name); + return { client: untouchable, native, type: 'standalone', ready: true }; + }, + }); + + const instance = await provider.init({ connection: 'cache' }, { strapi }); + const store = instance.cache.stores[0].opts.store; + + assert.deepStrictEqual(asked, ['cache'], 'should wait on the configured connection'); + assert.ok(store instanceof NodeRedisStore, 'v3 should run on NodeRedisStore'); + assert.strictEqual(store.client, native, 'v3 should use connections[name].native'); + assert.strictEqual(store.isCluster, false); + assert.strictEqual(instance.ready, true); +} + +async function redisProviderDetectsV3Cluster() { + const { provider } = redisProviderModule(); + const strapi = v3Strapi({ + whenReady: async () => ({ client: untouchable, native: { isReady: true }, type: 'cluster' }), + }); + + const instance = await provider.init({}, { strapi }); + assert.strictEqual(instance.cache.stores[0].opts.store.isCluster, true); +} + +async function redisProviderReportsV3Failure() { + const { provider } = redisProviderModule(); + const strapi = v3Strapi({ + whenReady: async () => { + throw new Error('connection "default" failed: ECONNREFUSED'); + }, + }); + + await assert.rejects(provider.init({}, { strapi }), (error) => { + assert.match(error.message, /Could not initialize REST Cache provider "redis"/); + assert.match(error.message, /ECONNREFUSED/); + return true; + }); +} + +async function redisProviderKeepsV2Path() { + const { provider, NodeRedisStore } = redisProviderModule(); + const listeners = {}; + const client = { + status: 'ready', + options: { family: 4 }, + on(event, fn) { + (listeners[event] ||= []).push(fn); + }, + once() {}, + off() {}, + }; + // plugin-redis v2 registers no "connection" service. + const strapi = quietStrapi({ + redis: { connections: { default: { client } } }, + plugin: (name) => (name === 'redis' ? { service: () => undefined } : undefined), + }); + + // The v2 path logs through the global, as it always has. + global.strapi = strapi; + let instance; + try { + instance = await provider.init({}, { strapi }); + } finally { + delete global.strapi; + } + const store = instance.cache.stores[0].opts.store; + + assert.ok(!(store instanceof NodeRedisStore), 'v2 should stay on @keyv/redis'); + assert.strictEqual(store.redis, client, 'v2 should use connections[name].client'); +} + (async () => { console.log(`provider smoke test - Node ${process.version}\n`); await record('memory provider: set/get/keys/del', memoryProvider); await record('memory provider: honours maxAge in ms', memoryProviderRespectsTtl); await record('redis provider: module loads', redisProviderModuleLoads); + await record('redis provider: uses plugin-redis v3 native client', redisProviderUsesV3Native); + await record('redis provider: detects a v3 cluster from .type', redisProviderDetectsV3Cluster); + await record('redis provider: reports a v3 connection failure', redisProviderReportsV3Failure); + await record('redis provider: keeps the plugin-redis v2 path', redisProviderKeepsV2Path); for (const [ok, name, detail] of results) { console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`); diff --git a/scripts/redis-cluster-check.cjs b/scripts/redis-cluster-check.cjs index e9ff8bc9..45f9e2a2 100644 --- a/scripts/redis-cluster-check.cjs +++ b/scripts/redis-cluster-check.cjs @@ -70,6 +70,93 @@ function recordingClient({ isCluster }) { return client; } +/** + * A node-redis client, standalone or cluster, that records what it is asked. + * Same command names and argument shapes as @redis/client: arrays for + * multi-key commands, camelCase methods, a chainable multi(). + */ +function recordingNodeRedis({ isCluster }) { + const issued = []; + const members = new Set(); + const flat = (k) => [k].flat(); + + const run = { + set: (key) => { + issued.push({ cmd: 'set', keys: [key] }); + return 'OK'; + }, + sAdd: (_set, m) => { + for (const k of flat(m)) members.add(k); + return flat(m).length; + }, + sRem: (_set, m) => { + issued.push({ cmd: 'srem', keys: flat(m) }); + for (const k of flat(m)) members.delete(k); + return flat(m).length; + }, + unlink: (k) => { + issued.push({ cmd: 'unlink', keys: flat(k) }); + for (const key of flat(k)) members.delete(key); + return flat(k).length; + }, + }; + + return { + isCluster, + isReady: true, + issued, + members, + seed(keys) { + for (const key of keys) members.add(key); + }, + async get(key) { + issued.push({ cmd: 'get', keys: [key] }); + return null; + }, + async mGet(keys) { + issued.push({ cmd: 'mget', keys }); + return keys.map(() => null); + }, + async set(key) { + return run.set(key); + }, + async sAdd(set, m) { + return run.sAdd(set, m); + }, + async sRem(set, m) { + return run.sRem(set, m); + }, + async unlink(k) { + return run.unlink(k); + }, + async sMembers() { + return [...members]; + }, + async scan() { + issued.push({ cmd: 'scan', keys: [] }); + return { cursor: '0', keys: [] }; + }, + multi() { + const queued = []; + const chain = { + set: (key) => (queued.push({ cmd: 'set', keys: [key] }), chain), + sAdd: (set, m) => (queued.push({ cmd: 'sadd', set, keys: flat(m) }), chain), + sRem: (set, m) => (queued.push({ cmd: 'srem', set, keys: flat(m) }), chain), + unlink: (k) => (queued.push({ cmd: 'unlink', keys: flat(k) }), chain), + async exec() { + issued.push({ cmd: 'multi', queued }); + for (const q of queued) { + if (q.cmd === 'unlink' || q.cmd === 'srem') for (const k of q.keys) members.delete(k); + if (q.cmd === 'sadd') for (const k of q.keys) members.add(k); + } + return queued.map(() => 1); + }, + }; + return chain; + }, + }; +} + /** Swap the provider's underlying store for our recorder. */ function withClient(provider, client) { provider.cache.stores[0].opts.store = { @@ -183,6 +270,75 @@ async function check(name, fn) { assert.strictEqual(multis[0].queued[0].keys.length, KEYS.length); }); + // plugin-redis v3: the same guarantees on the node-redis store. Driven + // through a real provider, constructed the way init() does on v3, so the + // store and its wiring are what is under test. See + // https://github.com/strapi-community/plugin-rest-cache/pull/239 + const v3 = (isCluster) => { + const client = recordingNodeRedis({ isCluster }); + const provider = new RedisCacheProvider(null, {}, { client, isCluster }); + return { client, provider }; + }; + + await check('v3 cluster: set() never uses MULTI', async () => { + const { client, provider } = v3(true); + await provider.set(KEYS[0], { a: 1 }, 60000); + + assert.strictEqual(client.issued.some((c) => c.cmd === 'multi'), false, 'set() used MULTI'); + assert.ok(client.members.has(qualified[0]), 'set() did not track the key'); + }); + + await check('v3 cluster: delMany issues no multi-key command', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.delMany(KEYS); + + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], `issued ${JSON.stringify(offending[0])}`); + assert.strictEqual(client.issued.some((c) => c.cmd === 'multi'), false, 'used MULTI'); + assert.strictEqual(client.issued.filter((c) => c.cmd === 'unlink').length, KEYS.length); + assert.ok(client.issued.filter((c) => c.cmd === 'srem').length < KEYS.length); + }); + + await check('v3 cluster: clear() deletes key by key', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.clear(); + + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], 'clear() issued a multi-key command'); + assert.strictEqual(client.issued.some((c) => c.cmd === 'multi'), false, 'clear() used MULTI'); + assert.strictEqual(client.members.size, 0, 'clear() left tracked keys'); + }); + + await check('v3 cluster: keys() reads the tracking set, never SCAN', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + const keys = await provider.keys(); + + assert.strictEqual(client.issued.some((c) => c.cmd === 'scan'), false); + assert.strictEqual(keys.length, KEYS.length); + assert.ok(keys.every((k) => !k.startsWith('keyv:')), 'keys should be unqualified'); + }); + + await check('v3 cluster: reading several entries never uses MGET', async () => { + const { client, provider } = v3(true); + await provider.cache.mget(KEYS.slice(0, 5)); + + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], `issued ${JSON.stringify(offending[0])}`); + }); + + await check('v3 standalone: delMany still batches into MULTI', async () => { + const { client, provider } = v3(false); + client.seed(qualified); + await provider.delMany(KEYS); + + const multis = client.issued.filter((c) => c.cmd === 'multi'); + assert.strictEqual(multis.length, 1, 'expected a single batched MULTI'); + assert.strictEqual(multis[0].queued[0].keys.length, KEYS.length); + }); + console.log(`redis cluster safety - ${results.length} checks\n`); for (const [ok, name, detail] of results) { console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`); diff --git a/scripts/redis-key-continuity.cjs b/scripts/redis-key-continuity.cjs new file mode 100644 index 00000000..692f4d82 --- /dev/null +++ b/scripts/redis-key-continuity.cjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Verifies the redis provider's two backends share one storage layout. + * + * On @strapi-community/plugin-redis v2 the provider stores entries through + * @keyv/redis 3 on the plugin's ioredis client. On v3 it uses its own store on + * the plugin's node-redis client. A deployment that upgrades the redis plugin + * keeps every entry cached before the upgrade, so the new store has to read + * them, list them and purge them - otherwise purges after the upgrade miss + * every older entry until its maxAge runs out. The same has to hold the other + * way round for a rollback. + * + * So this writes with one backend and reads, lists and purges with the other, + * against a real Redis, and compares the raw keys each one leaves behind. + * + * Needs a Redis it may flush: REDIS_URL (default redis://127.0.0.1:6379/15). + * + * @see https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ + +const assert = require('assert'); +const path = require('path'); +const { createRequire } = require('module'); + +const ROOT = path.join(__dirname, '..'); +const PROVIDER = path.join(ROOT, 'packages/provider-rest-cache-redis'); +const requireFromProvider = createRequire(path.join(PROVIDER, 'package.json')); + +const Redis = requireFromProvider('ioredis'); +const { createClient } = requireFromProvider('@redis/client'); +const { RedisCacheProvider } = require(path.join(PROVIDER, 'dist/RedisCacheProvider.js')); + +const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379/15'; + +const ENTRIES = [ + ['/api/articles?', { data: [{ id: 1 }] }], + ['/api/articles/1?populate=*', { data: { id: 1, title: 'one' } }], + ['/api/categories?locale=fr', { data: [] }], + ['/api/homepage?_etag', 'W/"abc"'], +]; +const MAX_AGE = 600000; + +const results = []; +async function check(name, fn) { + try { + await fn(); + results.push([true, name, '']); + } catch (error) { + results.push([false, name, error.message.split('\n')[0]]); + } +} + +/** Every raw key in the database with its type and remaining lifetime. */ +async function snapshot(raw) { + const keys = (await raw.keys('*')).sort(); + const out = []; + for (const key of keys) { + const type = await raw.type(key); + const ttl = await raw.pttl(key); + const members = type === 'set' ? (await raw.smembers(key)).sort() : undefined; + // Lifetimes drift by the time between writes, so compare a band rather + // than the exact value - but a tight one: maxAge is milliseconds and a + // unit slip (#126) lands orders of magnitude outside it. + const lifetime = + ttl < 0 ? 'none' : ttl <= MAX_AGE && ttl > MAX_AGE - 10000 ? 'maxAge' : `off: ${ttl}ms`; + out.push({ key, type, lifetime, members }); + } + return out; +} + +(async () => { + const raw = new Redis(REDIS_URL); + const io = new Redis(REDIS_URL); + const node = createClient({ url: REDIS_URL }); + await node.connect(); + + // On v3 the provider is handed the plugin's deprecated ioredis shim as well, + // and keeps it as `provider.client` for anyone typed against it - but must + // never use it. A shim that throws on any call makes "v3 quietly ran on the + // v2 path" fail here instead of passing. + const untouchable = new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'then') return undefined; + throw new Error(`v3 backend touched the ioredis client (.${String(prop)})`); + }, + } + ); + + const backends = { + v2: (options) => new RedisCacheProvider(io, options), + v3: (options) => + new RedisCacheProvider(untouchable, options, { client: node, isCluster: false }), + }; + + for (const options of [{}, { useRedisSets: false }]) { + const mode = options.useRedisSets === false ? 'without redis sets' : 'with redis sets'; + + await check(`${mode}: v2 and v3 write byte-identical keys`, async () => { + const layouts = {}; + for (const [name, make] of Object.entries(backends)) { + await raw.flushdb(); + const provider = make(options); + for (const [key, value] of ENTRIES) await provider.set(key, value, MAX_AGE); + layouts[name] = await snapshot(raw); + } + assert.ok(layouts.v2.length > 0, 'v2 wrote nothing'); + assert.deepStrictEqual(layouts.v3, layouts.v2); + }); + + for (const [writer, reader] of [ + ['v2', 'v3'], + ['v3', 'v2'], + ]) { + await check(`${mode}: ${reader} reads and purges what ${writer} wrote`, async () => { + await raw.flushdb(); + const before = backends[writer](options); + for (const [key, value] of ENTRIES) await before.set(key, value, MAX_AGE); + + const after = backends[reader](options); + for (const [key, value] of ENTRIES) { + assert.deepStrictEqual(await after.get(key), value, `get(${key})`); + } + + // keys() is what pattern purges match against. Without redis sets + // neither backend can enumerate (the layout predates this provider); + // that is the existing behaviour, so it only has to agree. + const listed = (await after.keys()).sort(); + const expected = (await before.keys()).sort(); + assert.deepStrictEqual(listed, expected, 'keys() differs between backends'); + + await after.delMany(ENTRIES.map(([key]) => key)); + for (const [key] of ENTRIES) { + assert.ok((await before.get(key)) == null, `${key} survived the purge`); + } + const left = (await raw.keys('*')).filter((k) => !k.startsWith('namespace:')); + assert.deepStrictEqual(left, [], 'purge left entries behind'); + if (options.useRedisSets !== false) { + assert.strictEqual(await raw.scard('namespace:keyv'), 0, 'tracking set not emptied'); + } + }); + } + + await check(`${mode}: v3 clear() empties what v2 wrote`, async () => { + await raw.flushdb(); + const before = backends.v2(options); + for (const [key, value] of ENTRIES) await before.set(key, value, MAX_AGE); + + await backends.v3(options).clear(); + + assert.deepStrictEqual(await raw.keys('*'), []); + }); + } + + await check('v3 never closes the shared client', async () => { + const provider = backends.v3({}); + await provider.set('/api/x', 1, MAX_AGE); + await provider.cache.disconnect(); + assert.strictEqual(node.isReady, true, 'the node-redis client was closed'); + assert.strictEqual(await node.ping(), 'PONG'); + }); + + await check('v3 reports ready from the node-redis client', async () => { + assert.strictEqual(backends.v3({}).ready, true); + }); + + await raw.flushdb(); + await Promise.all([raw.quit(), io.quit(), node.close()]); + + for (const [ok, name, detail] of results) { + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`); + } + const failed = results.filter(([ok]) => !ok).length; + console.log(`\n${results.length - failed}/${results.length} passed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((error) => { + console.error(error); + process.exit(1); +}); From 9a7bd68758d3239dd58e020bc5cf840bc1ce371b Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 19:17:21 -0700 Subject: [PATCH 3/8] fix(provider-redis): resolve the v3 node-redis client on every use plugin-redis v3 replaces the node-redis client of a cluster or sentinel connection when it has to reconnect from scratch, destroying the old one; `connection.native` is a getter for whichever client is current. The store kept the client it was given at bootstrap, so after a swap every cache operation went to a destroyed client. The store now takes the client or a function returning the current one, and init passes `() => connection.native`. A smoke check swaps the client behind the getter and fails if the store or `ready` still see the old one. --- .../src/NodeRedisStore.ts | 26 ++++++++++++++++--- .../src/RedisCacheProvider.ts | 3 ++- .../provider-rest-cache-redis/src/index.ts | 4 ++- scripts/provider-smoke.cjs | 26 +++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts index 670a5314..ca5ac62c 100644 --- a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts +++ b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts @@ -78,13 +78,33 @@ export class NodeRedisStore extends EventEmitter { readonly opts: { dialect: 'redis'; url: string; useRedisSets: boolean }; - readonly client: NodeRedisClient; + /** + * The client to use right now. + * + * plugin-redis v3 replaces the node-redis client of a cluster or sentinel + * connection when it has to reconnect from scratch, destroying the old one; + * `connection.native` is a getter for whichever is current. So the client is + * resolved on every use rather than kept: a reference taken at bootstrap + * would go on pointing at a destroyed client. + */ + get client(): NodeRedisClient { + return this.resolveClient(); + } + + private readonly resolveClient: () => NodeRedisClient; readonly isCluster: boolean; - constructor(client: NodeRedisClient, options: NodeRedisStoreOptions) { + /** + * @param client the client, or a function returning the current one - pass + * `() => connection.native` for a plugin-redis v3 connection. + */ + constructor( + client: NodeRedisClient | (() => NodeRedisClient), + options: NodeRedisStoreOptions + ) { super(); - this.client = client; + this.resolveClient = typeof client === 'function' ? client : () => client; this.isCluster = options.isCluster; // `dialect` is what makes Keyv treat this store as iterable. this.opts = { diff --git a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts index 6e870958..a36887e7 100644 --- a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts +++ b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts @@ -87,7 +87,8 @@ const nodeRedisStoreOf = (cache: Cache): NodeRedisStore | undefined => { * See https://github.com/strapi-community/plugin-rest-cache/pull/239 */ export interface NativeRedisConnection { - client: NodeRedisClient; + /** The client, or a function returning the current one; see NodeRedisStore#client. */ + client: NodeRedisClient | (() => NodeRedisClient); isCluster: boolean; } diff --git a/packages/provider-rest-cache-redis/src/index.ts b/packages/provider-rest-cache-redis/src/index.ts index 29100784..380f4a0f 100644 --- a/packages/provider-rest-cache-redis/src/index.ts +++ b/packages/provider-rest-cache-redis/src/index.ts @@ -108,7 +108,9 @@ export = { strapi.log.info('REST Cache provider "redis": connection established'); return new RedisCacheProvider(connection.client, options, { - client: connection.native, + // Read on every use, never kept: v3 swaps the client of a cluster or + // sentinel connection that has to reconnect from scratch. + client: () => connection.native, isCluster: connection.type === 'cluster', }); } diff --git a/scripts/provider-smoke.cjs b/scripts/provider-smoke.cjs index cd323bcd..4cdfa1b8 100644 --- a/scripts/provider-smoke.cjs +++ b/scripts/provider-smoke.cjs @@ -138,6 +138,31 @@ async function redisProviderUsesV3Native() { assert.strictEqual(instance.ready, true); } +// plugin-redis v3 replaces the node-redis client of a cluster or sentinel +// connection when it has to reconnect from scratch, and `connection.native` +// is a getter for whichever client is current. A store that kept the client +// it was first given would go on using a destroyed one. +async function redisProviderFollowsV3ClientSwap() { + const { provider } = redisProviderModule(); + const before = { isReady: true, async get() { return 'before'; } }; + const after = { isReady: true, async get() { return 'after'; } }; + let current = before; + const connection = { client: untouchable, type: 'cluster', ready: true }; + Object.defineProperty(connection, 'native', { get: () => current, enumerable: false }); + const strapi = v3Strapi({ whenReady: async () => connection }); + + const instance = await provider.init({}, { strapi }); + const store = instance.cache.stores[0].opts.store; + assert.strictEqual(await store.get('k'), 'before'); + + current = after; + assert.strictEqual(store.client, after, 'the store kept the replaced client'); + assert.strictEqual(await store.get('k'), 'after'); + + after.isReady = false; + assert.strictEqual(instance.ready, false, 'ready should follow the current client'); +} + async function redisProviderDetectsV3Cluster() { const { provider } = redisProviderModule(); const strapi = v3Strapi({ @@ -203,6 +228,7 @@ async function redisProviderKeepsV2Path() { await record('redis provider: module loads', redisProviderModuleLoads); await record('redis provider: uses plugin-redis v3 native client', redisProviderUsesV3Native); await record('redis provider: detects a v3 cluster from .type', redisProviderDetectsV3Cluster); + await record('redis provider: follows a v3 client swap', redisProviderFollowsV3ClientSwap); await record('redis provider: reports a v3 connection failure', redisProviderReportsV3Failure); await record('redis provider: keeps the plugin-redis v2 path', redisProviderKeepsV2Path); From 3afb1562818a6b95e0ac46ec5b227af650b2931f Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 23:11:43 -0700 Subject: [PATCH 4/8] test(provider-redis): hold the v3 store to the #244, #245 and #249 checks The merged fixes have live and recording-client checks for the plugin-redis v2 path only. The node-redis store this branch adds has the same three problems, and nothing exercised them: - on a cluster its delete() and unlinkTracked() UNLINK before they SREM, so a purge racing a write leaves a live entry outside the tracking set (#244); - nothing prunes its tracking set, so expired members are listed by keys() and the set grows for as long as the cache runs (#249); - with useRedisSets off, keys() reads the empty tracking set, so pattern purges match nothing (#245). redis-standalone-live and redis-cluster-live now also drive the provider on a real node-redis client (createClient, createCluster), constructed the way init() does on v3. The cluster harness runs every check against both backends. redis-cluster-check's node-redis recorder gains EXISTS, SSCAN, per-master clients and an SREM hook, for the command-order, prune and client-swap assertions. redis-key-continuity now requires keys() to list every entry the other backend wrote, not only to agree with it, and runs against a cluster when REDIS_CLUSTER_NODES is set, which CI's redis_cluster job now does. The new v3 checks fail at this commit; the fixes follow. The prune-race checks also assert that a SREM was issued, since a prune that does nothing passes them otherwise. --- .github/workflows/tests.yml | 7 + scripts/redis-cluster-check.cjs | 235 +++++++++++++++++++++++++++++- scripts/redis-cluster-live.cjs | 173 +++++++++++++++------- scripts/redis-key-continuity.cjs | 126 +++++++++++----- scripts/redis-standalone-live.cjs | 193 ++++++++++++++++++++++++ 5 files changed, 637 insertions(+), 97 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3e725b05..988cfc62 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -118,6 +118,13 @@ jobs: - run: pnpm run test:cluster:live + # The v2/v3 storage layout again, with both backends on their cluster + # code paths and the entries spread over every master. See + # https://github.com/strapi-community/plugin-rest-cache/pull/239 + - run: pnpm run test:continuity + env: + REDIS_CLUSTER_NODES: 127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002 + linters: runs-on: ubuntu-latest strategy: diff --git a/scripts/redis-cluster-check.cjs b/scripts/redis-cluster-check.cjs index 43609a94..93d46a69 100644 --- a/scripts/redis-cluster-check.cjs +++ b/scripts/redis-cluster-check.cjs @@ -114,40 +114,102 @@ function recordingClient({ isCluster }) { * Same command names and argument shapes as @redis/client: arrays for * multi-key commands, camelCase methods, a chainable multi(). */ -function recordingNodeRedis({ isCluster }) { +function recordingNodeRedis({ isCluster, masters = 3 }) { const issued = []; const members = new Set(); + // The entries themselves, as distinct from the tracking set's members. + const entries = new Set(); const flat = (k) => [k].flat(); const run = { set: (key) => { issued.push({ cmd: 'set', keys: [key] }); + entries.add(key); return 'OK'; }, sAdd: (_set, m) => { + issued.push({ cmd: 'sadd', keys: flat(m) }); for (const k of flat(m)) members.add(k); return flat(m).length; }, sRem: (_set, m) => { issued.push({ cmd: 'srem', keys: flat(m) }); + // Before the members go, as on the ioredis recorder: a write landing + // between the prune's check and its SREM. + if (client.onSrem) client.onSrem(); for (const k of flat(m)) members.delete(k); return flat(m).length; }, unlink: (k) => { issued.push({ cmd: 'unlink', keys: flat(k) }); - for (const key of flat(k)) members.delete(key); + for (const key of flat(k)) { + members.delete(key); + entries.delete(key); + } return flat(k).length; }, }; - return { + /** + * One client per master, each holding the entries that hash to it - here, + * every `masters`-th one. SCAN sent to the cluster object itself would only + * see whichever node it landed on, so it throws. + */ + const nodes = Array.from({ length: masters }, (_, i) => ({ address: `node-${i}` })); + const nodeClients = nodes.map((node, i) => ({ + async scan(cursor, options = {}) { + issued.push({ cmd: 'scan', node: node.address, cursor, keys: [] }); + const pattern = String(options.MATCH || '*').replace(/\*$/, ''); + const held = [...entries].filter((_, n) => n % masters === i); + return { cursor: '0', keys: held.filter((key) => key.startsWith(pattern)) }; + }, + async unlink(k) { + return run.unlink(k); + }, + })); + + const client = { isCluster, isReady: true, issued, members, + entries, + onSrem: null, seed(keys) { - for (const key of keys) members.add(key); + for (const key of keys) { + members.add(key); + entries.add(key); + } }, + /** An entry Redis expired: gone, but still a member of the tracking set. */ + expire(key) { + entries.delete(key); + }, + async exists(k) { + issued.push({ cmd: 'exists', keys: flat(k) }); + return flat(k).filter((key) => entries.has(key)).length; + }, + async sScan(_set, cursor, options = {}) { + // Pages of COUNT, cursor = offset: enough to drive a multi-page pass. + issued.push({ cmd: 'sscan', cursor, keys: [] }); + const all = [...members]; + const start = Number(cursor); + const next = start + Number(options.COUNT || 10); + return { cursor: next >= all.length ? '0' : String(next), members: all.slice(start, next) }; + }, + async keys(pattern) { + issued.push({ cmd: 'keys', keys: [] }); + const prefix = pattern.replace(/\*$/, ''); + return [...entries].filter((key) => key.startsWith(prefix)); + }, + ...(isCluster + ? { + masters: nodes, + async nodeClient(node) { + return nodeClients[nodes.indexOf(node)]; + }, + } + : {}), async get(key) { issued.push({ cmd: 'get', keys: [key] }); return null; @@ -171,9 +233,16 @@ function recordingNodeRedis({ isCluster }) { async sMembers() { return [...members]; }, - async scan() { - issued.push({ cmd: 'scan', keys: [] }); - return { cursor: '0', keys: [] }; + async scan(cursor, options) { + // On a cluster, only the node the command lands on: the first. + if (isCluster) { + const reply = await nodeClients[0].scan(cursor, options); + issued[issued.length - 1].node = 'cluster'; + return reply; + } + issued.push({ cmd: 'scan', cursor, keys: [] }); + const pattern = String((options && options.MATCH) || '*').replace(/\*$/, ''); + return { cursor: '0', keys: [...entries].filter((key) => key.startsWith(pattern)) }; }, multi() { const queued = []; @@ -186,6 +255,8 @@ function recordingNodeRedis({ isCluster }) { issued.push({ cmd: 'multi', queued }); for (const q of queued) { if (q.cmd === 'unlink' || q.cmd === 'srem') for (const k of q.keys) members.delete(k); + if (q.cmd === 'unlink') for (const k of q.keys) entries.delete(k); + if (q.cmd === 'set') for (const k of q.keys) entries.add(k); if (q.cmd === 'sadd') for (const k of q.keys) members.add(k); } return queued.map(() => 1); @@ -194,6 +265,8 @@ function recordingNodeRedis({ isCluster }) { return chain; }, }; + + return client; } /** Swap the provider's underlying store for our recorder. */ @@ -600,6 +673,154 @@ async function check(name, fn) { assert.strictEqual(multis[0].queued[0].keys.length, KEYS.length); }); + // The v3 store is not one MULTI per write or delete on a cluster either, so + // the same order applies, for the same reason: UNLINK (delete) -> SET, SADD + // (write) -> SREM (delete) leaves a live entry no purge can find. + await check('v3 cluster: delMany untracks before it unlinks', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.delMany(KEYS); + + const order = client.issued.map((c) => c.cmd); + const lastSrem = order.lastIndexOf('srem'); + const firstUnlink = order.indexOf('unlink'); + assert.ok(lastSrem !== -1 && firstUnlink !== -1, `unexpected commands: ${order.join(',')}`); + assert.ok(lastSrem < firstUnlink, 'unlinked an entry before removing it from the tracking set'); + }); + + await check('v3 cluster: del() untracks before it unlinks', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.del(KEYS[0]); + + const order = client.issued.map((c) => c.cmd).filter((cmd) => cmd === 'srem' || cmd === 'unlink'); + assert.deepStrictEqual(order, ['srem', 'unlink'], `issued ${order.join(' -> ')}`); + assert.ok(!client.members.has(qualified[0]), 'still tracked after del()'); + }); + + await check('v3 cluster: clear() untracks before it unlinks', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.clear(); + + const order = client.issued.map((c) => c.cmd); + assert.ok(order.lastIndexOf('srem') < order.indexOf('unlink'), `issued ${order.join(',')}`); + }); + + await check('v3 cluster: prune() untracks expired entries, checking key by key', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + client.expire(qualified[0]); + + await provider.prune(); + const keys = await provider.keys(); + assert.ok(!keys.includes(KEYS[0]), 'listed an expired entry after a prune'); + assert.strictEqual(keys.length, KEYS.length - 1, `listed ${keys.length} of ${KEYS.length - 1} live entries`); + + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], 'the prune issued a multi-key command'); + assert.strictEqual(client.issued.some((c) => c.cmd === 'multi'), false, 'used MULTI'); + assert.ok( + client.issued.some((c) => c.cmd === 'srem' && c.keys.includes(qualified[0])), + 'the expired entry was not removed from the tracking set' + ); + }); + + await check('v3 cluster: prune() keeps an entry written while it was being pruned', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + client.expire(qualified[0]); + client.onSrem = () => { + client.onSrem = null; + client.seed([qualified[0]]); + }; + + await provider.prune(); + assert.strictEqual(client.onSrem, null, 'the prune issued no SREM'); + assert.ok(client.members.has(qualified[0]), 'the rewritten entry was left out of the tracking set'); + }); + + await check('v3: prune() re-tracks what it removed when the re-check fails', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + client.expire(qualified[0]); + client.onSrem = () => { + client.onSrem = null; + client.seed([qualified[0]]); + client.exists = async () => { + throw new Error('connection lost'); + }; + }; + + await provider.prune(); + // Without this, a prune that never untracked anything would pass. + assert.strictEqual(client.onSrem, null, 'the prune issued no SREM'); + assert.ok(client.members.has(qualified[0]), 'a possibly-live member was left untracked'); + }); + + await check('v3: set() starts a background prune, at most once per interval', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + const passes = () => client.issued.filter((c) => c.cmd === 'sscan' && c.cursor === '0').length; + for (let i = 0; i < 3; i++) { + await provider.set(KEYS[i], { i }, 60000); + await provider.keys(); + await new Promise((resolve) => setImmediate(resolve)); + } + assert.strictEqual(passes(), 1, `started ${passes()} passes`); + }); + + // plugin-redis v3 replaces the node-redis client of a cluster connection + // that has to reconnect from scratch, and destroys the old one. A prune that + // took the client once, at the start of a pass, would go on sending to the + // destroyed one for the rest of it. + await check('v3: prune() resolves the client for every command', async () => { + const before = recordingNodeRedis({ isCluster: true }); + const after = recordingNodeRedis({ isCluster: true }); + let current = before; + const provider = new RedisCacheProvider(null, {}, { client: () => current, isCluster: true }); + for (const client of [before, after]) { + client.seed(qualified); + client.expire(qualified[0]); + } + const sScan = before.sScan; + before.sScan = async (...args) => { + const reply = await sScan(...args); + current = after; + return reply; + }; + + await provider.prune(); + const late = before.issued.filter((c) => c.cmd !== 'sscan'); + assert.deepStrictEqual(late, [], `sent ${late.map((c) => c.cmd).join(',')} to the replaced client`); + assert.ok(!after.members.has(qualified[0]), 'the current client was not pruned'); + }); + + // Without redis sets the entries are named `sets:namespace:keyv:keyv:` + // and there is nothing to read them from, so keys() has to SCAN for them - + // on every master, since SCAN only sees the node it is sent to. + await check('v3 cluster without redis sets: keys() scans every master', async () => { + const client = recordingNodeRedis({ isCluster: true }); + const provider = new RedisCacheProvider(null, { useRedisSets: false }, { client, isCluster: true }); + client.seed(qualified.map((k) => `sets:namespace:keyv:${k}`)); + + const keys = await provider.keys(); + assert.deepStrictEqual(keys.sort(), [...KEYS].sort()); + const scanned = new Set(client.issued.filter((c) => c.cmd === 'scan').map((c) => c.node)); + assert.deepStrictEqual([...scanned].sort(), ['node-0', 'node-1', 'node-2'], `scanned ${[...scanned]}`); + }); + + await check('v3 cluster without redis sets: clear() issues no multi-key command', async () => { + const client = recordingNodeRedis({ isCluster: true }); + const provider = new RedisCacheProvider(null, { useRedisSets: false }, { client, isCluster: true }); + client.seed(qualified.map((k) => `sets:namespace:keyv:${k}`)); + + await provider.clear(); + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], `issued ${JSON.stringify(offending[0])}`); + assert.strictEqual(client.entries.size, 0, `left ${client.entries.size} entries`); + }); + console.log(`redis cluster safety - ${results.length} checks\n`); for (const [ok, name, detail] of results) { console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`); diff --git a/scripts/redis-cluster-live.cjs b/scripts/redis-cluster-live.cjs index 84039bf3..46bd19c2 100644 --- a/scripts/redis-cluster-live.cjs +++ b/scripts/redis-cluster-live.cjs @@ -29,6 +29,8 @@ const PROVIDER = path.join(ROOT, 'packages/provider-rest-cache-redis'); const requireFromProvider = createRequire(path.join(PROVIDER, 'package.json')); // The ioredis the provider actually runs on is @keyv/redis's. const Redis = createRequire(requireFromProvider.resolve('@keyv/redis'))('ioredis'); +// The node-redis plugin-redis v3 hands over as `.native`. +const { createCluster } = requireFromProvider('@redis/client'); const { RedisCacheProvider } = require(path.join(PROVIDER, 'dist/RedisCacheProvider.js')); const NODES = (process.env.REDIS_CLUSTER_NODES || '127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002') @@ -62,18 +64,54 @@ async function allKeys(cluster) { return per.flat().sort(); } +/** + * The two ways the provider runs: on plugin-redis v2's ioredis client through + * @keyv/redis, and on plugin-redis v3's node-redis client through + * NodeRedisStore, constructed the way init() does on v3 - with `null` standing + * for v3's ioredis shim, which that path must never touch. Every check below + * runs against both. See + * https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +const BACKENDS = [ + { + label: '', + async connect() { + const client = new Redis.Cluster(NODES); + await new Promise((resolve, reject) => { + client.once('ready', resolve); + client.once('error', reject); + }); + return client; + }, + make: (client, options) => new RedisCacheProvider(client, options), + // What a write, a delete or a prune issues, by this client's names. + commands: ['set', 'sadd', 'srem', 'unlink', 'exists', 'sscan'], + close: (client) => client.disconnect(), + }, + { + label: 'v3 ', + async connect() { + const client = createCluster({ + rootNodes: NODES.map(({ host, port }) => ({ url: `redis://${host}:${port}` })), + }); + await client.connect(); + return client; + }, + make: (client, options) => + new RedisCacheProvider(null, options, { client: () => client, isCluster: true }), + commands: ['set', 'sAdd', 'sRem', 'unlink', 'exists', 'sScan'], + close: (client) => client.close(), + }, +]; + /** * Its own cluster connection, with a few milliseconds of jitter on every * command a write, a delete or a prune issues - as a busy network would add. * Locally a round trip is far shorter than the gaps the races below need. */ -async function jittered() { - const client = new Redis.Cluster(NODES); - await new Promise((resolve, reject) => { - client.once('ready', resolve); - client.once('error', reject); - }); - for (const command of ['set', 'sadd', 'srem', 'unlink', 'exists', 'sscan']) { +async function jittered(backend) { + const client = await backend.connect(); + for (const command of backend.commands) { const send = client[command].bind(client); client[command] = async (...args) => { await new Promise((resolve) => setTimeout(resolve, Math.random() * 3)); @@ -83,14 +121,11 @@ async function jittered() { return client; } -(async () => { - const cluster = new Redis.Cluster(NODES); - await new Promise((resolve, reject) => { - cluster.once('ready', resolve); - cluster.once('error', reject); - }); - - const provider = new RedisCacheProvider(cluster, {}); +/** Every check, against one backend. `cluster` is an ioredis client for raw access. */ +async function suite(backend, cluster) { + const { label } = backend; + const own = await backend.connect(); + const provider = backend.make(own, {}); // Keyv reports store failures as events, not rejections. Collect them, so a // swallowed CROSSSLOT fails a check instead of passing it. const storeErrors = []; @@ -98,32 +133,41 @@ async function jittered() { const noStoreErrors = () => assert.deepStrictEqual(storeErrors.splice(0), [], 'the store reported an error'); - await check('set() stores entries that get() returns', async () => { + /** Live entries keys() does not list, which no purge can reach. */ + const unseenByKeys = async () => { + const listed = new Set(await provider.keys()); + return (await allKeys(cluster)) + .filter((key) => key.startsWith('keyv:')) + .map((key) => key.slice('keyv:'.length)) + .filter((key) => !listed.has(key)); + }; + + await check(`${label}set() stores entries that get() returns`, async () => { await flushAll(cluster); for (const key of KEYS) await provider.set(key, { key }, MAX_AGE); noStoreErrors(); for (const key of KEYS) assert.deepStrictEqual(await provider.get(key), { key }, key); }); - await check('set() tracks every entry, with its lifetime', async () => { + await check(`${label}set() tracks every entry, with its lifetime`, async () => { const tracked = (await cluster.smembers('namespace:keyv')).sort(); assert.deepStrictEqual(tracked, KEYS.map((k) => `keyv:${k}`).sort()); const ttl = await cluster.pttl(`keyv:${KEYS[0]}`); assert.ok(ttl > MAX_AGE - 10000 && ttl <= MAX_AGE, `lifetime ${ttl}ms is not maxAge`); }); - await check('keys() lists every entry', async () => { + await check(`${label}keys() lists every entry`, async () => { assert.deepStrictEqual((await provider.keys()).sort(), [...KEYS].sort()); }); - await check('del() removes the entry and its tracking', async () => { + await check(`${label}del() removes the entry and its tracking`, async () => { await provider.del(KEYS[0]); noStoreErrors(); assert.ok((await provider.get(KEYS[0])) == null, 'entry survived del()'); assert.strictEqual(await cluster.sismember('namespace:keyv', `keyv:${KEYS[0]}`), 0); }); - await check('delMany() removes entries across slots', async () => { + await check(`${label}delMany() removes entries across slots`, async () => { await provider.delMany(KEYS.slice(1, 15)); noStoreErrors(); for (const key of KEYS.slice(1, 15)) { @@ -132,7 +176,7 @@ async function jittered() { assert.deepStrictEqual((await provider.keys()).sort(), KEYS.slice(15).sort()); }); - await check('clear() empties the cluster', async () => { + await check(`${label}clear() empties the cluster`, async () => { await provider.clear(); noStoreErrors(); assert.deepStrictEqual(await allKeys(cluster), []); @@ -144,7 +188,7 @@ async function jittered() { // missing from the tracking set: invisible to keys(), so no later purge could // find it and it was served stale until maxAge. Every live entry must stay // reachable through keys(). - await check('delMany() racing set() never leaves a live entry keys() cannot see', async () => { + await check(`${label}delMany() racing set() never leaves a live entry keys() cannot see`, async () => { await flushAll(cluster); const keys = Array.from({ length: 200 }, (_, i) => `/api/articles?page=${i}&`); for (let round = 0; round < 20; round++) { @@ -155,11 +199,7 @@ async function jittered() { ]); } noStoreErrors(); - const listed = new Set(await provider.keys()); - const unseen = (await allKeys(cluster)) - .filter((key) => key.startsWith('keyv:')) - .map((key) => key.slice('keyv:'.length)) - .filter((key) => !listed.has(key)); + const unseen = await unseenByKeys(); assert.deepStrictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); }); @@ -168,11 +208,11 @@ async function jittered() { // commands on a loaded network, so the race almost never shows. Here the // deleting and the writing instance each get their own connection and a few // milliseconds of jitter per command, as a busy network would add. - await check('del() racing set() never leaves a live entry keys() cannot see', async () => { + await check(`${label}del() racing set() never leaves a live entry keys() cannot see`, async () => { await flushAll(cluster); - const clients = [await jittered(), await jittered()]; + const clients = [await jittered(backend), await jittered(backend)]; const [deleter, writer] = clients.map((client) => { - const instance = new RedisCacheProvider(client, {}); + const instance = backend.make(client, {}); instance.cache.stores[0].on('error', (error) => storeErrors.push(error.message)); return instance; }); @@ -185,14 +225,10 @@ async function jittered() { ); } } finally { - for (const client of clients) client.disconnect(); + await Promise.all(clients.map((client) => backend.close(client))); } noStoreErrors(); - const listed = new Set(await provider.keys()); - const unseen = (await allKeys(cluster)) - .filter((key) => key.startsWith('keyv:')) - .map((key) => key.slice('keyv:'.length)) - .filter((key) => !listed.has(key)); + const unseen = await unseenByKeys(); assert.deepStrictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); }); @@ -200,7 +236,7 @@ async function jittered() { // not: @keyv/redis only SREMs on an explicit delete, and the set itself has // no lifetime. keys() reported the dead entry, and the set grew forever. // keys() lists what the set holds; the prune is what removes such members. - await check('prune() untracks expired entries', async () => { + await check(`${label}prune() untracks expired entries`, async () => { await flushAll(cluster); await provider.set('/api/short', { short: true }, 50); await provider.set('/api/long', { long: true }, MAX_AGE); @@ -213,26 +249,44 @@ async function jittered() { assert.deepStrictEqual(await provider.get('/api/long'), { long: true }); }); + // The pass is incremental (SSCAN), so it has to follow the cursor past the + // first batch - on a cluster through whatever routing the client does for a + // keyed SSCAN. + await check(`${label}prune() covers a set larger than one batch`, async () => { + await flushAll(cluster); + const live = Array.from({ length: 1500 }, (_, i) => `/api/live?page=${i}&`); + const dead = Array.from({ length: 1500 }, (_, i) => `keyv:/api/dead?page=${i}&`); + await Promise.all(live.map((key) => provider.set(key, { key }, MAX_AGE))); + await cluster.sadd('namespace:keyv', ...dead); + await provider.prune(); + assert.deepStrictEqual((await provider.keys()).sort(), [...live].sort()); + }); + // The prune checks a member, SREMs it if its entry is gone, then checks // again and re-adds any that came back. A response cached for that key // between the first check and the SREM is untracked by the SREM, and only the // re-check keeps it findable by purges. Responses here are cached for keys // whose entries have expired - exactly the members a prune removes - while // it runs. - await check('prune() racing set() never leaves a live entry keys() cannot see', async () => { + await check(`${label}prune() racing set() never leaves a live entry keys() cannot see`, async () => { await flushAll(cluster); - const clients = [await jittered(), await jittered()]; + const clients = [await jittered(backend), await jittered(backend)]; const [pruner, writer] = clients.map((client) => { - const instance = new RedisCacheProvider(client, {}); + const instance = backend.make(client, {}); instance.cache.stores[0].on('error', (error) => storeErrors.push(error.message)); return instance; }); + // Expired entries nobody writes again. A prune that does nothing would + // pass the race check below trivially, since every write tracks its own + // entry, so these have to be seen untracked. + const gone = Array.from({ length: 20 }, (_, i) => `keyv:/api/gone?page=${i}&`); + let leftTracked = 0; try { const keys = Array.from({ length: 300 }, (_, i) => `/api/articles?page=${i}&`); for (let round = 0; round < 10; round++) { await flushAll(cluster); // Tracked, with nothing behind them: entries that have expired. - await cluster.sadd('namespace:keyv', ...keys.map((key) => `keyv:${key}`)); + await cluster.sadd('namespace:keyv', ...keys.map((key) => `keyv:${key}`), ...gone); // Writes spread over the few milliseconds the prune spends between // its check and its SREM, rather than all landing before the check. const later = (key) => @@ -241,26 +295,25 @@ async function jittered() { ); await Promise.all([pruner.prune(), ...keys.map(later)]); await writer.prune(); + const tracked = new Set(await cluster.smembers('namespace:keyv')); + leftTracked += gone.filter((member) => tracked.has(member)).length; } } finally { - for (const client of clients) client.disconnect(); + await Promise.all(clients.map((client) => backend.close(client))); } noStoreErrors(); - const listed = new Set(await provider.keys()); - const unseen = (await allKeys(cluster)) - .filter((key) => key.startsWith('keyv:')) - .map((key) => key.slice('keyv:'.length)) - .filter((key) => !listed.has(key)); + assert.strictEqual(leftTracked, 0, `${leftTracked} expired members were never untracked`); + const unseen = await unseenByKeys(); assert.deepStrictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); }); // Without redis sets there is no tracking set to read, so keys() has to find // entries itself - on every master, since SCAN only sees the node it is sent // to. Reading one node, or the empty set, made pattern purges match nothing. - const unset = new RedisCacheProvider(cluster, { useRedisSets: false }); + const unset = backend.make(own, { useRedisSets: false }); unset.cache.stores[0].on('error', (error) => storeErrors.push(error.message)); - await check('without redis sets: keys() finds entries on every master', async () => { + await check(`${label}without redis sets: keys() finds entries on every master`, async () => { await flushAll(cluster); for (const key of KEYS) await unset.set(key, { key }, MAX_AGE); noStoreErrors(); @@ -271,12 +324,32 @@ async function jittered() { assert.deepStrictEqual((await unset.keys()).sort(), [...KEYS].sort()); }); - await check('without redis sets: purging what keys() lists removes it', async () => { + await check(`${label}without redis sets: purging what keys() lists removes it`, async () => { await unset.delMany(await unset.keys()); noStoreErrors(); assert.deepStrictEqual(await allKeys(cluster), []); }); + await check(`${label}without redis sets: clear() empties every master`, async () => { + await flushAll(cluster); + for (const key of KEYS) await unset.set(key, { key }, MAX_AGE); + await unset.clear(); + noStoreErrors(); + assert.deepStrictEqual(await allKeys(cluster), []); + }); + + await backend.close(own); +} + +(async () => { + const cluster = new Redis.Cluster(NODES); + await new Promise((resolve, reject) => { + cluster.once('ready', resolve); + cluster.once('error', reject); + }); + + for (const backend of BACKENDS) await suite(backend, cluster); + await flushAll(cluster); cluster.disconnect(); diff --git a/scripts/redis-key-continuity.cjs b/scripts/redis-key-continuity.cjs index 692f4d82..44fcbf58 100644 --- a/scripts/redis-key-continuity.cjs +++ b/scripts/redis-key-continuity.cjs @@ -16,6 +16,9 @@ * against a real Redis, and compares the raw keys each one leaves behind. * * Needs a Redis it may flush: REDIS_URL (default redis://127.0.0.1:6379/15). + * With REDIS_CLUSTER_NODES set (comma-separated host:port) it runs against + * that cluster instead, where both backends take their cluster code paths and + * the entries spread over every master. * * @see https://github.com/strapi-community/plugin-rest-cache/pull/239 */ @@ -29,16 +32,24 @@ const PROVIDER = path.join(ROOT, 'packages/provider-rest-cache-redis'); const requireFromProvider = createRequire(path.join(PROVIDER, 'package.json')); const Redis = requireFromProvider('ioredis'); -const { createClient } = requireFromProvider('@redis/client'); +const { createClient, createCluster } = requireFromProvider('@redis/client'); const { RedisCacheProvider } = require(path.join(PROVIDER, 'dist/RedisCacheProvider.js')); const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379/15'; +const CLUSTER_NODES = process.env.REDIS_CLUSTER_NODES + ? process.env.REDIS_CLUSTER_NODES.split(',').map((node) => { + const [host, port] = node.split(':'); + return { host, port: Number(port) }; + }) + : null; const ENTRIES = [ ['/api/articles?', { data: [{ id: 1 }] }], ['/api/articles/1?populate=*', { data: { id: 1, title: 'one' } }], ['/api/categories?locale=fr', { data: [] }], ['/api/homepage?_etag', 'W/"abc"'], + // Enough paths that a cluster spreads them over every master. + ...Array.from({ length: 12 }, (_, i) => [`/api/pages?page=${i}&`, { page: i }]), ]; const MAX_AGE = 600000; @@ -52,29 +63,57 @@ async function check(name, fn) { } } -/** Every raw key in the database with its type and remaining lifetime. */ -async function snapshot(raw) { - const keys = (await raw.keys('*')).sort(); - const out = []; - for (const key of keys) { - const type = await raw.type(key); - const ttl = await raw.pttl(key); - const members = type === 'set' ? (await raw.smembers(key)).sort() : undefined; - // Lifetimes drift by the time between writes, so compare a band rather - // than the exact value - but a tight one: maxAge is milliseconds and a - // unit slip (#126) lands orders of magnitude outside it. - const lifetime = - ttl < 0 ? 'none' : ttl <= MAX_AGE && ttl > MAX_AGE - 10000 ? 'maxAge' : `off: ${ttl}ms`; - out.push({ key, type, lifetime, members }); +/** + * Connections to the target. `raw` is an ioredis client for inspecting it; + * `nodes()` the clients holding its keys - every master on a cluster. + */ +async function connect() { + if (!CLUSTER_NODES) { + const raw = new Redis(REDIS_URL); + const io = new Redis(REDIS_URL); + const node = createClient({ url: REDIS_URL }); + await node.connect(); + return { label: 'standalone', isCluster: false, raw, io, node, nodes: () => [raw] }; } - return out; + + const ready = (client) => + new Promise((resolve, reject) => { + client.once('ready', () => resolve(client)); + client.once('error', reject); + }); + const raw = await ready(new Redis.Cluster(CLUSTER_NODES)); + const io = await ready(new Redis.Cluster(CLUSTER_NODES)); + const node = createCluster({ + rootNodes: CLUSTER_NODES.map(({ host, port }) => ({ url: `redis://${host}:${port}` })), + }); + await node.connect(); + return { label: 'cluster', isCluster: true, raw, io, node, nodes: () => raw.nodes('master') }; } (async () => { - const raw = new Redis(REDIS_URL); - const io = new Redis(REDIS_URL); - const node = createClient({ url: REDIS_URL }); - await node.connect(); + const target = await connect(); + const { raw, io, node, isCluster } = target; + + const flush = () => Promise.all(target.nodes().map((n) => n.flushdb())); + const allKeys = async () => + (await Promise.all(target.nodes().map((n) => n.keys('*')))).flat().sort(); + + /** Every raw key with its type and remaining lifetime. */ + async function snapshot() { + const out = []; + for (const key of await allKeys()) { + const type = await raw.type(key); + const ttl = await raw.pttl(key); + const members = type === 'set' ? (await raw.smembers(key)).sort() : undefined; + // Lifetimes drift by the time between writes, so compare a band rather + // than the exact value - but a tight one: maxAge is milliseconds and a + // unit slip (#126) lands orders of magnitude outside it. + const lifetime = + ttl < 0 ? 'none' : ttl <= MAX_AGE && ttl > MAX_AGE - 10000 ? 'maxAge' : `off: ${ttl}ms`; + out.push({ key, type, lifetime, members }); + } + return out; + } // On v3 the provider is handed the plugin's deprecated ioredis shim as well, // and keeps it as `provider.client` for anyone typed against it - but must @@ -92,22 +131,27 @@ async function snapshot(raw) { const backends = { v2: (options) => new RedisCacheProvider(io, options), - v3: (options) => - new RedisCacheProvider(untouchable, options, { client: node, isCluster: false }), + v3: (options) => new RedisCacheProvider(untouchable, options, { client: node, isCluster }), }; for (const options of [{}, { useRedisSets: false }]) { - const mode = options.useRedisSets === false ? 'without redis sets' : 'with redis sets'; + const mode = `${target.label}, ${options.useRedisSets === false ? 'without' : 'with'} redis sets`; await check(`${mode}: v2 and v3 write byte-identical keys`, async () => { const layouts = {}; for (const [name, make] of Object.entries(backends)) { - await raw.flushdb(); + await flush(); const provider = make(options); for (const [key, value] of ENTRIES) await provider.set(key, value, MAX_AGE); - layouts[name] = await snapshot(raw); + layouts[name] = await snapshot(); } assert.ok(layouts.v2.length > 0, 'v2 wrote nothing'); + if (isCluster) { + const populated = (await Promise.all(target.nodes().map((n) => n.dbsize()))).filter( + (size) => size > 0 + ).length; + assert.ok(populated > 1, 'entries did not spread across masters'); + } assert.deepStrictEqual(layouts.v3, layouts.v2); }); @@ -115,8 +159,8 @@ async function snapshot(raw) { ['v2', 'v3'], ['v3', 'v2'], ]) { - await check(`${mode}: ${reader} reads and purges what ${writer} wrote`, async () => { - await raw.flushdb(); + await check(`${mode}: ${reader} reads, lists and purges what ${writer} wrote`, async () => { + await flush(); const before = backends[writer](options); for (const [key, value] of ENTRIES) await before.set(key, value, MAX_AGE); @@ -125,18 +169,18 @@ async function snapshot(raw) { assert.deepStrictEqual(await after.get(key), value, `get(${key})`); } - // keys() is what pattern purges match against. Without redis sets - // neither backend can enumerate (the layout predates this provider); - // that is the existing behaviour, so it only has to agree. + // keys() is what pattern purges match against, so each backend has to + // list every entry the other wrote - with redis sets from the tracking + // set, without them by the `sets:` names (#245). const listed = (await after.keys()).sort(); - const expected = (await before.keys()).sort(); - assert.deepStrictEqual(listed, expected, 'keys() differs between backends'); + assert.deepStrictEqual(listed, ENTRIES.map(([key]) => key).sort(), 'keys() missed entries'); + assert.deepStrictEqual(listed, (await before.keys()).sort(), 'keys() differs between backends'); - await after.delMany(ENTRIES.map(([key]) => key)); + await after.delMany(listed); for (const [key] of ENTRIES) { assert.ok((await before.get(key)) == null, `${key} survived the purge`); } - const left = (await raw.keys('*')).filter((k) => !k.startsWith('namespace:')); + const left = (await allKeys()).filter((k) => !k.startsWith('namespace:')); assert.deepStrictEqual(left, [], 'purge left entries behind'); if (options.useRedisSets !== false) { assert.strictEqual(await raw.scard('namespace:keyv'), 0, 'tracking set not emptied'); @@ -145,29 +189,31 @@ async function snapshot(raw) { } await check(`${mode}: v3 clear() empties what v2 wrote`, async () => { - await raw.flushdb(); + await flush(); const before = backends.v2(options); for (const [key, value] of ENTRIES) await before.set(key, value, MAX_AGE); await backends.v3(options).clear(); - assert.deepStrictEqual(await raw.keys('*'), []); + assert.deepStrictEqual(await allKeys(), []); }); } - await check('v3 never closes the shared client', async () => { + await check(`${target.label}: v3 never closes the shared client`, async () => { const provider = backends.v3({}); await provider.set('/api/x', 1, MAX_AGE); await provider.cache.disconnect(); assert.strictEqual(node.isReady, true, 'the node-redis client was closed'); - assert.strictEqual(await node.ping(), 'PONG'); + // A cluster has no single node to PING; any routed command proves it works. + if (isCluster) assert.strictEqual(await node.get('continuity-probe'), null); + else assert.strictEqual(await node.ping(), 'PONG'); }); - await check('v3 reports ready from the node-redis client', async () => { + await check(`${target.label}: v3 reports ready from the node-redis client`, async () => { assert.strictEqual(backends.v3({}).ready, true); }); - await raw.flushdb(); + await flush(); await Promise.all([raw.quit(), io.quit(), node.close()]); for (const [ok, name, detail] of results) { diff --git a/scripts/redis-standalone-live.cjs b/scripts/redis-standalone-live.cjs index 83e6a0b6..27ba6a09 100644 --- a/scripts/redis-standalone-live.cjs +++ b/scripts/redis-standalone-live.cjs @@ -17,6 +17,8 @@ const PROVIDER = path.join(ROOT, 'packages/provider-rest-cache-redis'); const requireFromProvider = createRequire(path.join(PROVIDER, 'package.json')); // The ioredis the provider actually runs on is @keyv/redis's. const Redis = createRequire(requireFromProvider.resolve('@keyv/redis'))('ioredis'); +// The node-redis plugin-redis v3 hands over as `.native`. +const { createClient } = requireFromProvider('@redis/client'); const { RedisCacheProvider } = require(path.join(PROVIDER, 'dist/RedisCacheProvider.js')); const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379/15'; @@ -166,6 +168,197 @@ async function check(name, fn) { assert.deepStrictEqual(await provider.keys(), ['/api/raced'], 'a live entry was left untracked'); }); + // plugin-redis v3: the same behaviour from NodeRedisStore, on a real + // node-redis client, constructed the way init() does on v3. `null` stands + // for v3's ioredis shim, which the v3 path must never touch. + // See https://github.com/strapi-community/plugin-rest-cache/pull/239 + const node = createClient({ url: REDIS_URL }); + await node.connect(); + const v3 = (options, native = node) => + new RedisCacheProvider(null, options, { client: () => native, isCluster: false }); + + for (const options of [{}, { useRedisSets: false }]) { + const mode = options.useRedisSets === false ? 'without redis sets' : 'with redis sets'; + const provider = v3(options); + + await check(`v3 ${mode}: keys() lists every entry, unqualified`, async () => { + await client.flushdb(); + for (const key of KEYS) await provider.set(key, { key }, MAX_AGE); + assert.deepStrictEqual((await provider.keys()).sort(), [...KEYS].sort()); + }); + + await check(`v3 ${mode}: purging what keys() lists removes it`, async () => { + const matched = (await provider.keys()).filter((key) => key.startsWith('/api/articles')); + assert.strictEqual(matched.length, 2, 'pattern matched nothing'); + await provider.delMany(matched); + assert.deepStrictEqual((await provider.keys()).sort(), ['/api/categories?locale=fr']); + for (const key of matched) assert.ok((await provider.get(key)) == null, `${key} survived`); + }); + + await check(`v3 ${mode}: clear() removes every entry`, async () => { + await client.flushdb(); + for (const key of KEYS) await provider.set(key, { key }, MAX_AGE); + await provider.clear(); + assert.deepStrictEqual(await client.keys('*'), []); + }); + } + + await check('v3 with redis sets: prune() untracks expired entries', async () => { + const provider = v3({}); + await client.flushdb(); + await provider.set('/api/short', { short: true }, 50); + await provider.set('/api/long', { long: true }, MAX_AGE); + await new Promise((resolve) => setTimeout(resolve, 150)); + await provider.prune(); + const listed = await provider.keys(); + assert.deepStrictEqual(listed, ['/api/long'], `keys() returned ${JSON.stringify(listed)}`); + const tracked = await client.smembers('namespace:keyv'); + assert.deepStrictEqual(tracked, ['keyv:/api/long'], `tracking set holds ${JSON.stringify(tracked)}`); + assert.deepStrictEqual(await provider.get('/api/long'), { long: true }); + }); + + await check('v3 with redis sets: writes start a background prune', async () => { + await client.flushdb(); + await client.sadd('namespace:keyv', 'keyv:/api/expired'); + const provider = v3({}); + await provider.set('/api/written', { written: true }, MAX_AGE); + for (let i = 0; i < 50 && (await client.scard('namespace:keyv')) > 1; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.deepStrictEqual(await client.smembers('namespace:keyv'), ['keyv:/api/written']); + }); + + await check('v3 with redis sets: prune() covers a set larger than one batch', async () => { + const provider = v3({}); + await client.flushdb(); + const live = Array.from({ length: 1500 }, (_, i) => `/api/live?page=${i}&`); + const dead = Array.from({ length: 1500 }, (_, i) => `keyv:/api/dead?page=${i}&`); + for (const key of live) await provider.set(key, { key }, MAX_AGE); + await client.sadd('namespace:keyv', ...dead); + await provider.prune(); + assert.deepStrictEqual((await provider.keys()).sort(), [...live].sort()); + }); + + // As `racing` above, on node-redis' sRem: a write lands between the prune's + // check and its SREM, and only the re-check keeps it tracked. + const racingNode = (onSrem, failExists) => + new Proxy(node, { + get(target, prop) { + if (prop === 'sRem') { + return async (...args) => { + const hook = onSrem.shift(); + if (hook) await hook(); + return target.sRem(...args); + }; + } + if (prop === 'exists' && failExists()) { + return async () => { + throw new Error('connection lost'); + }; + } + const value = target[prop]; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + + await check('v3 with redis sets: prune() re-tracks an entry written while it ran', async () => { + await client.flushdb(); + const hooks = []; + const provider = v3({}, racingNode(hooks, () => false)); + await provider.set('/api/raced', { stale: true }, 50); + await new Promise((resolve) => setTimeout(resolve, 150)); + hooks.push(() => provider.set('/api/raced', { fresh: true }, MAX_AGE)); + await provider.prune(); + assert.strictEqual(hooks.length, 0, 'the write was never injected - the prune issued no SREM'); + assert.deepStrictEqual(await provider.get('/api/raced'), { fresh: true }); + assert.deepStrictEqual(await provider.keys(), ['/api/raced'], 'a live entry was left untracked'); + }); + + await check('v3 with redis sets: a failed re-check leaves nothing live untracked', async () => { + await client.flushdb(); + const hooks = []; + let failing = false; + const provider = v3({}, racingNode(hooks, () => failing)); + await provider.set('/api/raced', { stale: true }, 50); + await new Promise((resolve) => setTimeout(resolve, 150)); + hooks.push(async () => { + await provider.set('/api/raced', { fresh: true }, MAX_AGE); + // Every EXISTS from here on - the re-check - fails. + failing = true; + }); + await provider.prune(); + assert.strictEqual(hooks.length, 0, 'the write was never injected - the prune issued no SREM'); + assert.deepStrictEqual(await provider.keys(), ['/api/raced'], 'a live entry was left untracked'); + }); + + // A purge racing responses being cached for the same keys, each from its own + // connection with a few milliseconds of jitter per command. A standalone + // server takes each write and each delete as one MULTI, so they cannot + // interleave; this guards that every live entry stays reachable by keys(). + const jitteredNode = async () => { + const own = createClient({ url: REDIS_URL }); + await own.connect(); + for (const command of ['set', 'sAdd', 'sRem', 'unlink', 'exists', 'sScan']) { + const send = own[command].bind(own); + own[command] = async (...args) => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 3)); + return send(...args); + }; + } + const multi = own.multi.bind(own); + own.multi = () => { + const queued = multi(); + const exec = queued.exec.bind(queued); + queued.exec = async (...args) => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 3)); + return exec(...args); + }; + return queued; + }; + return own; + }; + + const unseenByKeys = async (provider) => { + const listed = new Set(await provider.keys()); + return (await client.keys('keyv:*')) + .map((key) => key.slice('keyv:'.length)) + .filter((key) => !listed.has(key)); + }; + + await check('v3: delMany() racing set() never leaves a live entry keys() cannot see', async () => { + await client.flushdb(); + const provider = v3({}); + const keys = Array.from({ length: 200 }, (_, i) => `/api/articles?page=${i}&`); + for (let round = 0; round < 20; round++) { + await Promise.all(keys.map((key) => provider.set(key, 'old', MAX_AGE))); + await Promise.all([ + provider.delMany(keys), + ...keys.map((key) => provider.set(key, 'new', MAX_AGE)), + ]); + } + const unseen = await unseenByKeys(provider); + assert.strictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); + }); + + await check('v3: del() racing set() never leaves a live entry keys() cannot see', async () => { + await client.flushdb(); + const clients = [await jitteredNode(), await jitteredNode()]; + const [deleter, writer] = clients.map((own) => v3({}, own)); + const provider = v3({}); + try { + const keys = Array.from({ length: 200 }, (_, i) => `/api/articles?page=${i}&`); + for (let round = 0; round < 20; round++) { + await Promise.all(keys.map((key) => provider.set(key, 'old', MAX_AGE))); + await Promise.all(keys.flatMap((key) => [deleter.del(key), writer.set(key, 'new', MAX_AGE)])); + } + } finally { + await Promise.all(clients.map((own) => own.close())); + } + const unseen = await unseenByKeys(provider); + assert.strictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); + }); + + await node.close(); await client.flushdb(); await client.quit(); From 22472fe8a019b8f42221bf5d46be4344142a2982 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 23:15:59 -0700 Subject: [PATCH 5/8] fix(provider-redis): untrack before unlinking on a cluster in the v3 store #244 changed the plugin-redis v2 path to SREM before UNLINK on a cluster, where a delete is two commands rather than one MULTI. NodeRedisStore still unlinked first, in both delete() and unlinkTracked(), so a purge racing a response being cached for the same key could run UNLINK (purge) -> SET, SADD (write) -> SREM (purge) and leave a live entry outside the tracking set. keys() cannot list such an entry, so no purge reaches it and it is served stale until its maxAge. Against a real three-node cluster, the race checks left 16 and 22 such entries before this change and none after. The trade-off is the one makeClusterSafe documents: a failure between the two commands now leaves an untracked entry until its maxAge, where it used to leave only a dead member. --- .../src/NodeRedisStore.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts index ca5ac62c..4a18412b 100644 --- a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts +++ b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts @@ -174,10 +174,18 @@ export class NodeRedisStore extends EventEmitter { return (await this.client.unlink(name)) > 0; } + // Untrack first, then unlink, as the plugin-redis v2 path does on a + // cluster (#244). Without a MULTI a delete interleaves with a write of the + // same key, and unlinking first could run UNLINK (delete) -> SET, SADD + // (write) -> SREM (delete): a live entry missing from the tracking set, + // which keys() cannot list and so no purge can ever find. In this order the + // race leaves at worst a tracked key with nothing behind it, which the + // prune removes. An error between the two now leaves an untracked entry + // until its maxAge instead; makeClusterSafe in RedisCacheProvider has the + // trade-off. See https://github.com/strapi-community/plugin-rest-cache/issues/100 if (this.isCluster) { - const removed = await this.client.unlink(name); await this.client.sRem(this._getNamespace(), name); - return removed > 0; + return (await this.client.unlink(name)) > 0; } const [removed] = await this.client @@ -250,13 +258,15 @@ export class NodeRedisStore extends EventEmitter { if (!qualified.length) return; const set = this._getNamespace(); + // Untracked before anything is unlinked, for the reason given in + // delete(): unlinking first loses entries to a racing write (#244). if (this.isCluster) { - for (const batch of chunks(qualified, UNLINK_CONCURRENCY)) { - await Promise.all(batch.map((key) => this.client.unlink(key))); - } for (const batch of chunks(qualified, CHUNK)) { await this.client.sRem(set, batch); } + for (const batch of chunks(qualified, UNLINK_CONCURRENCY)) { + await Promise.all(batch.map((key) => this.client.unlink(key))); + } return; } From c28acd8c032971da182e3a7871769af9f6026ace Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 23:16:37 -0700 Subject: [PATCH 6/8] fix(provider-redis): find v3 entries by name when redis sets are off With useRedisSets: false there is no tracking set: entries are named `sets:namespace:keyv:keyv:`, as @keyv/redis 3 names them. #245 made the v2 path's keys() find them by that name, on every master of a cluster. The v3 store's keys() still read the empty tracking set, so every pattern purge matched nothing and entries outlived it until maxAge - and after #245, v2 and v3 listed different keys for the same data, which test:continuity caught. NodeRedisStore.namedKeys() SCANs for the `sets:` names. SCAN only sees the node it is sent to, so on a cluster it walks `masters` through nodeClient(), the @redis/client 5+ API, and refuses rather than scan one node when a client lacks them. clear() uses it too, unlinking key by key on a cluster instead of a multi-key UNLINK, and SCAN instead of KEYS, which blocks the server for the whole keyspace. --- .../src/NodeRedisStore.ts | 64 +++++++++++++++++-- .../src/RedisCacheProvider.ts | 15 ++++- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts index 4a18412b..a44783f5 100644 --- a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts +++ b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts @@ -32,12 +32,15 @@ export interface NodeRedisClient { sRem(key: string, members: string | string[]): Promise; sMembers(key: string): Promise; unlink(keys: string | string[]): Promise; - keys(pattern: string): Promise; scan( cursor: string, options?: { MATCH?: string; COUNT?: number } ): Promise<{ cursor: string; keys: string[] }>; multi(): NodeRedisMulti; + /** On a cluster (@redis/client 5+): one entry per master, for nodeClient(). */ + readonly masters?: unknown[]; + /** On a cluster: the client connected to one node of `masters`. */ + nodeClient?(node: unknown): Promise>; } export interface NodeRedisStoreOptions { @@ -211,20 +214,67 @@ export class NodeRedisStore extends EventEmitter { return; } - // As @keyv/redis 3: every key is named under the namespace, so one pattern - // finds them all. - const keys = await this.client.keys(`sets:${this._getNamespace()}:*`); + // Every key is named under the namespace, as @keyv/redis 3 names it, so + // one pattern finds them all - on every master of a cluster. + const keys = await this.namedKeys(); + if (this.isCluster) { + // A multi-key UNLINK spanning hash slots is CROSSSLOT on a cluster. + for (const batch of chunks(keys, UNLINK_CONCURRENCY)) { + await Promise.all(batch.map((key) => this.client.unlink(key))); + } + return; + } for (const batch of chunks(keys, CHUNK)) { await this.client.unlink(batch); } } + /** + * Every entry's raw key when redis sets are off: `sets:namespace::`. + * + * There is no tracking set to read then, so the keys are found by that name. + * SCAN only sees the node it is sent to, so on a cluster each master is + * scanned through its own client; scanning one node made pattern purges miss + * every entry held elsewhere (#245, as on the plugin-redis v2 path). + */ + async namedKeys(): Promise { + const MATCH = `sets:${this._getNamespace()}:*`; + const scanAll = async (node: Pick, found: string[]) => { + let cursor = '0'; + do { + const reply = await node.scan(cursor, { MATCH, COUNT: CHUNK }); + cursor = String(reply.cursor); + found.push(...reply.keys); + } while (cursor !== '0'); + }; + + const found: string[] = []; + if (!this.isCluster) { + await scanAll(this.client, found); + return found; + } + + const { masters } = this.client; + if (!masters || typeof this.client.nodeClient !== 'function') { + // Better a failed purge than one that reports success having looked at + // a single node. + throw new Error( + 'REST Cache provider "redis": this node-redis cluster client cannot list its masters' + ); + } + for (const master of masters) { + await scanAll(await this.client.nodeClient(master), found); + } + return found; + } + /** * Yields [key, value] for keys under `namespace`, as @keyv/redis 3 does. * - * SCAN addresses a single node, so on a cluster this would silently return - * part of the keyspace. The provider never needs it with redis sets on - it - * reads the tracking set instead - so refuse rather than mislead. + * Kept for Keyv's own iterator(). The provider never calls it: it reads the + * tracking set, or namedKeys() when redis sets are off. SCAN addresses a + * single node, so on a cluster this would silently return part of the + * keyspace - refuse rather than mislead. */ async *iterator(namespace?: string): AsyncGenerator<[string, string | null]> { if (this.isCluster) { diff --git a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts index 1e5fe7fa..39b28118 100644 --- a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts +++ b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts @@ -565,11 +565,20 @@ export class RedisCacheProvider extends CacheProvider { // See https://github.com/strapi-community/plugin-rest-cache/issues/131 const nodeStore = nodeRedisStoreOf(this.cache); if (nodeStore) { - // The same tracking set, read the same way, as on @keyv/redis below - - // including reading it when redis sets are off, where it is empty. + // The same keys, found the same way, as on @keyv/redis below: by their + // `sets:` names when redis sets are off (#245), otherwise from the + // tracking set. const prefix = `${nodeStore.namespace}:`; + const unqualified = (key: string) => + key.startsWith(prefix) ? key.slice(prefix.length) : key; + + if (!nodeStore.opts.useRedisSets) { + const named = `sets:${nodeStore._getNamespace()}:`; + return (await nodeStore.namedKeys()).map((key) => unqualified(key.slice(named.length))); + } + const members = await nodeStore.trackedKeys(); - return members.map((key) => (key.startsWith(prefix) ? key.slice(prefix.length) : key)); + return members.map(unqualified); } const store = redisStoreOf(this.cache); From 765332156943323e7b40b8a88b912ca6050471da Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 23:18:14 -0700 Subject: [PATCH 7/8] fix(provider-redis): prune the v3 store's tracking set #249 added a background prune of the tracking set: an entry that expires through its PX lifetime is removed by Redis, but its member stays, so keys() listed expired entries and the set grew for as long as the cache ran. The prune only knew @keyv/redis on ioredis, so on plugin-redis v3 it never ran, and prune() returned without doing anything. The prune's logic - SSCAN in batches, EXISTS per member, SREM the dead, re-check and re-add, fail safe, rate limited from keys() and set() - stays in one place and now drives a small TrackingSet adapter. The v2 adapter issues exactly the commands it issued before. NodeRedisStore.trackingSet() provides the v3 one: - EXISTS per key, issued together, which node-redis sends as one pipeline on a standalone server and routes key by key on a cluster; - a failed EXISTS counts as live, so a check that cannot be made never untracks an entry; - every command resolves the client as it is sent. A pass can take seconds on a large set, and plugin-redis v3 replaces a cluster or sentinel client that reconnects from scratch, destroying the old one. Seen failing on purpose as well as before the fix: counting a failed EXISTS as dead fails the two re-check checks, and resolving the client once per pass fails the client-swap check. --- .../src/NodeRedisStore.ts | 66 +++++++++++++++++++ .../src/RedisCacheProvider.ts | 60 ++++++++++++----- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts index a44783f5..3f94fb6c 100644 --- a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts +++ b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts @@ -36,6 +36,11 @@ export interface NodeRedisClient { cursor: string, options?: { MATCH?: string; COUNT?: number } ): Promise<{ cursor: string; keys: string[] }>; + sScan( + key: string, + cursor: string, + options?: { MATCH?: string; COUNT?: number } + ): Promise<{ cursor: string; members: string[] }>; multi(): NodeRedisMulti; /** On a cluster (@redis/client 5+): one entry per master, for nodeClient(). */ readonly masters?: unknown[]; @@ -43,6 +48,27 @@ export interface NodeRedisClient { nodeClient?(node: unknown): Promise>; } +/** + * The tracking set, as the provider's background prune sees it. + * + * The prune's logic - SSCAN a batch, check which members still exist, SREM the + * dead, re-check and re-add - lives once, in RedisCacheProvider, and runs on + * plugin-redis v2's ioredis client and v3's node-redis client alike through + * this. See https://github.com/strapi-community/plugin-rest-cache/issues/131 + */ +export interface TrackingSet { + /** One SSCAN step: the next cursor ('0' when done) and a batch of members. */ + sscan(cursor: string, count: number): Promise<[string, string[]]>; + /** + * Whether each key exists. Never rejects: a key that could not be checked is + * reported as existing, because untracking a live entry hides it from every + * purge while keeping a dead one only costs a wasted UNLINK later. + */ + existing(keys: string[]): Promise; + srem(members: string[]): Promise; + sadd(members: string[]): Promise; +} + export interface NodeRedisStoreOptions { /** Whether `client` is a cluster. Decides which commands are slot-safe. */ isCluster: boolean; @@ -330,6 +356,46 @@ export class NodeRedisStore extends EventEmitter { return this.client.sMembers(this._getNamespace()); } + /** + * The tracking set, for the provider's prune; undefined when there is none. + * + * Every command resolves `this.client` as it is sent, never once per pass: + * a pass takes seconds on a large set, and plugin-redis v3 may replace the + * client in the middle of one. + */ + trackingSet(): TrackingSet | undefined { + if (!this.opts.useRedisSets || !this.namespace) return undefined; + const set = this._getNamespace(); + + return { + sscan: async (cursor, count) => { + const reply = await this.client.sScan(set, cursor, { COUNT: count }); + return [String(reply.cursor), reply.members]; + }, + // One EXISTS per key: a variadic EXISTS only returns a count, and on a + // cluster it would span hash slots. Issued together, so node-redis sends + // them as one pipeline on a standalone server (it batches every command + // made in the same tick) and routes each to its node on a cluster. + existing: async (keys) => { + try { + return await Promise.all( + keys.map((key) => + this.client.exists(key).then( + (count) => Number(count) > 0, + () => true + ) + ) + ); + } catch { + return keys.map(() => true); + } + }, + // One key, the set, so a batched SREM or SADD is slot-safe on a cluster. + srem: (members) => this.client.sRem(set, members), + sadd: (members) => this.client.sAdd(set, members), + }; + } + get ready(): boolean { return this.client.isReady === true; } diff --git a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts index 39b28118..2cdd9c1e 100644 --- a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts +++ b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts @@ -2,7 +2,7 @@ import { createCache, type Cache } from 'cache-manager'; import { CacheProvider } from '@strapi-community/plugin-rest-cache/types'; import type { CacheKey, Milliseconds } from '@strapi-community/plugin-rest-cache/types'; -import { NodeRedisStore, type NodeRedisClient } from './NodeRedisStore'; +import { NodeRedisStore, type NodeRedisClient, type TrackingSet } from './NodeRedisStore'; // keyv 4 exports the constructor directly, keyv 5 exports it as `.default`, and // @keyv/redis exposes only `.default` from its CJS build. Accept either shape: @@ -223,7 +223,8 @@ interface PruneState { } /** - * Prune bookkeeping, keyed by the @keyv/redis store. + * Prune bookkeeping, keyed by the store: @keyv/redis, or NodeRedisStore on + * plugin-redis v3. * * Not a field on the provider: the plugin freezes the instance it gets back, * so it could never be written after construction. @@ -254,6 +255,31 @@ function prunable(store: KeyvRedisStore): store is KeyvRedisStore & { redis: Red ); } +/** + * The tracking set of @keyv/redis on plugin-redis v2's ioredis client, as the + * prune drives it. The same commands the prune always issued there; v3's + * node-redis store provides its own (NodeRedisStore#trackingSet). + */ +function ioredisTrackingSet(redis: RedisClient, set: string): TrackingSet { + return { + sscan: (cursor, count) => redis.sscan!(set, cursor, 'COUNT', count), + existing: (keys) => existing(redis, keys), + srem: (members) => redis.srem(set, ...members), + sadd: (members) => redis.sadd(set, ...members), + }; +} + +/** + * The tracking set to prune, on whichever store the cache runs on, or + * undefined when it keeps none this provider knows how to prune. + */ +function trackingSetOf(store: object): TrackingSet | undefined { + if (store instanceof NodeRedisStore) return store.trackingSet(); + const keyvRedis = store as KeyvRedisStore; + if (!prunable(keyvRedis)) return undefined; + return ioredisTrackingSet(keyvRedis.redis, keyvRedis._getNamespace!()); +} + /** * Put members back in the tracking set, trying twice. * @@ -262,10 +288,10 @@ function prunable(store: KeyvRedisStore): store is KeyvRedisStore & { redis: Red * purges and writes, and a member it could not restore is the one residual * case documented on untrackDead. */ -async function retrack(redis: RedisClient, set: string, members: string[]): Promise { +async function retrack(tracking: TrackingSet, members: string[]): Promise { for (let attempt = 0; attempt < 2; attempt++) { try { - await redis.sadd(set, ...members); + await tracking.sadd(members); return; } catch { // Tried again once; see above. @@ -291,23 +317,23 @@ async function retrack(redis: RedisClient, set: string, members: string[]): Prom * script would close it on a standalone server, but not on a cluster, where * the entries live in other slots than the set. */ -async function untrackDead(redis: RedisClient, set: string, members: string[]): Promise { - const found = await existing(redis, members); +async function untrackDead(tracking: TrackingSet, members: string[]): Promise { + const found = await tracking.existing(members); const dead = members.filter((_, i) => !found[i]); if (!dead.length) return; try { // One key, the set, so a batched SREM is slot-safe on a cluster too. - await redis.srem(set, ...dead); + await tracking.srem(dead); } catch { // It may still have applied; the re-check below decides. } // existing() reports every member it could not check as live, so a failed // re-check re-tracks all of them. - const recheck = await existing(redis, dead); + const recheck = await tracking.existing(dead); const rewritten = dead.filter((_, i) => recheck[i]); - if (rewritten.length) await retrack(redis, set, rewritten); + if (rewritten.length) await retrack(tracking, rewritten); } /** @@ -329,13 +355,13 @@ async function untrackDead(redis: RedisClient, set: string, members: string[]): * * See https://github.com/strapi-community/plugin-rest-cache/issues/131 */ -async function pruneTrackingSet(redis: RedisClient, set: string): Promise { +async function pruneTrackingSet(tracking: TrackingSet): Promise { try { let cursor = '0'; do { - const [next, batch] = await redis.sscan!(set, cursor, 'COUNT', PRUNE_BATCH); + const [next, batch] = await tracking.sscan(cursor, PRUNE_BATCH); cursor = next; - if (batch.length) await untrackDead(redis, set, batch); + if (batch.length) await untrackDead(tracking, batch); } while (cursor !== '0'); } catch { // See above: the next pass picks up where this one could not. @@ -351,15 +377,16 @@ async function pruneTrackingSet(redis: RedisClient, set: string): Promise * members took a pattern purge from ~70ms to 0.5s standalone and over 2s on a * cluster, on every content save. */ -function startPrune(store: KeyvRedisStore, interval: number): Promise | null { - if (!prunable(store)) return null; +function startPrune(store: object, interval: number): Promise | null { + const tracking = trackingSetOf(store); + if (!tracking) return null; const state = pruneStateOf(store); if (state.running) return state.running; if (Date.now() - state.lastStarted < interval) return null; state.lastStarted = Date.now(); - state.running = pruneTrackingSet(store.redis, store._getNamespace!()).finally(() => { + state.running = pruneTrackingSet(tracking).finally(() => { state.running = null; }); return state.running; @@ -567,7 +594,7 @@ export class RedisCacheProvider extends CacheProvider { if (nodeStore) { // The same keys, found the same way, as on @keyv/redis below: by their // `sets:` names when redis sets are off (#245), otherwise from the - // tracking set. + // tracking set, pruned in the background (#131). const prefix = `${nodeStore.namespace}:`; const unqualified = (key: string) => key.startsWith(prefix) ? key.slice(prefix.length) : key; @@ -578,6 +605,7 @@ export class RedisCacheProvider extends CacheProvider { } const members = await nodeStore.trackedKeys(); + startPrune(nodeStore, PRUNE_INTERVAL); return members.map(unqualified); } From f834063f6be86d27faf10c32fb7dcc0d58009884 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 23:18:52 -0700 Subject: [PATCH 8/8] docs(redis): describe plugin-redis v3, and listing without redis sets The guide still said that with useRedisSets off the provider falls back to a SCAN that misses entries on a cluster. Since #245 it SCANs every master, so purges are complete; the reason left to keep the tracking set is cost, and the warning now says that. A new section covers plugin-redis v3: how the provider detects it, that it runs on `.native` and follows a client swap, that entries survive an upgrade and a rollback, and that v2 - and with it Node 20 - is still supported. --- docs/guide/providers/redis.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/guide/providers/redis.md b/docs/guide/providers/redis.md index b94384d3..22248d24 100644 --- a/docs/guide/providers/redis.md +++ b/docs/guide/providers/redis.md @@ -158,10 +158,10 @@ The plugin passes an explicit lifetime on every write, taken from `strategy.maxA ### Other options -Anything else in `options` is forwarded to [`@keyv/redis`](https://github.com/jaredwray/keyv/tree/main/packages/redis#options) untouched — `namespace`, `useRedisSets`, and so on. +On plugin-redis v2, anything else in `options` is forwarded to [`@keyv/redis`](https://github.com/jaredwray/keyv/tree/main/packages/redis#options) untouched — `namespace`, `useRedisSets`, and so on. On [plugin-redis v3](#plugin-redis-v3) the provider runs on its own store, which honours `useRedisSets` and ignores the rest. ::: warning -Leave `useRedisSets` enabled. The provider relies on the key-tracking set that `@keyv/redis` maintains, both to enumerate keys efficiently and to purge correctly on a cluster. With it disabled the provider falls back to `SCAN`, which is far slower and, on a cluster, incomplete — see below. +Leave `useRedisSets` enabled. The provider lists keys for every pattern purge, and with the key-tracking set that is a single `SMEMBERS`. With it disabled there is no set to read, so the provider finds its entries with `SCAN` instead — on every master of a cluster, so purges are still complete, but each listing walks the whole keyspace of every node, which is far slower on a large or shared database. ::: ## Key prefixes @@ -239,11 +239,21 @@ Two things differ when clustered, and both are handled for you: Without a `MULTI`, a purge and a response being cached for the same key can interleave, so the provider removes a key from the tracking set _before_ deleting the entry. The other way round, a write landing between the two left a live entry outside the set, which no purge could find until it expired. The trade-off is on the failure path: if Redis fails the delete after the entry has been untracked, the entry stays cached, out of reach of purges, until its `maxAge`. -- **Key listing does not use `SCAN`.** `SCAN` addresses one node, so on a cluster it returns only the portion of the keyspace that node holds — a purge built on it silently misses everything on the other nodes. Reading the tracking set avoids that entirely, because the set is one key and therefore complete. +- **Key listing reads the tracking set.** The set is one key, and therefore complete wherever it lives. `SCAN` addresses one node, so on a cluster it returns only the portion of the keyspace that node holds. -::: warning -This is the concrete reason not to disable `useRedisSets` on a cluster: doing so drops the provider onto the `SCAN` fallback, and purges will quietly under-delete. -::: + With `useRedisSets` disabled there is no set, so the provider sends `SCAN` to every master and combines the results ([#245](https://github.com/strapi-community/plugin-rest-cache/pull/245)). Purges are complete either way; the tracking set is just much cheaper to read. + +## plugin-redis v3 + +[`@strapi-community/plugin-redis`](https://github.com/strapi-community/plugin-redis) v3 moves from ioredis to node-redis. The provider supports v2 and v3 alike — its peer range is `^2.0.0 || ^3.0.0-0` — and needs no configuration change for either: + +- **Detection.** The provider checks for v3's `connection` service at bootstrap, by what it can do rather than by version number. If it is there, the provider waits on `whenReady(connection)`, which rejects with the reason rather than hanging when the connection cannot be established. Otherwise it runs exactly as on v2. +- **Client.** On v3 the provider runs on the connection's node-redis client, `.native`. v3 also offers an ioredis-compatible `.client`, but that is deprecated and removed in v4, so the provider only keeps it on `provider.client` for code typed against it and never sends a command through it. +- **Client swap.** v3 replaces the node-redis client of a cluster or sentinel connection that has to reconnect from scratch. The provider reads `.native` for every command instead of keeping the client it started with, so it follows the swap. +- **Same data.** Entries are stored exactly as on v2 — the same key names, the same tracking set, lifetimes in milliseconds — so entries cached before an upgrade are still read and purged after it, and after a rollback. Cluster deletes, key listing without `useRedisSets` and the tracking-set prune behave as described above. +- **Shared connection.** The client belongs to the redis plugin and other plugins may use it. The provider never closes it. + +Node.js 20 is not supported by plugin-redis v3. On Node 20, stay on plugin-redis v2: the provider keeps working on it as before. ## KeyDB