From f5c21c00fe9dfad8ac2acb4e185085778eb077d5 Mon Sep 17 00:00:00 2001 From: BlankParticle Date: Tue, 6 Oct 2026 15:44:01 +0530 Subject: [PATCH] feat(cloudflare): add KV Instant namespaces with local emulation --- .../src/Cloudflare/KV/InstantNamespace.ts | 316 ++++++++++++++++++ .../Cloudflare/KV/InstantNamespaceLocal.ts | 6 + .../Cloudflare/KV/InstantNamespaceTypes.ts | 58 ++++ .../src/Cloudflare/KV/LocalKVGateway.ts | 9 +- .../alchemy/src/Cloudflare/KV/Namespace.ts | 25 +- .../src/Cloudflare/KV/NamespaceBinding.ts | 8 +- .../src/Cloudflare/KV/NamespaceHttp.ts | 3 +- .../src/Cloudflare/KV/NamespaceLocal.ts | 3 +- .../src/Cloudflare/KV/NamespaceTypes.ts | 10 + .../src/Cloudflare/KV/ReadNamespace.ts | 23 +- .../src/Cloudflare/KV/ReadWriteNamespace.ts | 23 +- .../src/Cloudflare/KV/WriteNamespace.ts | 23 +- packages/alchemy/src/Cloudflare/KV/index.ts | 2 + packages/alchemy/src/Cloudflare/Providers.ts | 2 + .../src/Cloudflare/Workers/InferEnv.ts | 120 +++---- .../src/Cloudflare/Workers/RuntimeBindings.ts | 7 +- .../Cloudflare/Workers/WorkerAsyncBindings.ts | 3 +- .../src/Cloudflare/Workers/WorkerBinding.ts | 2 + .../KV/InstantNamespace.local.test.ts | 206 ++++++++++++ .../Cloudflare/KV/InstantNamespace.test.ts | 310 +++++++++++++++++ .../KV/InstantNamespace.types.test.ts | 56 ++++ .../KV/fixtures/kv-instant-local-worker.ts | 55 +++ .../core/bindings/kv-namespace/KvNamespace.ts | 2 +- .../kv-namespace/KvNamespace.worker.ts | 25 +- .../kv-namespace/KvNamespaceOptions.shared.ts | 6 + .../core/test/bindings/KvNamespace.test.ts | 30 ++ 26 files changed, 1252 insertions(+), 81 deletions(-) create mode 100644 packages/alchemy/src/Cloudflare/KV/InstantNamespace.ts create mode 100644 packages/alchemy/src/Cloudflare/KV/InstantNamespaceLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/KV/InstantNamespaceTypes.ts create mode 100644 packages/alchemy/test/Cloudflare/KV/InstantNamespace.local.test.ts create mode 100644 packages/alchemy/test/Cloudflare/KV/InstantNamespace.test.ts create mode 100644 packages/alchemy/test/Cloudflare/KV/InstantNamespace.types.test.ts create mode 100644 packages/alchemy/test/Cloudflare/KV/fixtures/kv-instant-local-worker.ts diff --git a/packages/alchemy/src/Cloudflare/KV/InstantNamespace.ts b/packages/alchemy/src/Cloudflare/KV/InstantNamespace.ts new file mode 100644 index 0000000000..c97377e8dc --- /dev/null +++ b/packages/alchemy/src/Cloudflare/KV/InstantNamespace.ts @@ -0,0 +1,316 @@ +import * as kv from "@distilled.cloud/cloudflare/kv"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { isResolved } from "../../Diff.ts"; +import * as ProviderLayer from "../../Local/ProviderLayer.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { isResourceOfType, Resource } from "../../Resource.ts"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import { localAccountId } from "../LocalAccount.ts"; +import type { Providers } from "../Providers.ts"; +import { + createInstantNamespaceLocalId, + isInstantNamespaceLocalId, +} from "./InstantNamespaceLocal.ts"; +import { NamespaceModeMismatch } from "./NamespaceTypes.ts"; + +export const isInstantNamespace = (value: unknown): value is InstantNamespace => + isResourceOfType(value, "Cloudflare.KV.InstantNamespace"); + +export type InstantNamespaceProps = { + /** + * A human-readable string name for the namespace. + * If omitted, a unique name will be generated. + * @default ${app}-${stage}-${id} + */ + title?: string; +}; + +export type InstantNamespace = Resource< + "Cloudflare.KV.InstantNamespace", + InstantNamespaceProps, + { + /** Namespace storage mode, fixed at creation. */ + mode: "instant"; + /** Human-readable namespace title. */ + title: string; + /** Cloudflare namespace identifier, stable across title updates. */ + namespaceId: string; + /** Whether keys written in URLs are URL-decoded before storage. */ + supportsUrlEncoding: boolean | undefined; + /** Account that owns the namespace. */ + accountId: string; + }, + never, + Providers +>; + +/** + * A Cloudflare Workers KV Instant namespace, powered by Quicksilver. + * + * Requires private beta access. Uses the same Worker binding as classic KV, + * with fast global replication and no cold reads. Namespace mode is fixed at + * creation; switching between Namespace and InstantNamespace replaces it. + * + * Metadata is unsupported. Use get instead of getWithMetadata, and do not + * pass metadata to put. Listing returns all matching keys without pagination. + * The beta limits namespaces to 1 MB and 10,000 pairs, keys to 300 bytes, + * and writes to one per namespace per second. + * + * Storage and put/delete/list operations cost substantially more than classic + * KV; reads cost less. See https://blog.cloudflare.com/workers-kv-instant/ + * for pricing and private beta availability. + * + * ### Creating an Instant Namespace + * **Example:** Application configuration + * ```typescript + * const config = yield* Cloudflare.KV.InstantNamespace("Config"); + * ``` + * + * ### Binding to a Worker + * **Example:** Read configuration using the existing KV binding + * ```typescript + * const client = yield* Cloudflare.KV.ReadNamespace(config); + * const enabled = yield* client.get("feature-enabled"); + * ``` + * + * Provide ReadNamespaceBinding on the Worker runtime. WriteNamespace and + * ReadWriteNamespace also accept Instant namespaces, as do their Http and + * Local capability implementations. + * + * During `alchemy dev`, the namespace uses persistent local KV storage without + * cloud credentials or beta access. Local mode rejects metadata and lists all + * matching keys in one response. Local keys are limited to 300 UTF-8 bytes. + * Namespace storage quotas, key-count limits, global replication, billing, + * and write throttling are not emulated. Use Alchemy.remote() + * to opt into a live namespace during development (requires beta access). + * + * @resource + * @product KV + * @category Storage & Databases + */ +export const InstantNamespace = Resource("Cloudflare.KV.InstantNamespace"); + +const ProviderLive = () => + Provider.succeed(InstantNamespace, { + stables: ["namespaceId", "accountId"], + diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) { + const { accountId } = yield* yield* CloudflareEnvironment; + if (output && output.mode !== "instant") { + return { + action: "replace", + // Titles are unique across modes; an unresolved title may also reuse it. + deleteFirst: !isResolved(news) || news.title === output.title, + } as const; + } + if (!isResolved(news)) return undefined; + if ((output?.accountId ?? accountId) !== accountId) { + return { action: "replace" } as const; + } + const oldTitle = output?.title ?? (yield* createTitle(id, olds.title)); + // Auto-generated titles are engine-owned: the deployed title stays + // authoritative even if the generator would title this id differently + // today. Only an explicit user-provided title can force a rename. + const title = news.title ?? oldTitle; + if (title !== oldTitle) { + return { action: "update" } as const; + } + }), + reconcile: Effect.fn(function* ({ id, news = {}, output }) { + const { accountId } = yield* yield* CloudflareEnvironment; + const title = news.title ?? output?.title ?? (yield* createTitle(id, undefined)); + const acct = output?.accountId ?? accountId; + + // Observe — re-fetch the cached namespace; fall back to a title + // scan so we recover from out-of-band deletes or partial state + // persistence failures. + let observed: + | { + id: string; + title: string; + supportsUrlEncoding?: boolean | null | undefined; + mode?: "instant" | null; + } + | undefined; + if (output?.namespaceId) { + observed = yield* kv + .getNamespace({ + accountId: acct, + namespaceId: output.namespaceId, + }) + .pipe(Effect.catchTag("NamespaceNotFound", () => Effect.succeed(undefined))); + } + + // Ensure — create if missing. Cloudflare returns + // `NamespaceTitleAlreadyExists` on a concurrent create; tolerate + // by adopting the namespace with the same title. + if (!observed) { + observed = yield* kv + .createNamespace({ + accountId: acct, + title, + mode: "instant", + }) + .pipe( + Effect.catchTag("NamespaceTitleAlreadyExists", (error) => + Effect.gen(function* () { + const match = yield* findNamespaceByTitle(title); + if (match) { + return match; + } + return yield* Effect.fail(error); + }), + ), + ); + } + + if (observed.mode !== "instant") { + return yield* new NamespaceModeMismatch({ namespaceId: observed.id, expected: "instant" }); + } + + // Sync — KV's only mutable property is the title. Rename only + // when the observed title drifts from desired so we avoid + // unnecessary API calls on every reconcile. + let namespaceId = observed.id; + let resolvedTitle = observed.title; + let supportsUrlEncoding = observed.supportsUrlEncoding ?? undefined; + if (observed.title !== title) { + const renamed = yield* kv.updateNamespace({ + accountId: acct, + namespaceId: observed.id, + title, + }); + namespaceId = renamed.id; + resolvedTitle = renamed.title; + supportsUrlEncoding = renamed.supportsUrlEncoding ?? undefined; + } + + return { + mode: "instant" as const, + title: resolvedTitle, + namespaceId, + supportsUrlEncoding, + accountId: acct, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* kv + .deleteNamespace({ + accountId: output.accountId, + namespaceId: output.namespaceId, + }) + .pipe(Effect.catchTag("NamespaceNotFound", () => Effect.void)); + }), + list: Effect.fn(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + return yield* kv.listNamespaces.pages({ accountId }).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.result ?? []) + .filter((ns) => ns.mode === "instant") + .map((ns) => ({ + mode: "instant" as const, + title: ns.title, + namespaceId: ns.id, + supportsUrlEncoding: ns.supportsUrlEncoding ?? undefined, + accountId, + })), + ), + ), + ); + }), + read: Effect.fn(function* ({ id, olds, output }) { + const { accountId } = yield* yield* CloudflareEnvironment; + if (output?.namespaceId) { + return yield* kv + .getNamespace({ + accountId: output.accountId, + namespaceId: output.namespaceId, + }) + .pipe( + Effect.flatMap((namespace) => + namespace.mode !== "instant" + ? Effect.fail( + new NamespaceModeMismatch({ namespaceId: namespace.id, expected: "instant" }), + ) + : Effect.succeed({ + mode: "instant" as const, + title: namespace.title, + namespaceId: namespace.id, + supportsUrlEncoding: namespace.supportsUrlEncoding ?? undefined, + accountId: output.accountId, + }), + ), + Effect.catchTag("NamespaceNotFound", () => Effect.succeed(undefined)), + ); + } + const title = yield* createTitle(id, olds?.title); + const match = yield* findNamespaceByTitle(title); + if (match) { + return { + mode: "instant" as const, + title: match.title, + namespaceId: match.id, + supportsUrlEncoding: match.supportsUrlEncoding ?? undefined, + accountId, + }; + } + return undefined; + }), + }); + +/** Persistent local workerd namespace; no Cloudflare API calls. */ +const ProviderLocal = () => + Provider.succeed(InstantNamespace, { + stables: ["namespaceId", "accountId"], + diff: Effect.fn(function* ({ output }) { + const accountId = yield* localAccountId; + if (output && !isInstantNamespaceLocalId(output.namespaceId)) { + return { action: "replace" } as const; + } + if (output && output.accountId !== accountId) return { action: "replace" } as const; + }), + read: Effect.fn(function* ({ output }) { + return output ?? undefined; + }), + reconcile: Effect.fn(function* ({ id, news = {}, output }) { + const accountId = yield* localAccountId; + return { + mode: "instant" as const, + title: news.title ?? output?.title ?? (yield* createTitle(id, undefined)), + namespaceId: output?.namespaceId ?? createInstantNamespaceLocalId(), + supportsUrlEncoding: true, + accountId: output?.accountId ?? accountId, + }; + }), + delete: Effect.fn(function* () { + // Matches classic local KV: orphaned storage is reclaimed with .alchemy. + }), + }); + +export const InstantNamespaceProvider = () => + ProviderLayer.dual(InstantNamespace, { + local: () => ProviderLocal(), + live: () => ProviderLive(), + }); + +const createTitle = (id: string, title: string | undefined) => + Effect.gen(function* () { + return title ?? (yield* createPhysicalName({ id })); + }); + +// Cloudflare's `listNamespaces` accepts no title/prefix filter, so +// adoption-by-name has to scan every page. Use the paginated +// `.items` stream off the un-yielded operation method (yielding +// `kv.listNamespaces` collapses it to a single-page call). +const findNamespaceByTitle = Effect.fn(function* (title: string) { + const { accountId } = yield* yield* CloudflareEnvironment; + return yield* kv.listNamespaces.items({ accountId }).pipe( + Stream.filter((ns) => ns.title === title && ns.mode === "instant"), + Stream.runHead, + Effect.map(Option.getOrUndefined), + ); +}); diff --git a/packages/alchemy/src/Cloudflare/KV/InstantNamespaceLocal.ts b/packages/alchemy/src/Cloudflare/KV/InstantNamespaceLocal.ts new file mode 100644 index 0000000000..5f962206dc --- /dev/null +++ b/packages/alchemy/src/Cloudflare/KV/InstantNamespaceLocal.ts @@ -0,0 +1,6 @@ +// Carry Instant mode in the persisted local identity so Worker bindings and +// per-operation platform proxies select the same simulator behavior after reload. +const prefix = "dev:kv-instant:"; + +export const createInstantNamespaceLocalId = () => `${prefix}${crypto.randomUUID()}`; +export const isInstantNamespaceLocalId = (id: string) => id.startsWith(prefix); diff --git a/packages/alchemy/src/Cloudflare/KV/InstantNamespaceTypes.ts b/packages/alchemy/src/Cloudflare/KV/InstantNamespaceTypes.ts new file mode 100644 index 0000000000..7edfb90cea --- /dev/null +++ b/packages/alchemy/src/Cloudflare/KV/InstantNamespaceTypes.ts @@ -0,0 +1,58 @@ +import type * as runtime from "@cloudflare/workers-types"; +import type * as Effect from "effect/Effect"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { NamespaceError } from "./NamespaceTypes.ts"; +import type { ReadNamespaceClient } from "./ReadNamespace.ts"; +import type { WriteNamespaceClient } from "./WriteNamespace.ts"; + +/** Instant lists all matching keys in one request; pagination is unsupported. */ +export type InstantNamespaceListOptions = { + prefix?: string; + cursor?: never; + limit?: never; +}; + +/** Instant supports the KV write options except metadata. */ +export type InstantNamespacePutOptions = Omit & { + metadata?: never; +}; + +/** KV Instant read client. Use get: Instant does not store metadata. */ +export interface ReadInstantNamespaceClient extends Pick< + ReadNamespaceClient, + "get" +> { + /** Returns all matching keys without pagination. */ + list( + options?: InstantNamespaceListOptions, + ): Effect.Effect, NamespaceError, RuntimeContext>; +} + +/** KV Instant write client; metadata cannot be attached to values. */ +export interface WriteInstantNamespaceClient extends Pick< + WriteNamespaceClient, + "delete" +> { + put( + key: Key, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + options?: InstantNamespacePutOptions, + ): Effect.Effect; +} + +/** Combined read/write access to KV Instant. */ +export interface ReadWriteInstantNamespaceClient + extends ReadInstantNamespaceClient, WriteInstantNamespaceClient {} + +/** Native Worker env binding with Instant's metadata and pagination limits. */ +export interface InstantNamespaceBindingClient extends Omit< + runtime.KVNamespace, + "put" | "list" | "getWithMetadata" +> { + put( + key: string, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + options?: InstantNamespacePutOptions, + ): Promise; + list(options?: InstantNamespaceListOptions): Promise>; +} diff --git a/packages/alchemy/src/Cloudflare/KV/LocalKVGateway.ts b/packages/alchemy/src/Cloudflare/KV/LocalKVGateway.ts index b70395c82f..df860b4390 100644 --- a/packages/alchemy/src/Cloudflare/KV/LocalKVGateway.ts +++ b/packages/alchemy/src/Cloudflare/KV/LocalKVGateway.ts @@ -23,6 +23,7 @@ import type * as runtime from "@cloudflare/workers-types"; import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import { gatewayName, localGatewayRuntime } from "../LocalGateway.ts"; +import { isInstantNamespaceLocalId } from "./InstantNamespaceLocal.ts"; import type { makeKVNamespaceHelpers } from "./NamespaceBinding.ts"; import { NamespaceError } from "./NamespaceTypes.ts"; @@ -57,7 +58,13 @@ export const makeProxyKVNamespaceHelpers = ( Effect.gen(function* () { const proxy = yield* open({ name: gatewayName("alchemy-kv-gateway", namespaceId), - bindings: [KvNamespace.local({ binding: "KV", id: namespaceId })], + bindings: [ + KvNamespace.local({ + binding: "KV", + id: namespaceId, + mode: isInstantNamespaceLocalId(namespaceId) ? "instant" : undefined, + }), + ], }); const kv = (proxy.env as Record).KV as runtime.KVNamespace; return yield* tryPromise(() => fn(kv)); diff --git a/packages/alchemy/src/Cloudflare/KV/Namespace.ts b/packages/alchemy/src/Cloudflare/KV/Namespace.ts index ed69f8f43d..2bb0ba4152 100644 --- a/packages/alchemy/src/Cloudflare/KV/Namespace.ts +++ b/packages/alchemy/src/Cloudflare/KV/Namespace.ts @@ -11,6 +11,7 @@ import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import { localAccountId } from "../LocalAccount.ts"; import { generateLocalId } from "../LocalRuntime.ts"; import type { Providers } from "../Providers.ts"; +import { isInstantNamespaceLocalId } from "./InstantNamespaceLocal.ts"; export const isNamespace = (value: unknown): value is Namespace => isResourceOfType(value, "Cloudflare.KV.Namespace"); @@ -80,6 +81,13 @@ export const ProviderLive = () => stables: ["namespaceId", "accountId"], diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) { const { accountId } = yield* yield* CloudflareEnvironment; + if (output && "mode" in output && output.mode === "instant") { + return { + action: "replace", + // Titles are unique across modes; an unresolved title may also reuse it. + deleteFirst: !isResolved(news) || news.title === output.title, + } as const; + } if (!isResolved(news)) return undefined; if ((output?.accountId ?? accountId) !== accountId) { return { action: "replace" } as const; @@ -179,12 +187,14 @@ export const ProviderLive = () => Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => - (page.result ?? []).map((ns) => ({ - title: ns.title, - namespaceId: ns.id, - supportsUrlEncoding: ns.supportsUrlEncoding ?? undefined, - accountId, - })), + (page.result ?? []) + .filter((ns) => ns.mode !== "instant") + .map((ns) => ({ + title: ns.title, + namespaceId: ns.id, + supportsUrlEncoding: ns.supportsUrlEncoding ?? undefined, + accountId, + })), ), ), ); @@ -233,6 +243,7 @@ export const ProviderLocal = () => diff: Effect.fn(function* ({ news = {}, output }) { const accountId = yield* localAccountId; if (!output?.namespaceId) return { action: "update" } as const; + if (isInstantNamespaceLocalId(output.namespaceId)) return { action: "replace" } as const; if (!isResolved(news)) return undefined; if (output.accountId !== accountId) { return { action: "replace" } as const; @@ -277,7 +288,7 @@ const createTitle = (id: string, title: string | undefined) => const findNamespaceByTitle = Effect.fn(function* (title: string) { const { accountId } = yield* yield* CloudflareEnvironment; return yield* kv.listNamespaces.items({ accountId }).pipe( - Stream.filter((ns) => ns.title === title), + Stream.filter((ns) => ns.title === title && ns.mode !== "instant"), Stream.runHead, Effect.map(Option.getOrUndefined), ); diff --git a/packages/alchemy/src/Cloudflare/KV/NamespaceBinding.ts b/packages/alchemy/src/Cloudflare/KV/NamespaceBinding.ts index 5d7787b45d..a50fe99abd 100644 --- a/packages/alchemy/src/Cloudflare/KV/NamespaceBinding.ts +++ b/packages/alchemy/src/Cloudflare/KV/NamespaceBinding.ts @@ -1,6 +1,7 @@ import type * as runtime from "@cloudflare/workers-types"; import * as Effect from "effect/Effect"; import { Worker, WorkerEnvironment } from "../Workers/Worker.ts"; +import type { InstantNamespace } from "./InstantNamespace.ts"; import type { Namespace } from "./Namespace.ts"; import { NamespaceError } from "./NamespaceTypes.ts"; @@ -20,7 +21,7 @@ export const makeKVNamespaceBinding = (options: { const env = yield* WorkerEnvironment; const host = yield* Worker; - return Effect.fn(function* (namespace: Namespace) { + return Effect.fn(function* (namespace: Namespace | InstantNamespace) { if (!globalThis.__ALCHEMY_RUNTIME__) { yield* host.bind`${namespace}`({ bindings: [ @@ -38,7 +39,10 @@ export const makeKVNamespaceBinding = (options: { }); /** Primitives shared by the read and write halves of the binding client. */ -export const makeKVNamespaceHelpers = (env: Record, namespace: Namespace) => { +export const makeKVNamespaceHelpers = ( + env: Record, + namespace: Namespace | InstantNamespace, +) => { const raw = Effect.sync( // Lazy — the WorkerEnvironment binding is not populated until runtime. () => (env as Record)[namespace.LogicalId]!, diff --git a/packages/alchemy/src/Cloudflare/KV/NamespaceHttp.ts b/packages/alchemy/src/Cloudflare/KV/NamespaceHttp.ts index 9547060f2b..438ad34ab4 100644 --- a/packages/alchemy/src/Cloudflare/KV/NamespaceHttp.ts +++ b/packages/alchemy/src/Cloudflare/KV/NamespaceHttp.ts @@ -8,6 +8,7 @@ import type { PermissionGroupRef } from "../ApiToken/Common.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Credentials } from "../Credentials.ts"; import { authorizeWith } from "../HttpClientUtils.ts"; +import type { InstantNamespace } from "./InstantNamespace.ts"; import type { Namespace } from "./Namespace.ts"; import { NamespaceError } from "./NamespaceTypes.ts"; @@ -27,7 +28,7 @@ export const makeHttpKVNamespaceBinding = (options: { const self = yield* Self; const env = yield* CloudflareEnvironment; - return Effect.fn(function* (namespace: Namespace) { + return Effect.fn(function* (namespace: Namespace | InstantNamespace) { const { accountId } = yield* env; const token = yield* Token(`${self.LogicalId}Token`); if (!globalThis.__ALCHEMY_RUNTIME__) { diff --git a/packages/alchemy/src/Cloudflare/KV/NamespaceLocal.ts b/packages/alchemy/src/Cloudflare/KV/NamespaceLocal.ts index bdbb81359b..8416808e26 100644 --- a/packages/alchemy/src/Cloudflare/KV/NamespaceLocal.ts +++ b/packages/alchemy/src/Cloudflare/KV/NamespaceLocal.ts @@ -3,6 +3,7 @@ import type * as HttpClient from "effect/http/HttpClient"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Credentials } from "../Credentials.ts"; import { dispatchByMode } from "../LocalGateway.ts"; +import type { InstantNamespace } from "./InstantNamespace.ts"; import { makeProxyKVNamespaceHelpers } from "./LocalKVGateway.ts"; import type { Namespace } from "./Namespace.ts"; import type { makeKVNamespaceHelpers } from "./NamespaceBinding.ts"; @@ -47,7 +48,7 @@ export const makeLocalKVNamespaceBinding = (options: { // enumerable here. const ambient = yield* Effect.context(); - return Effect.fn(function* (namespace: Namespace) { + return Effect.fn(function* (namespace: Namespace | InstantNamespace) { // Deferred accessor — resolves the namespaceId against the tracker at // apply time (in an Action, that's the engine's resolve context). const namespaceId = yield* namespace.namespaceId; diff --git a/packages/alchemy/src/Cloudflare/KV/NamespaceTypes.ts b/packages/alchemy/src/Cloudflare/KV/NamespaceTypes.ts index fbdc044b41..16f9aba285 100644 --- a/packages/alchemy/src/Cloudflare/KV/NamespaceTypes.ts +++ b/packages/alchemy/src/Cloudflare/KV/NamespaceTypes.ts @@ -4,3 +4,13 @@ export class NamespaceError extends Data.TaggedError("NamespaceError")<{ message: string; cause: Error; }> {} + +/** The API returned a namespace with a different storage mode. */ +export class NamespaceModeMismatch extends Data.TaggedError("NamespaceModeMismatch")<{ + namespaceId: string; + expected: "instant" | "classic"; +}> { + get message() { + return `Namespace ${this.namespaceId} does not have the expected ${this.expected} mode`; + } +} diff --git a/packages/alchemy/src/Cloudflare/KV/ReadNamespace.ts b/packages/alchemy/src/Cloudflare/KV/ReadNamespace.ts index a796de9071..0d8bfee54a 100644 --- a/packages/alchemy/src/Cloudflare/KV/ReadNamespace.ts +++ b/packages/alchemy/src/Cloudflare/KV/ReadNamespace.ts @@ -1,7 +1,10 @@ import type * as runtime from "@cloudflare/workers-types"; import * as Effect from "effect/Effect"; import * as Binding from "../../Binding.ts"; +import type { Input } from "../../Input.ts"; import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { InstantNamespace } from "./InstantNamespace.ts"; +import type { ReadInstantNamespaceClient } from "./InstantNamespaceTypes.ts"; import type { Namespace } from "./Namespace.ts"; import type { NamespaceError } from "./NamespaceTypes.ts"; @@ -13,6 +16,9 @@ import type { NamespaceError } from "./NamespaceTypes.ts"; * Context tag, its type, and the callable — * `yield* Cloudflare.KV.ReadNamespace(ns)`. * + * Also accepts InstantNamespace, returning a client without metadata or + * paginated listing options. + * * @binding * @product KV * @category Storage & Databases @@ -20,8 +26,21 @@ import type { NamespaceError } from "./NamespaceTypes.ts"; export interface ReadNamespace extends Binding.Service< ReadNamespace, "Cloudflare.KV.ReadNamespace", - (namespace: Namespace) => Effect.Effect -> {} + { + (namespace: InstantNamespace): Effect.Effect; + (namespace: Namespace): Effect.Effect; + (namespace: Namespace | InstantNamespace): Effect.Effect; + } +> { + ( + namespace: Input | Effect.Effect, + ): Effect.Effect; + ( + namespace: + | Input + | Effect.Effect, + ): Effect.Effect; +} export const ReadNamespace = Binding.Service("Cloudflare.KV.ReadNamespace"); diff --git a/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespace.ts b/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespace.ts index 2d119b0efe..645e81b3f9 100644 --- a/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespace.ts +++ b/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespace.ts @@ -1,5 +1,8 @@ import * as Effect from "effect/Effect"; import * as Binding from "../../Binding.ts"; +import type { Input } from "../../Input.ts"; +import type { InstantNamespace } from "./InstantNamespace.ts"; +import type { ReadWriteInstantNamespaceClient } from "./InstantNamespaceTypes.ts"; import type { Namespace } from "./Namespace.ts"; import type { ReadNamespaceClient } from "./ReadNamespace.ts"; import type { WriteNamespaceClient } from "./WriteNamespace.ts"; @@ -13,6 +16,9 @@ import type { WriteNamespaceClient } from "./WriteNamespace.ts"; * binding's Context tag, its type, and the callable — * `yield* Cloudflare.KV.ReadWriteNamespace(ns)`. * + * Also accepts InstantNamespace, returning a client without metadata or + * paginated listing options. + * * @binding * @product KV * @category Storage & Databases @@ -20,8 +26,21 @@ import type { WriteNamespaceClient } from "./WriteNamespace.ts"; export interface ReadWriteNamespace extends Binding.Service< ReadWriteNamespace, "Cloudflare.KVNamespace.ReadWrite", - (namespace: Namespace) => Effect.Effect -> {} + { + (namespace: InstantNamespace): Effect.Effect; + (namespace: Namespace): Effect.Effect; + (namespace: Namespace | InstantNamespace): Effect.Effect; + } +> { + ( + namespace: Input | Effect.Effect, + ): Effect.Effect; + ( + namespace: + | Input + | Effect.Effect, + ): Effect.Effect; +} export const ReadWriteNamespace = Binding.Service( "Cloudflare.KVNamespace.ReadWrite", diff --git a/packages/alchemy/src/Cloudflare/KV/WriteNamespace.ts b/packages/alchemy/src/Cloudflare/KV/WriteNamespace.ts index 42d6bcffbb..97a784e70a 100644 --- a/packages/alchemy/src/Cloudflare/KV/WriteNamespace.ts +++ b/packages/alchemy/src/Cloudflare/KV/WriteNamespace.ts @@ -1,6 +1,9 @@ import * as Effect from "effect/Effect"; import * as Binding from "../../Binding.ts"; +import type { Input } from "../../Input.ts"; import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { InstantNamespace } from "./InstantNamespace.ts"; +import type { WriteInstantNamespaceClient } from "./InstantNamespaceTypes.ts"; import type { Namespace } from "./Namespace.ts"; import type { NamespaceError } from "./NamespaceTypes.ts"; @@ -12,6 +15,9 @@ import type { NamespaceError } from "./NamespaceTypes.ts"; * Context tag, its type, and the callable — * `yield* Cloudflare.KV.WriteNamespace(ns)`. * + * Also accepts InstantNamespace, returning a client without metadata or + * paginated listing options. + * * @binding * @product KV * @category Storage & Databases @@ -19,8 +25,21 @@ import type { NamespaceError } from "./NamespaceTypes.ts"; export interface WriteNamespace extends Binding.Service< WriteNamespace, "Cloudflare.KV.WriteNamespace", - (namespace: Namespace) => Effect.Effect -> {} + { + (namespace: InstantNamespace): Effect.Effect; + (namespace: Namespace): Effect.Effect; + (namespace: Namespace | InstantNamespace): Effect.Effect; + } +> { + ( + namespace: Input | Effect.Effect, + ): Effect.Effect; + ( + namespace: + | Input + | Effect.Effect, + ): Effect.Effect; +} export const WriteNamespace = Binding.Service("Cloudflare.KV.WriteNamespace"); diff --git a/packages/alchemy/src/Cloudflare/KV/index.ts b/packages/alchemy/src/Cloudflare/KV/index.ts index 5ffce21cf0..80fc4c3bbb 100644 --- a/packages/alchemy/src/Cloudflare/KV/index.ts +++ b/packages/alchemy/src/Cloudflare/KV/index.ts @@ -1,3 +1,5 @@ +export * from "./InstantNamespaceTypes.ts"; +export * from "./InstantNamespace.ts"; export * from "./Namespace.ts"; export * from "./NamespaceTypes.ts"; export * from "./ReadNamespace.ts"; diff --git a/packages/alchemy/src/Cloudflare/Providers.ts b/packages/alchemy/src/Cloudflare/Providers.ts index 7bd2511fb6..cf0fc311f7 100644 --- a/packages/alchemy/src/Cloudflare/Providers.ts +++ b/packages/alchemy/src/Cloudflare/Providers.ts @@ -258,6 +258,7 @@ export const providers = () => KeylessCertificate.KeylessCertificate, KeyPair, KV.Namespace, + KV.InstantNamespace, LeakedCredentialCheck.LeakedCredentialCheck, LeakedCredentialCheck.LeakedCredentialDetection, List.List, @@ -513,6 +514,7 @@ export const providers = () => Intel.IndicatorFeedProvider(), KeylessCertificate.KeylessCertificateProvider(), KV.NamespaceProvider(), + KV.InstantNamespaceProvider(), LeakedCredentialCheck.LeakedCredentialCheckProvider(), LeakedCredentialCheck.LeakedCredentialDetectionProvider(), List.ListProvider(), diff --git a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts index bbb0036f3c..ab882e9f27 100644 --- a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts +++ b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts @@ -93,67 +93,69 @@ export type GetBindingType = ? D1Database : T extends R2.Bucket ? R2Bucket - : T extends KV.Namespace - ? KVNamespace - : T extends DispatchNamespaceResource - ? DispatchNamespace - : T extends Queues.Queue - ? Queue - : T extends AI.Gateway - ? Ai - : T extends AIBinding + : T extends KV.InstantNamespace + ? KV.InstantNamespaceBindingClient + : T extends KV.Namespace + ? KVNamespace + : T extends DispatchNamespaceResource + ? DispatchNamespace + : T extends Queues.Queue + ? Queue + : T extends AI.Gateway ? Ai - : T extends AI.Search - ? AiSearchInstance - : T extends AI.SearchNamespace - ? AiSearchNamespace - : T extends Email.SendEmail - ? SendEmail - : T extends AnalyticsEngine.Dataset - ? AnalyticsEngineDataset - : T extends ArtifactsNs.Namespace - ? Artifacts - : T extends RateLimitBinding - ? RateLimit - : T extends SecretKeyBinding - ? CryptoKey - : T extends ImagesNs.ImagesBinding - ? ImagesBinding - : T extends BrowserBinding - ? BrowserRun - : // The ambient global `StreamBinding` from - // @cloudflare/workers-types (the alchemy binding value - // type of the same name is only reachable as - // `StreamNs.StreamBinding`). - T extends StreamNs.StreamBinding - ? StreamBinding - : T extends HyperdriveNs.Connection - ? Hyperdrive - : T extends VersionMetadataBinding - ? WorkerVersionMetadata - : T extends WorkerLoaderResource - ? WorkerLoader - : T extends - | WorkflowLike - | WorkflowBinding - ? Workflow - : T extends DurableObjectLike - ? DurableObjectNamespace< - Exclude - > - : T extends - | VpcService - | VpcServiceLookup - ? Fetcher + : T extends AIBinding + ? Ai + : T extends AI.Search + ? AiSearchInstance + : T extends AI.SearchNamespace + ? AiSearchNamespace + : T extends Email.SendEmail + ? SendEmail + : T extends AnalyticsEngine.Dataset + ? AnalyticsEngineDataset + : T extends ArtifactsNs.Namespace + ? Artifacts + : T extends RateLimitBinding + ? RateLimit + : T extends SecretKeyBinding + ? CryptoKey + : T extends ImagesNs.ImagesBinding + ? ImagesBinding + : T extends BrowserBinding + ? BrowserRun + : // The ambient global `StreamBinding` from + // @cloudflare/workers-types (the alchemy binding value + // type of the same name is only reachable as + // `StreamNs.StreamBinding`). + T extends StreamNs.StreamBinding + ? StreamBinding + : T extends HyperdriveNs.Connection + ? Hyperdrive + : T extends VersionMetadataBinding + ? WorkerVersionMetadata + : T extends WorkerLoaderResource + ? WorkerLoader + : T extends + | WorkflowLike + | WorkflowBinding + ? Workflow + : T extends DurableObjectLike + ? DurableObjectNamespace< + Exclude + > : T extends - | PipelinesNs.Stream - | PipelinesNs.LegacyPipeline - ? Pipeline - : T extends Redacted - ? // redacteds are always stored as secret_text, so are always string - // we JSON.stringify when not a Redacted - string - : T; + | VpcService + | VpcServiceLookup + ? Fetcher + : T extends + | PipelinesNs.Stream + | PipelinesNs.LegacyPipeline + ? Pipeline + : T extends Redacted + ? // redacteds are always stored as secret_text, so are always string + // we JSON.stringify when not a Redacted + string + : T; /** * Cloudflare service-binding wire shape for an Effect-native Worker. diff --git a/packages/alchemy/src/Cloudflare/Workers/RuntimeBindings.ts b/packages/alchemy/src/Cloudflare/Workers/RuntimeBindings.ts index 8a24101498..6b65ff2026 100644 --- a/packages/alchemy/src/Cloudflare/Workers/RuntimeBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/RuntimeBindings.ts @@ -36,6 +36,7 @@ import { import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; +import { isInstantNamespaceLocalId } from "../KV/InstantNamespaceLocal.ts"; import { isLocalId } from "../LocalRuntime.ts"; import { LOCAL_R2_S3_CREDENTIALS, LOCAL_R2_S3_PATH } from "../R2/LocalS3.ts"; import type { WorkerBinding } from "./WorkerBinding.ts"; @@ -120,7 +121,11 @@ export const toRuntimeBinding = Effect.fn(function* ( // A `dev:` id belongs to a locally-emulated namespace; a real id is // a live namespace the dev worker proxies to. return isLocalId(b.namespaceId) - ? KvNamespace.local({ binding: b.name, id: b.namespaceId }) + ? KvNamespace.local({ + binding: b.name, + id: b.namespaceId, + mode: isInstantNamespaceLocalId(b.namespaceId) ? "instant" : undefined, + }) : KvNamespace.remote(b.name, b.namespaceId); case "mtls_certificate": return MtlsCertificate.remote(b.name, b.certificateId); diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index 62cfd752f5..50d39df7bc 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -25,6 +25,7 @@ import { isApp } from "../Flagship/App.ts"; import { getHyperdriveDevOrigin } from "../Hyperdrive/ConnectBinding.ts"; import { isHyperdriveConnection } from "../Hyperdrive/Connection.ts"; import { isImages } from "../Images/Images.ts"; +import { isInstantNamespace } from "../KV/InstantNamespace.ts"; import { isNamespace as isKVNamespace } from "../KV/Namespace.ts"; import { isLegacyPipeline } from "../Pipelines/LegacyPipeline.ts"; import { isStream as isPipelinesStream } from "../Pipelines/Stream.ts"; @@ -539,7 +540,7 @@ const toBinding = ( Output.map((jurisdiction) => (jurisdiction === "default" ? undefined : jurisdiction)), ), }; - } else if (isKVNamespace(binding)) { + } else if (isKVNamespace(binding) || isInstantNamespace(binding)) { return { type: "kv_namespace", name: bindingName, diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts index e1362a5fcd..1343a042d2 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts @@ -17,6 +17,7 @@ import { SendEmail } from "../Email/SendEmail.ts"; import type { App as FlagshipApp } from "../Flagship/App.ts"; import type { Connection as Hyperdrive } from "../Hyperdrive/Connection.ts"; import type { ImagesBinding } from "../Images/ImagesBinding.ts"; +import type { InstantNamespace } from "../KV/InstantNamespace.ts"; import type { Namespace } from "../KV/Namespace.ts"; import type { LegacyPipeline } from "../Pipelines/LegacyPipeline.ts"; import type { Stream as PipelinesStream } from "../Pipelines/Stream.ts"; @@ -198,6 +199,7 @@ export type WorkerBindingResource = | S3Credentials | D1Database | Namespace + | InstantNamespace | Queue | AiGateway | AIBinding diff --git a/packages/alchemy/test/Cloudflare/KV/InstantNamespace.local.test.ts b/packages/alchemy/test/Cloudflare/KV/InstantNamespace.local.test.ts new file mode 100644 index 0000000000..5f0525c661 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/KV/InstantNamespace.local.test.ts @@ -0,0 +1,206 @@ +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as pathe from "pathe"; +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare/index"; +import * as Alchemy from "@/index"; +import * as Test from "@/Test/Alchemy"; + +// `dev: true` runs local providers behind the RPC sidecar proxy by default, +// matching the process topology of the real `alchemy dev` command (see +// MakeOptions.sidecar in Test/Core.ts). +const { test } = Test.make({ + providers: Cloudflare.providers(), + dev: true, +}); + +const logLevel = Effect.provideService(MinimumLogLevel, process.env.DEBUG ? "Debug" : "Info"); + +class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ + status: number; +}> {} + +const getJsonReady = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail(new WorkerNotReady({ status: res.status })), + ), + Effect.retry({ + while: (e): e is WorkerNotReady => e instanceof WorkerNotReady, + // Cap the backoff: an uncapped exponential over 10 recurs sums to + // ~8.5 minutes and turns a persistent non-200 into an apparent hang. + schedule: Schedule.max([ + Schedule.min([Schedule.exponential("500 millis"), Schedule.spaced("2 seconds")]), + Schedule.recurs(10), + ]), + }), + ); + return yield* res.json; + }).pipe(Effect.orDie); + +test.provider( + "Instant local binding supports CRUD, rejects metadata, and lists beyond 1,000 keys", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const deploy = (title: string) => + stack.deploy( + Effect.gen(function* () { + const ns = yield* Cloudflare.KV.InstantNamespace("Config", { title }); + const worker = yield* Cloudflare.Worker("instant-local", { + main: pathe.resolve(import.meta.dirname, "fixtures/kv-instant-local-worker.ts"), + env: { KV: ns }, + }); + return { ns, worker }; + }), + ); + const initial = yield* deploy("initial"); + expect(initial.ns.namespaceId).toMatch(/^dev:kv-instant:/); + const result = yield* getJsonReady(`${initial.worker.url}/roundtrip`); + expect(result).toEqual({ + value: "hello", + metadata: null, + metadataRejected: true, + longKeyRejected: true, + count: 1001, + complete: true, + afterDelete: null, + }); + const updated = yield* deploy("renamed"); + expect(updated.ns.namespaceId).toBe(initial.ns.namespaceId); + expect(updated.ns.title).toBe("renamed"); + expect(yield* getJsonReady(`${updated.worker.url}/get?key=config:0`)).toEqual({ value: "1" }); + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000, tags: ["provider:cloudflare", "provider:cloudflare:kv", "local"] }, +); + +test.provider( + "Instant ReadWriteNamespaceLocal Action enforces key and metadata rules and single-page listing", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const ns = yield* Cloudflare.KV.InstantNamespace("ActionSeededKV"); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + const client = yield* Cloudflare.KV.ReadWriteNamespace(ns); + return Effect.fn(function* () { + yield* client.put("seeded", "from-action"); + yield* client.put("other", "value"); + const value = yield* client.get("seeded"); + const list = yield* client.list(); + yield* client.delete("other"); + const afterDelete = yield* client.get("other"); + const keyError = yield* client.put("é".repeat(151), "value").pipe(Effect.flip); + const metadataError = yield* client + .put("metadata", "value", { + // @ts-expect-error Also verify rejection for untyped JavaScript callers. + metadata: { unsupported: true }, + }) + .pipe(Effect.flip); + yield* client.put("page:one", "value"); + yield* client.put("page:two", "value"); + const all = yield* client.list({ + prefix: "page:", + // @ts-expect-error Native callers may still send a page size; Instant ignores it. + limit: 1, + }); + return { + value, + keys: list.keys.map((k) => k.name), + afterDelete, + keyError: keyError.message, + metadataError: metadataError.message, + count: all.keys.length, + complete: all.list_complete, + }; + }); + }).pipe(Effect.provide(Cloudflare.KV.ReadWriteNamespaceLocal)), + ); + const seeded = yield* Seed({}); + + const worker = yield* Cloudflare.Worker("kv-action-worker", { + main: pathe.resolve(import.meta.dirname, "fixtures/kv-instant-local-worker.ts"), + env: { KV: ns }, + }); + return { ns, worker, seeded }; + }), + ); + + expect(deployed.ns.namespaceId).toMatch(/^dev:kv-instant:/); + expect(deployed.seeded.value).toBe("from-action"); + expect(deployed.seeded.keys.sort()).toEqual(["other", "seeded"]); + expect(deployed.seeded.afterDelete).toBeNull(); + expect(deployed.seeded.keyError).toContain("300 bytes"); + expect(deployed.seeded.metadataError).toContain("does not support metadata"); + expect(deployed.seeded.count).toBe(2); + expect(deployed.seeded.complete).toBe(true); + + // The worker's native binding reads the same simulator storage the + // Action's gateway wrote to. + const body = (yield* getJsonReady(`${deployed.worker.url}/get?key=seeded`)) as { + value: string | null; + }; + expect(body.value).toBe("from-action"); + + yield* stack.destroy(); + }).pipe(logLevel), + { + tags: ["provider:cloudflare", "provider:cloudflare:kv", "provider:cloudflare:worker", "local"], + timeout: 120_000, + }, +); + +// TODO: Requires private beta access even when the containing stack is local. +test.provider.todo( + "Alchemy.remote opts InstantNamespace out of local emulation", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const ns = yield* stack.deploy( + Cloudflare.KV.InstantNamespace("Remote").pipe(Alchemy.remote()), + ); + expect(ns.namespaceId.startsWith("dev:")).toBe(false); + yield* stack.destroy(); + }), + { timeout: 120_000, tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] }, +); + +test.provider( + "switching between classic and Instant replaces local storage in both directions", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const classic = yield* stack.deploy(Cloudflare.KV.Namespace("Config", { title: "config" })); + const instant = yield* stack.deploy( + Cloudflare.KV.InstantNamespace("Config", { title: "config" }), + ); + expect(instant.namespaceId).not.toBe(classic.namespaceId); + expect(instant.namespaceId).toMatch(/^dev:kv-instant:/); + expect(instant.mode).toBe("instant"); + const unchanged = yield* stack.deploy( + Cloudflare.KV.InstantNamespace("Config", { title: "config" }), + ); + expect(unchanged.namespaceId).toBe(instant.namespaceId); + const restored = yield* stack.deploy(Cloudflare.KV.Namespace("Config", { title: "config" })); + expect(restored.namespaceId).not.toBe(instant.namespaceId); + expect(restored.namespaceId.startsWith("dev:kv-instant:")).toBe(false); + const stable = yield* stack.deploy(Cloudflare.KV.Namespace("Config", { title: "config" })); + expect(stable.namespaceId).toBe(restored.namespaceId); + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000, tags: ["provider:cloudflare", "provider:cloudflare:kv", "local"] }, +); diff --git a/packages/alchemy/test/Cloudflare/KV/InstantNamespace.test.ts b/packages/alchemy/test/Cloudflare/KV/InstantNamespace.test.ts new file mode 100644 index 0000000000..e1236ccb88 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/KV/InstantNamespace.test.ts @@ -0,0 +1,310 @@ +// TODO: Enable these lifecycle tests by changing test.provider.todo to test.provider +// once the testing account has Workers KV Instant private beta access. +import * as kv from "@distilled.cloud/cloudflare/kv"; +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as KV from "@/Cloudflare/KV/index"; +import * as Provider from "@/Provider"; +import { State } from "@/State"; +import * as Test from "@/Test/Alchemy"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService(MinimumLogLevel, process.env.DEBUG ? "Debug" : "Info"); + +test.provider.todo( + "create and delete namespace with default props", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const namespace = yield* stack.deploy( + Effect.gen(function* () { + return yield* KV.InstantNamespace("DefaultNamespace"); + }), + ); + + expect(namespace.title).toBeDefined(); + expect(namespace.namespaceId).toBeDefined(); + + const actualNamespace = yield* kv.getNamespace({ + accountId, + namespaceId: namespace.namespaceId, + }); + expect(actualNamespace.id).toEqual(namespace.namespaceId); + expect(actualNamespace.mode).toEqual("instant"); + + yield* stack.destroy(); + + yield* waitForNamespaceToBeDeleted(namespace.namespaceId, accountId); + }).pipe(logLevel), + { timeout: 120_000, tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] }, +); + +test.provider.todo( + "create, update, delete namespace", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const namespace = yield* stack.deploy( + Effect.gen(function* () { + return yield* KV.InstantNamespace("TestNamespace"); + }), + ); + + const actualNamespace = yield* kv.getNamespace({ + accountId, + namespaceId: namespace.namespaceId, + }); + expect(actualNamespace.id).toEqual(namespace.namespaceId); + expect(actualNamespace.mode).toEqual("instant"); + expect(actualNamespace.title).toEqual(namespace.title); + + const updatedNamespace = yield* stack.deploy( + Effect.gen(function* () { + return yield* KV.InstantNamespace("TestNamespace", { + title: namespace.title + "-updated", + }); + }), + ); + + const actualUpdatedNamespace = yield* kv.getNamespace({ + accountId, + namespaceId: updatedNamespace.namespaceId, + }); + expect(actualUpdatedNamespace.mode).toEqual("instant"); + expect(updatedNamespace.namespaceId).toEqual(namespace.namespaceId); + expect(actualUpdatedNamespace.title).toEqual(namespace.title + "-updated"); + expect(actualUpdatedNamespace.id).toEqual(updatedNamespace.namespaceId); + + yield* stack.destroy(); + + yield* waitForNamespaceToBeDeleted(namespace.namespaceId, accountId); + }).pipe(logLevel), + { timeout: 120_000, tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] }, +); + +// Canonical `list()` test (account-scoped collection): deploy a real +// namespace, resolve the provider from context via `findProviderByType`, +// call `list()`, and assert the deployed namespace appears in the +// exhaustively-paginated result. +test.provider.todo( + "list separates classic and instant namespaces", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const { instant, classic } = yield* stack.deploy( + Effect.gen(function* () { + return { + instant: yield* KV.InstantNamespace("Instant"), + classic: yield* KV.Namespace("Classic"), + }; + }), + ); + const instantProvider = yield* Provider.findProvider(KV.InstantNamespace); + const classicProvider = yield* Provider.findProvider(KV.Namespace); + const instantNamespaces = yield* instantProvider.list(); + const classicNamespaces = yield* classicProvider.list(); + expect(instantNamespaces.some((ns) => ns.namespaceId === instant.namespaceId)).toBe(true); + expect(instantNamespaces.some((ns) => ns.namespaceId === classic.namespaceId)).toBe(false); + expect(classicNamespaces.some((ns) => ns.namespaceId === classic.namespaceId)).toBe(true); + expect(classicNamespaces.some((ns) => ns.namespaceId === instant.namespaceId)).toBe(false); + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000, tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] }, +); + +// Engine-level adoption: KV namespaces have no ownership signal (Cloudflare +// doesn't expose tags on KV), so a name match in `read` is treated as silent +// adoption. The test wipes local state mid-run while leaving the namespace +// on Cloudflare — this simulates a fresh state store seeing an existing +// resource with the same physical name. +test.provider.todo( + "existing namespace (matching title) is silently adopted without --adopt", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + // Phase 1: deploy normally so a real KV namespace exists on + // Cloudflare. No explicit `title` — the engine generates a + // random-suffixed physical name (collision-free across concurrent + // runs); the deploy output hands back the real title, which pins the + // namespace's identity for the adoption phase below. + const initial = yield* stack.deploy( + Effect.gen(function* () { + return yield* KV.InstantNamespace("AdoptableNamespace"); + }), + ); + const title = initial.title; + const initialId = initial.namespaceId; + expect(initialId).toBeDefined(); + + // Phase 2: wipe local state — the namespace stays on Cloudflare. + yield* Effect.gen(function* () { + const state = yield* yield* State; + yield* state.delete({ stack: stack.name, stage: stack.stage, fqn: "AdoptableNamespace" }); + }).pipe(Effect.provide(stack.state)); + + // Phase 3: redeploy without `adopt(true)`. The engine calls + // `provider.read`, which lists namespaces, matches by title, and + // returns plain attrs — silent adoption. + const adopted = yield* stack.deploy( + Effect.gen(function* () { + return yield* KV.InstantNamespace("AdoptableNamespace", { title }); + }), + ); + + // Same physical namespace — adoption, not re-creation. + expect(adopted.namespaceId).toEqual(initialId); + expect(adopted.title).toEqual(title); + + const persisted = yield* Effect.gen(function* () { + const state = yield* yield* State; + return yield* state.get({ + stack: stack.name, + stage: stack.stage, + fqn: "AdoptableNamespace", + }); + }).pipe(Effect.provide(stack.state)); + + expect((persisted as any)?.attr).toMatchObject({ namespaceId: initialId, title }); + + yield* stack.destroy(); + yield* waitForNamespaceToBeDeleted(initialId, accountId); + }).pipe(logLevel), + { timeout: 120_000, tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] }, +); + +const waitForNamespaceToBeDeleted = Effect.fn(function* (namespaceId: string, accountId: string) { + yield* kv.getNamespace({ accountId, namespaceId }).pipe( + Effect.flatMap(() => Effect.fail(new NamespaceStillExists())), + Effect.retry({ + while: (e): e is NamespaceStillExists => e instanceof NamespaceStillExists, + schedule: Schedule.exponential(100), + }), + Effect.catchTag("NamespaceNotFound", () => Effect.void), + ); +}); + +class NamespaceStillExists extends Data.TaggedError("NamespaceStillExists") {} + +test.provider.todo( + "Instant client: put, get, list all keys, and delete from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const namespace = yield* Cloudflare.KV.InstantNamespace("SeedNamespace"); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + const kv = yield* Cloudflare.KV.ReadWriteNamespace(namespace); + // Accessor — resolved at apply time against the tracker. + const namespaceId = yield* namespace.namespaceId; + + return Effect.fn(function* () { + yield* kv.put("greeting", "hello world"); + + // KV is eventually consistent — retry the read-back until the + // value propagates (bounded so the test fails fast). + const value = yield* kv.get("greeting").pipe( + Effect.flatMap((v) => + v === "hello world" + ? Effect.succeed(v) + : Effect.fail("not yet propagated" as const), + ), + Effect.retry({ + schedule: Schedule.spaced("1 second"), + times: 10, + }), + Effect.orElseSucceed(() => null), + ); + + const listed = yield* kv.list(); + expect(listed.list_complete).toBe(true); + const names = listed.keys.map((k) => k.name); + + // Instant allows only one write per namespace per second. + yield* Effect.sleep("1 second"); + yield* kv.delete("greeting"); + + const afterDelete = yield* kv.get("greeting").pipe( + Effect.flatMap((v) => + v === null ? Effect.succeed(v) : Effect.fail("not yet deleted" as const), + ), + Effect.retry({ + schedule: Schedule.spaced("1 second"), + times: 10, + }), + Effect.orElseSucceed(() => "still present" as string | null), + ); + + return { + namespaceId: yield* namespaceId, + value, + names, + afterDelete, + }; + }); + }).pipe(Effect.provide(Cloudflare.KV.ReadWriteNamespaceLocal)), + ); + + return yield* Seed({}); + }), + ); + + expect(out.namespaceId).toBeTruthy(); + expect(out.value).toBe("hello world"); + expect(out.names).toContain("greeting"); + expect(out.afterDelete).toBeNull(); + + yield* stack.destroy(); + }).pipe(logLevel), + { + tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"], + timeout: 120_000, + }, +); + +test.provider.todo( + "switching namespace modes replaces storage while reusing an explicit title", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + const { accountId } = yield* yield* CloudflareEnvironment; + const classic = yield* stack.deploy(KV.Namespace("Config")); + const instant = yield* stack.deploy(KV.InstantNamespace("Config", { title: classic.title })); + expect(instant.namespaceId).not.toBe(classic.namespaceId); + expect(instant.title).toBe(classic.title); + expect((yield* kv.getNamespace({ accountId, namespaceId: instant.namespaceId })).mode).toBe( + "instant", + ); + yield* waitForNamespaceToBeDeleted(classic.namespaceId, accountId); + const restored = yield* stack.deploy(KV.Namespace("Config", { title: classic.title })); + expect(restored.namespaceId).not.toBe(instant.namespaceId); + expect(restored.title).toBe(classic.title); + expect( + (yield* kv.getNamespace({ accountId, namespaceId: restored.namespaceId })).mode, + ).not.toBe("instant"); + yield* waitForNamespaceToBeDeleted(instant.namespaceId, accountId); + yield* stack.destroy(); + yield* waitForNamespaceToBeDeleted(restored.namespaceId, accountId); + }).pipe(logLevel), + { timeout: 120_000, tags: ["provider:cloudflare", "provider:cloudflare:kv", "live"] }, +); diff --git a/packages/alchemy/test/Cloudflare/KV/InstantNamespace.types.test.ts b/packages/alchemy/test/Cloudflare/KV/InstantNamespace.types.test.ts new file mode 100644 index 0000000000..c31718e84d --- /dev/null +++ b/packages/alchemy/test/Cloudflare/KV/InstantNamespace.types.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as KV from "@/Cloudflare/KV/index"; +import type { Input } from "@/Input"; + +const typeCases = ( + classic: KV.Namespace, + instant: KV.InstantNamespace, + either: KV.Namespace | KV.InstantNamespace, + input: Input, +) => + Effect.gen(function* () { + const classicRead = yield* KV.ReadNamespace(classic); + yield* classicRead.getWithMetadata("key"); + yield* classicRead.list({ cursor: "cursor", limit: 1 }); + const classicWrite = yield* KV.WriteNamespace(classic); + yield* classicWrite.put("key", "value", { metadata: { allowed: true } }); + const classicBoth = yield* KV.ReadWriteNamespace(Effect.succeed(classic)); + yield* classicBoth.getWithMetadata("key"); + yield* classicBoth.put("key", "value", { metadata: {} }); + + const instantRead = yield* KV.ReadNamespace(instant); + // @ts-expect-error Instant clients do not expose metadata. + instantRead.getWithMetadata("key"); + // @ts-expect-error Instant listing does not accept pagination. + instantRead.list({ limit: 1 }); + const instantWrite = yield* KV.WriteNamespace(instant); + // @ts-expect-error Instant puts do not accept metadata. + instantWrite.put("key", "value", { metadata: {} }); + + const read = yield* KV.ReadNamespace(either); + yield* read.get("key"); + // @ts-expect-error A union client must be safe for Instant. + read.list({ cursor: "cursor" }); + const write = yield* KV.WriteNamespace(Effect.succeed(either)); + yield* write.put("key", "value"); + // @ts-expect-error A union client must be safe for Instant. + write.put("key", "value", { metadata: {} }); + const both = yield* KV.ReadWriteNamespace(input); + yield* both.list({ prefix: "key" }); + yield* both.delete("key"); + // @ts-expect-error A union client must be safe for Instant. + both.getWithMetadata("key"); + + const bind = yield* KV.ReadWriteNamespace; + const bound = yield* bind(either); + yield* bound.put("key", "value"); + // @ts-expect-error The service shape also narrows union clients. + bound.put("key", "value", { metadata: {} }); + }); + +test.effect("KV capability overloads preserve classic and Instant APIs", () => + Effect.sync(() => { + expect(typeof typeCases).toBe("function"); + }), +); diff --git a/packages/alchemy/test/Cloudflare/KV/fixtures/kv-instant-local-worker.ts b/packages/alchemy/test/Cloudflare/KV/fixtures/kv-instant-local-worker.ts new file mode 100644 index 0000000000..3932d00bf0 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/KV/fixtures/kv-instant-local-worker.ts @@ -0,0 +1,55 @@ +// Deliberately use the raw API to verify runtime restrictions, beyond TS types. +interface Namespace { + put(key: string, value: string, options?: { metadata?: unknown }): Promise; + get(key: string): Promise; + getWithMetadata(key: string): Promise<{ metadata: unknown }>; + list(options?: { prefix?: string }): Promise<{ + keys: Array<{ name: string }>; + list_complete: boolean; + }>; + delete(key: string): Promise; +} + +export default { + async fetch(request: Request, env: { KV: Namespace }) { + const kv = env.KV; + const url = new URL(request.url); + if (url.pathname === "/get") { + return Response.json({ value: await kv.get(url.searchParams.get("key") ?? "seeded") }); + } + await kv.put("value", "hello"); + const value = await kv.get("value"); + const { metadata } = await kv.getWithMetadata("value"); + let metadataRejected = false; + try { + await kv.put("metadata", "invalid", { metadata: { unsupported: true } }); + } catch { + metadataRejected = true; + } + let longKeyRejected = false; + try { + await kv.put("a".repeat(301), "invalid"); + } catch { + longKeyRejected = true; + } + // Cross classic KV's 1,000-key page boundary. + for (let offset = 0; offset < 1001; offset += 50) { + await Promise.all( + Array.from({ length: Math.min(50, 1001 - offset) }, (_, index) => + kv.put(`config:${offset + index}`, "1"), + ), + ); + } + const listed = await kv.list({ prefix: "config:" }); + await kv.delete("value"); + return Response.json({ + value, + metadata, + metadataRejected, + longKeyRejected, + count: listed.keys.length, + complete: listed.list_complete, + afterDelete: await kv.get("value"), + }); + }, +}; diff --git a/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespace.ts b/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespace.ts index f9160a4337..6a2e09a1a1 100644 --- a/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespace.ts +++ b/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespace.ts @@ -147,7 +147,7 @@ export const KvNamespaceLive = Layer.effect( export const local = (props: KvNamespaceProps): BindingHook => Plugin.use(KvNamespace, (kv) => Effect.map( - kv.api.register({ namespaceId: props.id ?? props.binding }), + kv.api.register({ namespaceId: props.id ?? props.binding, mode: props.mode }), (service): WorkerdConfig.Worker_Binding => ({ name: props.binding, kvNamespace: service, diff --git a/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespace.worker.ts b/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespace.worker.ts index 37e342d62e..92acb1dc8d 100644 --- a/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespace.worker.ts +++ b/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespace.worker.ts @@ -31,6 +31,7 @@ import { BINDING_KV_OBJECT, HEADER_KV_CONTROL_OP, HEADER_KV_NAMESPACE, + HEADER_KV_MODE, } from "./KvNamespaceOptions.shared.ts"; interface Env { @@ -41,10 +42,11 @@ interface Env { export default { async fetch(request, env, ctx) { - const { namespaceId } = (ctx as { props: KvServiceProps }).props; + const { namespaceId, mode } = (ctx as { props: KvServiceProps }).props; const stub = env[BINDING_KV_OBJECT].getByName(namespaceId); const headers = new Headers(request.headers); headers.set(HEADER_KV_NAMESPACE, encodeURIComponent(namespaceId)); + headers.set(HEADER_KV_MODE, mode ?? "classic"); return stub.fetch(new Request(request, { headers })); }, } satisfies ExportedHandler; @@ -321,6 +323,7 @@ export class KVNamespaceObject implements DurableObject { beingTested = false; #name?: string; + #instant = false; #blob?: BlobStore; #storage?: KeyValueStorage; @@ -354,6 +357,7 @@ export class KVNamespaceObject implements DurableObject { const encodedName = req.headers.get(HEADER_KV_NAMESPACE); assert(encodedName !== null, `Expected ${HEADER_KV_NAMESPACE} header`); this.#name = decodeURIComponent(encodedName); + this.#instant = req.headers.get(HEADER_KV_MODE) === "instant"; // Allow control of object internals via a reserved header. Used by tests // to update fake time and access internal storage. @@ -431,6 +435,12 @@ export class KVNamespaceObject implements DurableObject { } } + #validateInstantKeySize(key: string): void { + if (this.#instant && utf8ByteLength(key) > 300) { + throw new HttpError(400, "KV Instant keys must be at most 300 bytes"); + } + } + async #get(rawKey: string, url: URL): Promise { // Decode URL parameters const key = decodeKey(rawKey, url.searchParams); @@ -438,6 +448,7 @@ export class KVNamespaceObject implements DurableObject { const cacheTtl = cacheTtlParam === null ? undefined : parseInt(cacheTtlParam); // Get value from storage + this.#validateInstantKeySize(key); validateGetOptions(key, { cacheTtl }); const entry = await this.storage.get(key); if (entry === null) throw new HttpError(404, "Not Found"); @@ -477,6 +488,7 @@ export class KVNamespaceObject implements DurableObject { const obj: Record = {}; let totalBytes = 0; for (const key of keys) { + this.#validateInstantKeySize(key); validateGetOptions(key, { cacheTtl: parsedBody.cacheTtl }); const entry = await this.storage.get(key); const [value, size] = await processKeyValue( @@ -506,6 +518,10 @@ export class KVNamespaceObject implements DurableObject { const rawExpiration = url.searchParams.get(KVParams.EXPIRATION); const rawExpirationTtl = url.searchParams.get(KVParams.EXPIRATION_TTL); const rawMetadata = req.headers.get(KVHeaders.METADATA); + if (this.#instant && rawMetadata !== null) { + throw new HttpError(400, "KV Instant does not support metadata"); + } + this.#validateInstantKeySize(key); // Validate key, expiration and metadata const now = millisToSeconds(this.timers.now()); @@ -575,6 +591,7 @@ export class KVNamespaceObject implements DurableObject { async #delete(rawKey: string, url: URL): Promise { // Decode URL parameters const key = decodeKey(rawKey, url.searchParams); + this.#validateInstantKeySize(key); validateKey(key); // Delete key from storage @@ -586,6 +603,12 @@ export class KVNamespaceObject implements DurableObject { // Decode URL parameters const options = decodeListOptions(url); validateListOptions(options); + if (this.#instant) { + // workerd may supply its default page size even when list() has no + // options. Ignore it and return every matching key in a single response. + options.limit = Number.MAX_SAFE_INTEGER - 1; + options.cursor = undefined; + } // List keys from storage const res = this.storage.list(options); diff --git a/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespaceOptions.shared.ts b/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespaceOptions.shared.ts index 9d6a089f07..8fba320381 100644 --- a/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespaceOptions.shared.ts +++ b/packages/cloudflare-runtime/src/core/bindings/kv-namespace/KvNamespaceOptions.shared.ts @@ -8,6 +8,8 @@ export interface KvNamespaceProps { * persisted on disk. */ readonly id?: string; + /** Emulate Instant metadata and listing semantics; defaults to classic KV. */ + readonly mode?: "instant"; } /** @@ -17,6 +19,7 @@ export interface KvNamespaceProps { */ export interface KvServiceProps { readonly namespaceId: string; + readonly mode?: "instant"; } export const SERVICE_KV = "kv"; @@ -39,3 +42,6 @@ export const HEADER_KV_NAMESPACE = "CF-Runtime-KV-Namespace"; * control endpoints are enabled; used by tests. */ export const HEADER_KV_CONTROL_OP = "CF-Runtime-KV-Control-Op"; + +/** Internal namespace mode forwarded to the storage object. */ +export const HEADER_KV_MODE = "CF-Runtime-KV-Mode"; diff --git a/packages/cloudflare-runtime/src/core/test/bindings/KvNamespace.test.ts b/packages/cloudflare-runtime/src/core/test/bindings/KvNamespace.test.ts index b62a40b5f6..244b891cfc 100644 --- a/packages/cloudflare-runtime/src/core/test/bindings/KvNamespace.test.ts +++ b/packages/cloudflare-runtime/src/core/test/bindings/KvNamespace.test.ts @@ -1066,3 +1066,33 @@ describe("KvNamespace binding persistence", () => { { timeout: 30_000 }, ); }); + +it.effect( + "KV Instant accepts 300-byte keys and rejects oversized UTF-8 keys across operations", + () => + Effect.gen(function* () { + const worker = yield* startTestWorker({ + name: "kv-instant-key-test", + compatibilityDate: "2026-03-10", + modules: [{ name: "main.js", type: "ESModule", content: TEST_SCRIPT }], + bindings: [KvNamespace.local({ binding: "NAMESPACE", id: "instant", mode: "instant" })], + }); + const kv = new NamespacedKv(worker.baseUrl, ""); + yield* Effect.promise(async () => { + for (const key of ["a".repeat(300), "é".repeat(150), "😀".repeat(75)]) { + await kv.put(key, "boundary"); + expect(await kv.get(key)).toBe("boundary"); + expect(await kv.get([key])).toEqual(new Map([[key, "boundary"]])); + await kv.delete(key); + expect(await kv.get(key)).toBeNull(); + } + for (const key of ["a".repeat(301), "é".repeat(150) + "a", "😀".repeat(76)]) { + await expect(kv.put(key, "invalid")).rejects.toThrow("at most 300 bytes"); + await expect(kv.get(key)).rejects.toThrow("at most 300 bytes"); + await expect(kv.get([key])).rejects.toThrow("at most 300 bytes"); + await expect(kv.delete(key)).rejects.toThrow("at most 300 bytes"); + } + }); + }).pipe(Effect.provide(localRuntimeLayer), Effect.scoped), + { timeout: 60_000 }, +);