Skip to content
Open
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
4 changes: 3 additions & 1 deletion server/_shared/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,9 @@ export async function getHashFieldsBatch(key: string, fields: string[], raw = fa
const values = data[0]?.result;
if (values) {
for (let i = 0; i < fields.length; i++) {
if (values[i]) result.set(fields[i]!, values[i]!);
// Use a null/undefined check rather than a truthy test: "" is a
// legitimate Redis hash value and must be preserved (see #3530).
if (values[i] != null) result.set(fields[i]!, values[i]!);
}
}
} catch (err) {
Expand Down
47 changes: 47 additions & 0 deletions tests/redis-caching.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1414,3 +1414,50 @@ describe('setCachedJson wire shape and failure reporting', { concurrency: 1 }, (
}
});
});

describe('getHashFieldsBatch empty-string handling (#3530)', { concurrency: 1 }, () => {
it('preserves empty-string values, omits null/missing, and retains real strings', async () => {
// Regression: getHashFieldsBatch used a truthy check (`if (values[i])`) that
// dropped valid empty-string hash values. Real Redis hash values are
// allowed to be the empty string, so a caller that round-trips "" will
// silently lose it. The fix switches to a null/undefined check so "" is
// preserved.
const redis = await importRedisFresh();
const restoreEnv = withEnv({
UPSTASH_REDIS_REST_URL: 'https://redis.test',
UPSTASH_REDIS_REST_TOKEN: 'token',
VERCEL_ENV: undefined,
VERCEL_GIT_COMMIT_SHA: undefined,
});
const originalFetch = globalThis.fetch;

let pipelineCalls = 0;
globalThis.fetch = async (_url, init = {}) => {
pipelineCalls += 1;
const pipeline = JSON.parse(String(init.body));
assert.equal(pipeline.length, 1);
assert.equal(pipeline[0][0], 'HMGET');
assert.deepEqual(pipeline[0].slice(2), ['name', 'empty', 'missing', 'real']);
// Upstash HMGET returns an array of (string | null) in field order:
// - real value -> "alice"
// - valid "" -> ""
// - missing key -> null
// - real value -> "ok"
return jsonResponse([{ result: ['alice', '', null, 'ok'] }]);
};

try {
const map = await redis.getHashFieldsBatch('user:42', ['name', 'empty', 'missing', 'real']);
assert.equal(pipelineCalls, 1, 'should batch into one HMGET pipeline call');
assert.equal(map.get('name'), 'alice', 'non-empty value is kept');
assert.equal(map.get('empty'), '', 'empty-string value must be preserved (this is the bug)');
assert.equal(map.has('missing'), false, 'null entries are omitted');
assert.equal(map.get('real'), 'ok', 'non-empty value is kept');
assert.equal(map.size, 3, 'map should contain only the three resolvable fields');
} finally {
globalThis.fetch = originalFetch;
restoreEnv();
}
});
});

Loading