Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/redis-candidate-pack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'uptide': patch
---

Add a candidate redis (node-redis) 4 to 5 and 5 to 6 migration pack, one rule set per major: an upgrade gets the rules of the majors it crosses, so 4 to 6 gets both. 4 to 5 covers `legacyMode`, `commandOptions` and isolation, scan iterators that yield batches, string SCAN cursors, boolean replies that became numbers, enums that became constants, `keepAlive`, `@redis/graph` and internal `dist` imports; 5 to 6 covers the RESP3 client type, `unstableResp3`, and the client defaults 6 changes (RESP3, command timeout, keep-alive delay, maintenance notifications) with a decision per client. Scored against five public upgrades; it stays a candidate until the stricter verified gate lands, since the core 4 to 5 API changes have no real site yet.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ verified with two or more public repositories and no false positive. The contrac
| `next` | 15.x → 16.x | 100% | 85% | sambecker/exif-photo-blog, stoczec/wortkarte | verified |
| `react` | 18.x → 19.x | 100% | 69% | excalidraw/excalidraw, tldraw/tldraw | verified |
| `react-router` | 6.x → 7.x | 100% | 88% | Arize-ai/phoenix, getlago/lago-front | verified |
| `redis` | >=4 <6 → >=5 <7 | n/a | n/a | deanmoses/tacocat-gallery-sam, swoofer/mcp-coordinator, deanmoses/tacocat-gallery-sam, UNN-Devotek/FCM-Fallout-Chat-Mod, tearingItUp786/blog | candidate |
| `@sentry/browser` | 10.x → 11.x | 100% | 100% | Automancer-Ltd/automancer-site, sbomify/sbomify | verified |
| `@sentry/nextjs` | 10.x → 11.x | 100% | 100% | cohstats/coh3-stats, xdoubleu/tools.xdoubleu.com, maximunited/yomu | verified |
| `@sentry/react` | 10.x → 11.x | 100% | 100% | owine/youth-activity-scheduler, successible/cleanslate | verified |
Expand Down
150 changes: 150 additions & 0 deletions packages/core/src/packs/redis/RESEARCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# redis (node-redis) 4 → 5 → 6: evidence and verification limits

The npm package `redis` (node-redis), not ioredis (a separate pack). Two majors, two rule sets in
one pack: an upgrade gets the rules and notes of the majors it crosses. 4.7 → 6.x in one step is
what Dependabot proposes to every 4.x user, and three of the six real upgrades found did exactly
that, so splitting the majors into two packs would have left the most common upgrade with no pack
(and pack lookup is one pack per package). `appliesTo` in `index.ts` gates detection, `ruleOf` and
notes on the crossed majors; `transform` refuses a 4 → 5 rewrite on code already on 5.

## Source ledger

All read 2026-10-14.

| Source | Pin |
|---|---|
| `docs/v4-to-v5.md`, `docs/v5-to-v6.md`, `docs/RESP.md` | node-redis `b64bacbd85642223f0a98c2d91cbcffdb9fb2052` (master) |
| Release notes `redis@5.0.0` (2025-04-30), `redis@6.0.0` (2026-05-28) | GitHub releases |
| Published declarations and builds | @redis/client 1.6.1, 5.12.1, 6.3.0; @redis/search 1.2.0, 5.12.1; @redis/time-series 1.1.0, 5.12.1 (npm tarballs) |
| `redis` manifests 4.7.0 … 6.3.0 | npm registry: dependencies, `engines` |

Release group: every `redis` release depends on `@redis/client`, `@redis/bloom`, `@redis/json`,
`@redis/search` and `@redis/time-series` at its own exact version (4.x: the 1.x line plus
`@redis/graph`; 5.0.0 onward: all five at the `redis` version). `@redis/graph` is not in 5.

Node: 5.0–5.11 `>= 18`, 5.12 `>= 18.19.0`, 6.x `>= 20.0.0`.

## Classified changes

Compiler-visible = a typed consumer gets a diagnostic. "Rewrite" only where the edit is 1:1 for
every surrounding code shape the rule accepts; anything else is assisted.

### 4 → 5

