From 9c0675105ddabed4d7a943efea0210ae8fe68413 Mon Sep 17 00:00:00 2001 From: Jason G <41053218+gianghungtien@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:54:01 +0700 Subject: [PATCH 1/2] [miniflare] Fix KV bulk getWithMetadata dropping metadata for falsy values The bulk-get handler in `processKeyValue` decided whether to wrap an entry as `{ value, metadata }` based on the truthiness of the decoded value rather than on whether the key existed. Present keys holding a falsy value - an empty string, or `0`, `false` and `null` when read with `type: "json"` - were returned as bare values, losing their metadata and diverging from the shape the runtime binding expects for present keys. Gate the wrapper on entry presence instead. `storage.get()` returns `null` only for missing keys, and a present entry always carries a `value` ReadableStream, so `obj !== null` is the correct presence check. Missing keys continue to return a bare `null`. Also switch the two preceding `obj?.value` truthiness checks to the same explicit presence check. These are equivalent - a present entry's `value` is always a non-null stream - but make the invariant clear at each site. --- .changeset/shaggy-geese-flow.md | 9 ++++ .../src/workers/kv/namespace.worker.ts | 23 ++++---- .../miniflare/test/plugins/kv/index.spec.ts | 54 +++++++++++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 .changeset/shaggy-geese-flow.md diff --git a/.changeset/shaggy-geese-flow.md b/.changeset/shaggy-geese-flow.md new file mode 100644 index 0000000000..860a10e625 --- /dev/null +++ b/.changeset/shaggy-geese-flow.md @@ -0,0 +1,9 @@ +--- +"miniflare": patch +--- + +Fix KV bulk `getWithMetadata` dropping metadata for present-but-falsy values + +The bulk-get handler decided whether to wrap an entry as `{ value, metadata }` based on the truthiness of the value rather than on whether the key existed. Keys holding a falsy value — an empty string, or `0`, `false` and `null` when reading with `type: "json"` — were returned as bare values, losing their metadata and diverging from the shape the runtime binding expects for present keys. Empty-string values were the most common trigger. + +The check now gates on entry presence, so present keys always keep their `{ value, metadata }` wrapper and only missing keys return a bare `null`. diff --git a/packages/miniflare/src/workers/kv/namespace.worker.ts b/packages/miniflare/src/workers/kv/namespace.worker.ts index 2796c59614..a73a37d61e 100644 --- a/packages/miniflare/src/workers/kv/namespace.worker.ts +++ b/packages/miniflare/src/workers/kv/namespace.worker.ts @@ -79,10 +79,14 @@ async function processKeyValue( type: "text" | "json" = "text", withMetadata = false ) { + // `obj` is `null` only when the key is missing. A present key always has a + // `value` stream, so presence — not value truthiness — decides the response + // shape: present keys are wrapped as `{ value, metadata }`, missing keys are + // returned as a bare `null`. const decoder = new TextDecoder(); let decodedValue = ""; - if (obj?.value) { - for await (const chunk of obj?.value) { + if (obj !== null) { + for await (const chunk of obj.value) { decodedValue += decoder.decode(chunk, { stream: true }); } decodedValue += decoder.decode(); @@ -91,22 +95,23 @@ async function processKeyValue( let val = null; const size = decodedValue.length; try { - val = !obj?.value - ? null - : type === "json" - ? JSON.parse(decodedValue) - : decodedValue; + val = + obj === null + ? null + : type === "json" + ? JSON.parse(decodedValue) + : decodedValue; } catch { throw new HttpError( 400, `At least one of the requested keys corresponds to a non-${type} value` ); } - if (val && withMetadata) { + if (obj !== null && withMetadata) { return [ { value: val, - metadata: obj?.metadata ?? null, + metadata: obj.metadata ?? null, }, size, ]; diff --git a/packages/miniflare/test/plugins/kv/index.spec.ts b/packages/miniflare/test/plugins/kv/index.spec.ts index bb16aed11c..48ac571efc 100644 --- a/packages/miniflare/test/plugins/kv/index.spec.ts +++ b/packages/miniflare/test/plugins/kv/index.spec.ts @@ -253,6 +253,60 @@ test("bulk get: check metadata as string", async ({ expect }) => { expect(result).toEqual(expectedResult); }); +test("bulk get: check metadata for empty string value", async ({ expect }) => { + const { kv } = ctx; + await kv.put("key1", "", { metadata: { testing: true } }); + + const result: any = await kv.getWithMetadata(["key1"]); + const expectedResult: any = new Map([ + ["key1", { value: "", metadata: { testing: true } }], + ]); + expect(result).toEqual(expectedResult); +}); + +test("bulk get: check metadata for falsy json values", async ({ expect }) => { + const { kv } = ctx; + await kv.put("key1", "0", { metadata: { testing: "zero" } }); + await kv.put("key2", "false", { metadata: { testing: "false" } }); + await kv.put("key3", "null", { metadata: { testing: "null" } }); + + const result: any = await kv.getWithMetadata( + ["key1", "key2", "key3"], + "json" + ); + const expectedResult: any = new Map([ + ["key1", { value: 0, metadata: { testing: "zero" } }], + ["key2", { value: false, metadata: { testing: "false" } }], + ["key3", { value: null, metadata: { testing: "null" } }], + ]); + expect(result).toEqual(expectedResult); +}); + +test("bulk get: distinguishes falsy values from missing keys", async ({ + expect, +}) => { + const { kv } = ctx; + await kv.put("key1", "0", { metadata: { testing: true } }); + + // A present key holding a falsy value keeps its `{ value, metadata }` + // wrapper, while a missing key is still returned as a bare `null` + const result: any = await kv.getWithMetadata(["key1", "key2"], "json"); + const expectedResult: any = new Map([ + ["key1", { value: 0, metadata: { testing: true } }], + ["key2", null], + ]); + expect(result).toEqual(expectedResult); +}); + +test("bulk get: returns falsy values without metadata", async ({ expect }) => { + const { kv } = ctx; + await kv.put("key1", ""); + await kv.put("key2", "0"); + + expect(await kv.get(["key1"])).toEqual(new Map([["key1", ""]])); + expect(await kv.get(["key2"], "json")).toEqual(new Map([["key2", 0]])); +}); + test("bulk get: get with metadata for 404", async ({ expect }) => { const { kv } = ctx; From b34f696d20863e77db6a423913f3276592f4a395 Mon Sep 17 00:00:00 2001 From: Jason G <41053218+gianghungtien@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:03:20 +0700 Subject: [PATCH 2/2] Remove `any` assertions from the new bulk get tests Addresses review feedback. The annotations were copied from the surrounding bulk get tests; dropping them and letting inference do the work type-checks cleanly and reads better. Annotating the expected Maps with the real result type isn't an option here: `KVNamespaceGetWithMetadataResult` declares a `cacheStatus` field that the bulk endpoint doesn't return, so the literals would have to carry a property the assertion would then fail on. --- packages/miniflare/test/plugins/kv/index.spec.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/miniflare/test/plugins/kv/index.spec.ts b/packages/miniflare/test/plugins/kv/index.spec.ts index 48ac571efc..787efd1e3b 100644 --- a/packages/miniflare/test/plugins/kv/index.spec.ts +++ b/packages/miniflare/test/plugins/kv/index.spec.ts @@ -257,8 +257,8 @@ test("bulk get: check metadata for empty string value", async ({ expect }) => { const { kv } = ctx; await kv.put("key1", "", { metadata: { testing: true } }); - const result: any = await kv.getWithMetadata(["key1"]); - const expectedResult: any = new Map([ + const result = await kv.getWithMetadata(["key1"]); + const expectedResult = new Map([ ["key1", { value: "", metadata: { testing: true } }], ]); expect(result).toEqual(expectedResult); @@ -270,11 +270,8 @@ test("bulk get: check metadata for falsy json values", async ({ expect }) => { await kv.put("key2", "false", { metadata: { testing: "false" } }); await kv.put("key3", "null", { metadata: { testing: "null" } }); - const result: any = await kv.getWithMetadata( - ["key1", "key2", "key3"], - "json" - ); - const expectedResult: any = new Map([ + const result = await kv.getWithMetadata(["key1", "key2", "key3"], "json"); + const expectedResult = new Map([ ["key1", { value: 0, metadata: { testing: "zero" } }], ["key2", { value: false, metadata: { testing: "false" } }], ["key3", { value: null, metadata: { testing: "null" } }], @@ -290,8 +287,8 @@ test("bulk get: distinguishes falsy values from missing keys", async ({ // A present key holding a falsy value keeps its `{ value, metadata }` // wrapper, while a missing key is still returned as a bare `null` - const result: any = await kv.getWithMetadata(["key1", "key2"], "json"); - const expectedResult: any = new Map([ + const result = await kv.getWithMetadata(["key1", "key2"], "json"); + const expectedResult = new Map([ ["key1", { value: 0, metadata: { testing: true } }], ["key2", null], ]);