| Change | Compiler | Rule / note | Fix |
|---|---|---|---|
| `legacyMode` removed, `client.legacy()` | yes (excess property) | `legacy-mode` + detect | assisted |
| `commandOptions()` first argument, `isolated`, `executeIsolated`, `isolationPoolOptions` | yes | `command-options` + detect | assisted (`withCommandOptions`, `createClientPool`) |
| scan iterators yield batches | only downstream, in typed code | `scan-iterators`, detect only | rewrite: nested loop; declined when the body `break`s/`continue`s the loop |
| `socket.keepAlive: number` → `keepAlive` + `keepAliveInitialDelay` | yes | `keep-alive` + detect | rewrite for a numeric literal |
| SCAN/HSCAN/SSCAN/ZSCAN cursor is a string | yes when typed; `cursor !== 0` loops forever in JS | `scan-cursor` + detect | assisted |
| boolean replies → number (SETNX, EXPIRE, HEXISTS, SISMEMBER, …) | only where used as boolean | `number-replies` | assisted |
| enums → constants (17), `SearchOptions` → `FtSearchOptions` | yes (TS2305/TS2724) | `renamed-exports` | rewrite: import under the old local name; declined when the file uses a member that changed (`TO_LIST`, `FM_PT`, `AVERAGE`/`MINIMUM`/`MAXIMUM`, the reducers' `RANGE` value, `HIGH`/`MID`) or uses the name as a type |
| `@redis/*/dist/...` internals moved (`RedisCommandArgument`) | yes | `deep-imports`, compiler only (some dist paths still exist) | assisted |
| `@redis/graph` removed | yes | `graph-removed` + detect | assisted, a decision for the owner |
| `quit()`/`disconnect()` deprecated → `close()`/`destroy()` | deprecation | `quit-disconnect` | rewrite |
| SET `EX`/`PX`/`EXAT`/`PXAT`/`KEEPTTL` deprecated → `expiration` (not in the guide; declarations) | deprecation | `set-expiration` | assisted |
| search queries send `DIALECT 2` by default (release notes) | no | note `search-dialect-2`, detects query calls without `DIALECT` | — |
| `execAsPipeline` discards unwritten commands | no | note (decision) | — |
| Node 18 | no | note `node-18` (decision when engines/.nvmrc allow lower) | — |
| Reply map keys no longer camelCased (CLUSTER LINKS, XINFO, FUNCTION STATS, …), `LASTSAVE`/`TIME` types, `ZMPOP`, `XADD INCR`, `GEORADIUSSTORE` renames, JSON path options, `CLUSTER RESET/FAILOVER` option objects, `cluster.multi().addCommand` | yes, rarely used | none: `generic`, the agent works from the compiler and the guide | — |

### 5 → 6

| Change | Compiler | Rule / note | Fix |
|---|---|---|---|
| `RedisClientType` invariant in RESP, RESP3 default (not in the guide; seen in all four upgrades that reached 6) | yes (TS2322/TS2345 naming `RedisClientType<…, 3, …>`) | `client-type` | assisted |
| `unstableResp3`, `unstableResp3Modules` removed | yes | `unstable-resp3` + detect | rewrite: delete the property |
| `commandOptions` removed from sentinel `nodeClientOptions`/`sentinelClientOptions` | yes | `sentinel-node-command-options` | assisted |
| RESP3 default, 5 s command timeout, keepAliveInitialDelay 30 s, maintNotifications `auto` | no | note `client-defaults`: a finding at each inline-options client that leaves one to the default, and a decision | the instructions pin the 5 defaults per client (the ioredis approach) |
| Node 20 | no | note `node-20` | — |
| null-prototype → plain object replies | no | note (decision) | — |
| RESP3-only reply stabilization (FT.SEARCH, XREAD, TS.MRANGE GROUPBY `sources`, …) | under RESP3 only | none: `generic` | — |

Commands called through `any` or in JavaScript: `legacy-mode`, `command-options`, `scan-iterators`,
`keep-alive`, `scan-cursor`, `graph-removed`, `unstable-resp3` and both detecting notes find their
sites syntactically, in files that import `redis` or `@redis/*` only (a `.quit()` or `keepAlive` of
another library is never a site: fixture `other-libraries`).

## Independent ground truth

Drafted with `pnpm packs:truth` from each repository's own compiler and merged upgrade, reviewed by
hand and committed (ffb4f90) before the first score. Kept: the line the compiler reports, else the
line the upgrade changed because of redis. Not kept: lines changed only as a consequence of the
repository's own fix, and deprecations the code already had at the installed version.

| Repository | Hop | Upgrade | Sites |
|---|---|---|---|
| deanmoses/tacocat-gallery-sam (`app`) | 4.6.12 → 5.10.0 | PR #83 | 2 renamed-exports, 2 generic (FT.SEARCH reply union) |
| swoofer/mcp-coordinator | 5.12.1 → 6.2.0 | PR #251 | 2 client-type |
| deanmoses/tacocat-gallery-sam (`app`) | 5.12.1 → 6.2.1 | PR #133 | 2 client-type |
| UNN-Devotek/FCM-Fallout-Chat-Mod (`backend`) | 4.7.1 → 6.0.0 | PR #125 | 5 scan-cursor, 2 client-type, 2 client-defaults (RESP: 2 pinned) |
| tearingItUp786/blog | 4.7.0 → 6.0.0 | #223 + 092ea34 | 5 scan-cursor |

### Screened out

- **FalkorDB/falkordb-ts** 4.7.1 → 5.10.0 (#495), the richest 4 → 5 upgrade found (executeIsolated
×7, isolationPoolOptions ×2, ten `@redis/client/dist` deep imports): in the committed truth, then
removed after the first run, which could not score it. Its `package.json` declares
`workspaces: ["./packages/*"]` matching no directory, and `check` refuses to analyze it. No site
of it was ever scored; an engine follow-up, not a pack change.
- **snapshot-labs/stamp** 4.7.1 → 6.2.1 (#713): requires Node >= 24.15; the install fails on Node 22.
- **semaphore-chat** 5.11 → 6.2.1 (#530): lockfile at the root, project in `backend/`; not
installable by `packs:truth`.
- Bump-only upgrades with no source change (umami, vercel/chatbot and about 30 Dependabot PRs),
3.x starts, latent bugs written on 5.x, and LibreChat (depends on `@redis/client`, not `redis`).
- In the issue's OSS sample, karakeep and appsmith adopted node-redis at 5/6 (no upgrade); twenty,
n8n and budibase are still on 4.x.

## Measured pack test

`uptide pack test redis`, 2026-10-14:

```
rule precision recall tp fp fn
client-defaults 0% 0% 0 4 2
client-type 100% 33% 2 0 4
generic 50% 100% 2 2 0
renamed-exports 100% 100% 2 0 0
scan-cursor 100% 30% 3 0 7
breaking (sites) 100% 61% 11 0 7
behavior notes (sites) 50% 50% 2 2 2
```

Breaking misses, none of them a rule that did not match:

- blog (5 scan-cursor): `refresh-cache.ts` and `utils.ts` reach the client through a local module
and a local `RedisScanClient` type and never import `redis`; `check` reports no compile error
there, and runtime detection reads importing files only.
- FCM and tacocat 5 → 6 (4 client-type): the repositories' own `tsc` reports TS2322 at these lines,
`check` reports nothing there. Both lines already had a different TS2322 at the installed version
(`ReturnType<typeof createClient>` against a configured client) and `check` subtracts errors it
finds at the installed version, which is most likely why (not confirmed in the engine).
- FCM `cursor !== 0` (2): the files import a local wrapper, not `redis`.

Behavior: the two `client-defaults` false positives are clients whose repositories accepted the
RESP3 default without pinning (mcp-coordinator, blog). The defaults did change for them; the ground
truth rule (only what the repository changed) counts them against the note. FCM pins RESP2 in an
options function the detector cannot read (it reads inline literals only).

### Per-rule evidence (for the UPT-48 gate)

Exercised by a real site: `renamed-exports` (its rewrite too), `scan-cursor`, `client-type`.
Rewrites with no real site: `scan-iterators`, `keep-alive`, `quit-disconnect`, `unstable-resp3`;
under UPT-48 they run as assisted. Rules and notes with no real site: `legacy-mode`,
`command-options`, `number-replies`, `deep-imports`, `graph-removed`, `set-expiration`,
`sentinel-node-command-options`, `search-dialect-2` and the decision-only notes.

By hop: the 4 → 5 rule set rests on one 4 → 5 repository (4 sites) and the 4 → 5 sites of the two
4 → 6 repositories (scan-cursor). The core 4 → 5 API changes (`command-options`, `legacy-mode`,
`scan-iterators`, `number-replies`, `keep-alive`) have no real site at all. The 5 → 6 rule set
has `client-type` in four repositories, two of them scored.

## What the engine cannot express

- One pack per package: the two majors are one pack with a per-major gate in the pack itself.
- Fixtures run every rewrite at the lowest versions of the pack's ranges (4.0.0 → 5.0.0), so the
gate in `transform` only refuses rewrites of a major the upgrade already passed.
- A site reported but deliberately not rewritten (a scan loop with `break`) cannot be a fixture
marker; it is a unit test.
- Compile errors in files that use the client without importing the package are not reported (blog).
96 changes: 96 additions & 0 deletions packages/core/src/packs/redis/behavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { minVersion, satisfies, validRange } from '../../fetch/range.js';
import type { BehaviorNote } from '../contract.js';
import type { PackContext } from '../types.js';
import { clientDefaultSites, searchDialectSites, V6_DEFAULTS } from './detect.js';

/** Whether an upgrade from `from` to `to` crosses into `major` (4 → 6 crosses 5 and 6). */
export function crosses(major: number, from: string, to: string): boolean {
return satisfies(from, `<${major}.0.0-0`) && satisfies(to, `>=${major}.0.0-0`);
}

/** Where the repository says it runs Node below `version`: `engines.node`, `.nvmrc`. */
function nodeBelow(context: PackContext, version: string): string | false {
const hits: string[] = [];
for (const { file, text } of context.inventory ?? []) {
let range: string | undefined;
if (/(?:^|\/)package\.json$/.test(file)) {
try {
range = (JSON.parse(text) as { engines?: { node?: string } }).engines?.node;
} catch {
range = undefined;
}
} else if (/(?:^|\/)\.(?:nvmrc|node-version)$/.test(file))
range = text.trim().replace(/^v/, '');
const lowest = range && validRange(range) ? minVersion(range) : undefined;
if (lowest && !satisfies(lowest, `>=${version}`))
hits.push(`\`${file}\` allows Node ${JSON.stringify(range)}`);
}
return hits.length ? hits.join('; ') : false;
}

/** Sources read 2026-10-14 at node-redis b64bacb; see RESEARCH.md for each line. */
export const redisBehavior: BehaviorNote[] = [
// ---- 4 → 5 ----
{
id: 'node-18',
summary: 'redis 5 requires Node.js 18 or later (`engines`: ">= 18", ">= 18.19.0" from 5.12)',
reported: ['decision'],
applies: (context) => {
if (!crosses(5, context.from, context.to) || crosses(6, context.from, context.to))
return false;
const below = nodeBelow(context, '18.19.0');
return below ? `redis 5 requires Node.js >= 18.19.0: ${below}` : false;
},
},
{
id: 'search-dialect-2',
summary:
"redis 5 sends DIALECT 2 with search queries (FT.SEARCH, FT.AGGREGATE, FT.EXPLAIN, FT.PROFILE, FT.SPELLCHECK) that pass no DIALECT, which can change their results where the server default is dialect 1. Pass `DIALECT: 1` to keep 4's queries, or confirm the results under dialect 2",
reported: ['finding'],
severity: 'deprecated',
detect: (text, file) => searchDialectSites(text, file),
},
{
id: 'pipeline-discards-unwritten',
summary:
'`multi().execAsPipeline()` in redis 5 discards the commands it had not written when the socket drops; 4 rejected but still ran them on the server',
reported: ['decision'],
applies: (context) => crosses(5, context.from, context.to),
},
// ---- 5 → 6 ----
{
id: 'client-defaults',
summary: `redis 6 changes client defaults: RESP3 instead of RESP2 (reply shapes of some commands, legacy callback replies), a 5 s command timeout instead of none, keepAliveInitialDelay 30 s instead of 5 s, and maintNotifications "auto" under RESP3. To keep 5's behavior, set ${V6_DEFAULTS.map((d) => `\`${d.keep}\``).join(', ')}`,
reported: ['finding', 'decision'],
severity: 'deprecated',
detect: (text, file) => clientDefaultSites(text, file),
applies: (context) => crosses(6, context.from, context.to),
},
{
id: 'node-20',
summary: 'redis 6 requires Node.js 20 or later (`engines`: ">= 20.0.0")',
reported: ['decision'],
applies: (context) => {
if (!crosses(6, context.from, context.to)) return false;
const below = nodeBelow(context, '20.0.0');
return below ? `redis 6 requires Node.js >= 20: ${below}` : false;
},
},
{
id: 'plain-object-replies',
summary:
'redis 6 returns object replies (HGETALL, CONFIG GET, XRANGE messages, FT.SEARCH documents and others) as plain objects instead of null-prototype objects: only code or tests that check the prototype see it',
reported: ['decision'],
applies: (context) => crosses(6, context.from, context.to),
},
];

/** The major each note belongs to: a 5 → 6 upgrade never reports the 4 → 5 notes. */
export const NOTE_MAJOR: Record<string, 5 | 6> = {
'node-18': 5,
'search-dialect-2': 5,
'pipeline-discards-unwritten': 5,
'client-defaults': 6,
'node-20': 6,
'plain-object-replies': 6,
};
Loading
Loading