@takosjp/yurucommu-core 3.4.5 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,16 +11,15 @@ import type {
11
11
  MessageBatch,
12
12
  Queue,
13
13
  R2Bucket,
14
- R2Object,
15
14
  } from "@cloudflare/workers-types";
16
15
  import { getDb } from "../../db/index.ts";
17
16
  import type {
18
17
  IKeyValueStore,
19
- IObjectStorage,
18
+ ObjectStore,
19
+ ObjectStoreBody,
20
+ ObjectStoreObject,
21
+ ObjectStorePutOptions,
20
22
  IStaticAssets,
21
- ListObjectsResult,
22
- ObjectMetadata,
23
- StorageObject,
24
23
  } from "./types.ts";
25
24
  import type {
26
25
  IQueueBatch,
@@ -33,76 +32,39 @@ import type {
33
32
  /**
34
33
  * Cloudflare R2 Storage Adapter
35
34
  */
36
- class CloudflareStorage implements IObjectStorage {
35
+ class CloudflareStorage implements ObjectStore {
37
36
  constructor(private bucket: R2Bucket) {}
38
37
 
39
38
  async put(
40
39
  key: string,
41
- value: Blob | ReadableStream | ArrayBuffer | string,
42
- options?: {
43
- httpMetadata?: ObjectMetadata["httpMetadata"];
44
- customMetadata?: Record<string, string>;
45
- },
40
+ value: ObjectStoreBody,
41
+ options?: ObjectStorePutOptions,
46
42
  ): Promise<void> {
47
43
  await this.bucket.put(key, value as Parameters<R2Bucket["put"]>[1], {
48
- httpMetadata: options?.httpMetadata,
49
- customMetadata: options?.customMetadata,
44
+ httpMetadata:
45
+ options?.contentType === undefined
46
+ ? undefined
47
+ : { contentType: options.contentType },
50
48
  });
51
49
  }
52
50
 
53
- async get(key: string): Promise<StorageObject | null> {
51
+ async get(key: string): Promise<ObjectStoreObject | null> {
54
52
  const obj = await this.bucket.get(key);
55
53
  if (!obj) return null;
56
54
 
57
55
  return {
58
56
  key,
59
57
  body: obj.body as unknown as ReadableStream,
60
- bodyUsed: obj.bodyUsed,
61
- httpEtag: obj.httpEtag,
62
- arrayBuffer: () => obj.arrayBuffer(),
63
- text: () => obj.text(),
64
- json: <T>() => obj.json<T>(),
65
- httpMetadata: obj.httpMetadata,
66
- customMetadata: obj.customMetadata,
67
- };
68
- }
69
-
70
- async delete(key: string | string[]): Promise<void> {
71
- await this.bucket.delete(key);
72
- }
73
-
74
- async list(options?: {
75
- prefix?: string;
76
- limit?: number;
77
- cursor?: string;
78
- delimiter?: string;
79
- }): Promise<ListObjectsResult> {
80
- const result = await this.bucket.list(options);
81
- return {
82
- objects: result.objects.map((obj: R2Object) => ({
83
- key: obj.key,
84
- size: obj.size,
85
- uploaded: obj.uploaded,
86
- etag: obj.etag,
87
- httpMetadata: obj.httpMetadata,
88
- })),
89
- truncated: result.truncated,
90
- cursor: result.truncated ? result.cursor : undefined,
91
- delimitedPrefixes: result.delimitedPrefixes,
58
+ contentType: obj.httpMetadata?.contentType,
59
+ etag: obj.httpEtag,
60
+ byteLength: obj.size,
92
61
  };
93
62
  }
94
63
 
95
- async head(key: string): Promise<ObjectMetadata | null> {
96
- const obj = await this.bucket.head(key);
97
- if (!obj) return null;
98
-
99
- return {
100
- contentType: obj.httpMetadata?.contentType,
101
- contentLength: obj.size,
102
- etag: obj.etag,
103
- httpMetadata: obj.httpMetadata,
104
- customMetadata: obj.customMetadata,
105
- };
64
+ async delete(key: string | readonly string[]): Promise<void> {
65
+ await this.bucket.delete(
66
+ typeof key === "string" ? key : ([...key] as string[]),
67
+ );
106
68
  }
107
69
  }
108
70
 
@@ -241,7 +203,7 @@ export function wrapCloudflareBindings<
241
203
  "DB" | "MEDIA" | "KV" | "ASSETS" | "DELIVERY_QUEUE" | "DELIVERY_DLQ"
242
204
  > & {
243
205
  DB_INSTANCE: ReturnType<typeof getDb>;
244
- MEDIA?: IObjectStorage;
206
+ MEDIA?: ObjectStore;
245
207
  KV: IKeyValueStore;
246
208
  ASSETS?: IStaticAssets;
247
209
  DELIVERY_QUEUE?: IQueueProducer<unknown>;
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The portable binding facades a Takoserver-hosted Worker receives.
3
+ *
4
+ * A Worker Version published through Takoform onto a Takoserver Host does not
5
+ * get Cloudflare's native `KVNamespace` / `D1Database` / `Queue` / `R2Bucket`
6
+ * objects. The Host's generated entrypoint replaces `env` with an object whose
7
+ * bindings are the exact facades named by the Interface the Version declared —
8
+ * `edge.kv@1.0.0`, `edge.sql@1.0.0`, `edge.queue@1.0.0`, `edge.objects@1.0.0`.
9
+ * The managed Cloudflare backend and the self-host backend project the SAME
10
+ * facade: same methods, same option keys, same error names. Takoserver's
11
+ * ADR 0005 states this explicitly for object storage, and its self-host wrapper
12
+ * repeats it for KV and SQL.
13
+ *
14
+ * This module is a TYPE MIRROR of that contract plus the structural probes the
15
+ * lane selector uses. It deliberately contains no behaviour: the adapters that
16
+ * map a facade onto this repo's runtime ports live in `edge-kv.ts`,
17
+ * `edge-sql.ts`, `edge-queue.ts`, and `edge-objects.ts`.
18
+ *
19
+ * Source of truth (read, do not re-derive from memory):
20
+ * takoserver `src/providers/cloudflare-managed-worker-wrapper.ts`
21
+ * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`,
22
+ * `createQueueAdapter`, `createEdgeObjectsR2Adapter`
23
+ * takoserver `src/providers/selfhost-worker-wrapper.ts`
24
+ * — `projectEnv`, `createKvAdapter`, `createSqlAdapter`
25
+ *
26
+ * Every method rejects with an `Error` whose `name` is the portable error code
27
+ * (`invalid_key`, `invalid_value`, `value_too_large`, `metadata_too_large`,
28
+ * `invalid_cursor`, `invalid_argument`, `sql_error`, `numeric_out_of_range`,
29
+ * `busy`, `not_found`, `precondition_failed`, `range_not_satisfiable`,
30
+ * `invalid_body`, `message_too_large`, `batch_too_large`, `invalid_part`,
31
+ * `already_settled`, `backend_unavailable`). The adapters let those propagate
32
+ * unchanged so a caller sees the Host's own vocabulary.
33
+ */
34
+
35
+ /** Limits the facades enforce. Mirrored so the adapters can fail before the
36
+ * round-trip instead of surfacing an opaque `invalid_*` from the Host. */
37
+ export const EDGE_KV_MAX_KEY_BYTES = 467;
38
+ export const EDGE_KV_MAX_VALUE_BYTES = 26214400;
39
+ /** `expirationTtlSeconds` is rejected outside this range by both backends. */
40
+ export const EDGE_KV_MIN_EXPIRATION_TTL_SECONDS = 60;
41
+ export const EDGE_KV_MAX_EXPIRATION_TTL_SECONDS = 315360000;
42
+ export const EDGE_KV_MAX_LIST_LIMIT = 1000;
43
+ export const EDGE_SQL_MAX_STATEMENTS = 100;
44
+ export const EDGE_SQL_MAX_PARAMETERS = 100;
45
+ export const EDGE_SQL_MAX_ROWS = 10000;
46
+ export const EDGE_SQL_MAX_COLUMNS = 100;
47
+ export const EDGE_QUEUE_MAX_MESSAGES = 100;
48
+
49
+ /** A byte string on the wire. The facades never hand out raw `Uint8Array`. */
50
+ export interface EdgeEncodedBytes {
51
+ readonly encoding: "base64";
52
+ readonly data: string;
53
+ }
54
+
55
+ /** Exactly what `edge.sql` accepts as a bound parameter and returns in a row. */
56
+ export type EdgeSqlValue = null | number | string | EdgeEncodedBytes;
57
+
58
+ export interface EdgeSqlStatement {
59
+ readonly sql: string;
60
+ readonly params?: readonly EdgeSqlValue[];
61
+ }
62
+
63
+ /**
64
+ * One statement's result. `rows` are RECORDS keyed by result-column name, not
65
+ * positional arrays — the single most consequential difference from D1, and the
66
+ * reason the lane has to rewrite the projection list (`sqlite-proxy-rows.ts`)
67
+ * before it can hand anything to Drizzle.
68
+ */
69
+ export interface EdgeSqlResult {
70
+ readonly rows: readonly Readonly<Record<string, EdgeSqlValue>>[];
71
+ readonly rowsWritten: number;
72
+ }
73
+
74
+ /** `edge.sql@1.0.0`. */
75
+ export interface EdgeSqlBinding {
76
+ execute(
77
+ sql: string,
78
+ params?: readonly EdgeSqlValue[],
79
+ ): Promise<EdgeSqlResult>;
80
+ /** `execute` restricted to statements that write nothing. */
81
+ query(sql: string, params?: readonly EdgeSqlValue[]): Promise<EdgeSqlResult>;
82
+ /** All-or-none. 1..100 statements, ordered, one Host round trip. */
83
+ transaction(
84
+ statements: readonly EdgeSqlStatement[],
85
+ ): Promise<readonly EdgeSqlResult[]>;
86
+ }
87
+
88
+ export interface EdgeKvPutOptions {
89
+ readonly expirationTtlSeconds?: number;
90
+ /** String values only; the Host projects a record of strings. */
91
+ readonly metadata?: Record<string, string>;
92
+ }
93
+
94
+ export interface EdgeKvListOptions {
95
+ readonly prefix?: string;
96
+ readonly cursor?: string;
97
+ readonly limit?: number;
98
+ }
99
+
100
+ /**
101
+ * A listed key carries its NAME ONLY. Neither backend returns the expiration or
102
+ * the metadata it stored, so `IKeyValueStore.list` reports those as absent on
103
+ * this lane.
104
+ */
105
+ export interface EdgeKvListResult {
106
+ readonly keys: readonly { readonly name: string }[];
107
+ readonly listComplete: boolean;
108
+ readonly cursor?: string;
109
+ }
110
+
111
+ /** `edge.kv@1.0.0`. Values are always bytes; there is no `type` option. */
112
+ export interface EdgeKvBinding {
113
+ get(key: string): Promise<ArrayBuffer | null>;
114
+ getWithMetadata(key: string): Promise<{
115
+ readonly value: ArrayBuffer;
116
+ readonly metadata?: Record<string, string>;
117
+ } | null>;
118
+ put(
119
+ key: string,
120
+ value: string | ArrayBuffer | ArrayBufferView,
121
+ options?: EdgeKvPutOptions,
122
+ ): Promise<void>;
123
+ delete(key: string): Promise<void>;
124
+ list(options?: EdgeKvListOptions): Promise<EdgeKvListResult>;
125
+ }
126
+
127
+ export interface EdgeQueueSendOptions {
128
+ readonly delaySeconds?: number;
129
+ }
130
+
131
+ export interface EdgeQueueBatchItem {
132
+ readonly body: string | ArrayBuffer | ArrayBufferView;
133
+ readonly delaySeconds?: number;
134
+ }
135
+
136
+ /**
137
+ * `edge.queue@1.0.0` producer. Bodies are BYTES — there is no structured-clone
138
+ * path, so a JavaScript object has to be serialized by the caller. `send`
139
+ * returns the Host's acceptance id, which is not a provider dedupe id.
140
+ */
141
+ export interface EdgeQueueBinding {
142
+ send(
143
+ body: string | ArrayBuffer | ArrayBufferView,
144
+ options?: EdgeQueueSendOptions,
145
+ ): Promise<string>;
146
+ sendBatch(
147
+ messages: readonly EdgeQueueBatchItem[],
148
+ ): Promise<readonly string[]>;
149
+ }
150
+
151
+ /** One message as the Host hands it to a declared `queue` handler. */
152
+ export interface EdgeQueueMessage {
153
+ readonly id: string;
154
+ readonly timestampMillis: number;
155
+ readonly attempts: number;
156
+ readonly body: EdgeEncodedBytes;
157
+ acknowledge(): void;
158
+ /** `delaySeconds`, when given, must be >= 1. */
159
+ retry(options?: { readonly delaySeconds?: number }): void;
160
+ }
161
+
162
+ export interface EdgeQueueBatch {
163
+ readonly batchId: string;
164
+ readonly queue: string;
165
+ readonly messages: readonly EdgeQueueMessage[];
166
+ acknowledgeAll(): void;
167
+ retryAll(options?: { readonly delaySeconds?: number }): void;
168
+ }
169
+
170
+ export interface EdgeObjectMetadata {
171
+ readonly etag: string;
172
+ readonly size: number;
173
+ readonly contentType?: string;
174
+ readonly uploadedAtMillis?: number;
175
+ }
176
+
177
+ export interface EdgeObjectBody extends EdgeObjectMetadata {
178
+ readonly body: ReadableStream;
179
+ readonly partial: boolean;
180
+ readonly range?: { readonly offset: number; readonly length: number };
181
+ }
182
+
183
+ export interface EdgeObjectListResult {
184
+ readonly objects: readonly (EdgeObjectMetadata & { readonly key: string })[];
185
+ readonly prefixes: readonly string[];
186
+ readonly truncated: boolean;
187
+ readonly cursor?: string;
188
+ }
189
+
190
+ /**
191
+ * `edge.objects@1.0.0`. Note the fixed arities — the Host counts
192
+ * `arguments.length`, so `get(key)` with one argument is a type error and the
193
+ * adapter must pass `undefined` explicitly. There is no `customMetadata`, and a
194
+ * streaming `put` requires `contentLength`.
195
+ */
196
+ export interface EdgeObjectsBinding {
197
+ head(key: string): Promise<EdgeObjectMetadata | null>;
198
+ get(
199
+ key: string,
200
+ options:
201
+ undefined | { readonly range?: { offset: number; length?: number } },
202
+ ): Promise<EdgeObjectBody | null>;
203
+ put(
204
+ key: string,
205
+ body: string | ArrayBuffer | ArrayBufferView | ReadableStream,
206
+ options:
207
+ | undefined
208
+ | {
209
+ readonly contentLength?: number;
210
+ readonly contentType?: string;
211
+ },
212
+ ): Promise<{ readonly etag: string; readonly size: number }>;
213
+ delete(key: string): Promise<void>;
214
+ list(
215
+ options:
216
+ | undefined
217
+ | {
218
+ readonly prefix?: string;
219
+ readonly delimiter?: string;
220
+ readonly cursor?: string;
221
+ readonly limit?: number;
222
+ },
223
+ ): Promise<EdgeObjectListResult>;
224
+ }
225
+
226
+ function hasMethods(value: unknown, names: readonly string[]): boolean {
227
+ if (typeof value !== "object" || value === null) return false;
228
+ const record = value as Record<string, unknown>;
229
+ for (const name of names) {
230
+ if (typeof record[name] !== "function") return false;
231
+ }
232
+ return true;
233
+ }
234
+
235
+ /**
236
+ * Structural probes.
237
+ *
238
+ * Only SOME bindings can be told apart by shape, and the difference matters:
239
+ *
240
+ * decisive `DB` — `execute`/`query`/`transaction` (facade) against
241
+ * `prepare`/`batch` (D1). Disjoint method sets.
242
+ * decisive `MEDIA` — R2 carries the multipart helpers the facade omits.
243
+ * decisive a queue *batch* — `acknowledgeAll` (facade) against `ackAll`
244
+ * (Cloudflare `MessageBatch`).
245
+ * AMBIGUOUS `KV` — `edge.kv` and `KVNamespace` expose the same five
246
+ * method names.
247
+ * AMBIGUOUS a queue *producer* — both are `send`/`sendBatch`.
248
+ *
249
+ * That is why the lane is a DECLARED variable rather than something sniffed:
250
+ * two of the five bindings cannot be identified at all. The declaration is then
251
+ * cross-checked against the decisive bindings, so a Worker whose var and whose
252
+ * bindings disagree refuses to start instead of calling `kv.get(key, {type})`
253
+ * on a facade that would silently treat the options object as nothing.
254
+ */
255
+ export function isEdgeSqlBinding(value: unknown): value is EdgeSqlBinding {
256
+ return (
257
+ hasMethods(value, ["execute", "query", "transaction"]) &&
258
+ typeof (value as Record<string, unknown>).prepare !== "function"
259
+ );
260
+ }
261
+
262
+ /** Cloudflare's `D1Database` is the `prepare`/`batch`/`exec` shape. */
263
+ export function isNativeD1Database(value: unknown): boolean {
264
+ return (
265
+ hasMethods(value, ["prepare", "batch"]) &&
266
+ typeof (value as Record<string, unknown>).execute !== "function"
267
+ );
268
+ }
269
+
270
+ export function isEdgeQueueBatch(value: unknown): value is EdgeQueueBatch {
271
+ return (
272
+ hasMethods(value, ["acknowledgeAll", "retryAll"]) &&
273
+ Array.isArray((value as Record<string, unknown>).messages)
274
+ );
275
+ }
276
+
277
+ export function isEdgeObjectsBinding(
278
+ value: unknown,
279
+ ): value is EdgeObjectsBinding {
280
+ return (
281
+ hasMethods(value, ["head", "get", "put", "delete", "list"]) &&
282
+ // R2 exposes multipart helpers on the binding itself; the facade does not
283
+ // give a bucket-shaped object those names.
284
+ typeof (value as Record<string, unknown>).createMultipartUpload !==
285
+ "function" &&
286
+ // Arity is part of the facade's contract and is asserted rather than
287
+ // assumed: the Host checks `arguments.length`, so `get` and `list` take
288
+ // their options slot even when it is `undefined`. Anything bucket-shaped
289
+ // whose `get` takes one argument is some other adapter, not this facade.
290
+ (value as { get: (...args: unknown[]) => unknown }).get.length === 2
291
+ );
292
+ }
293
+
294
+ /** Cloudflare's `R2Bucket`. */
295
+ export function isNativeR2Bucket(value: unknown): boolean {
296
+ return hasMethods(value, [
297
+ "head",
298
+ "get",
299
+ "put",
300
+ "delete",
301
+ "list",
302
+ "createMultipartUpload",
303
+ ]);
304
+ }
305
+
306
+ /** Decode one `{encoding:"base64"}` value into bytes. */
307
+ export function decodeEdgeBytes(value: EdgeEncodedBytes): Uint8Array {
308
+ const binary = atob(value.data);
309
+ const bytes = new Uint8Array(binary.length);
310
+ for (let index = 0; index < binary.length; index += 1) {
311
+ bytes[index] = binary.charCodeAt(index);
312
+ }
313
+ return bytes;
314
+ }
315
+
316
+ /** Encode bytes into the facade's wire value. */
317
+ export function encodeEdgeBytes(bytes: Uint8Array): EdgeEncodedBytes {
318
+ let binary = "";
319
+ // Chunked so a large blob does not blow the argument limit of `apply`.
320
+ const CHUNK = 0x8000;
321
+ for (let index = 0; index < bytes.length; index += CHUNK) {
322
+ binary += String.fromCharCode(
323
+ ...bytes.subarray(index, Math.min(index + CHUNK, bytes.length)),
324
+ );
325
+ }
326
+ return { encoding: "base64", data: btoa(binary) };
327
+ }
328
+
329
+ export function isEdgeEncodedBytes(value: unknown): value is EdgeEncodedBytes {
330
+ return (
331
+ typeof value === "object" &&
332
+ value !== null &&
333
+ (value as EdgeEncodedBytes).encoding === "base64" &&
334
+ typeof (value as EdgeEncodedBytes).data === "string"
335
+ );
336
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `edge.kv@1.0.0` → {@link IKeyValueStore}.
3
+ *
4
+ * The app's port and the facade disagree in three places, and each disagreement
5
+ * is resolved here rather than at the call sites:
6
+ *
7
+ * - READS. `IKeyValueStore.get` selects `text` / `json` / `arrayBuffer`; the
8
+ * facade always returns bytes. The decode happens here.
9
+ * - EXPIRY. The port carries Cloudflare's pair (`expirationTtl` relative,
10
+ * `expiration` absolute); the facade accepts only `expirationTtlSeconds`. An
11
+ * absolute instant is converted against the current clock.
12
+ * - LISTING. The facade returns `{name}` only and calls the flag
13
+ * `listComplete`; the port expects `list_complete` and optional
14
+ * `expiration` / `metadata`. Those two fields are ABSENT on this lane — the
15
+ * Host does not return them — so nothing may be inferred from their absence.
16
+ */
17
+
18
+ import type { IKeyValueStore } from "./types.ts";
19
+ import {
20
+ EDGE_KV_MAX_EXPIRATION_TTL_SECONDS,
21
+ EDGE_KV_MIN_EXPIRATION_TTL_SECONDS,
22
+ type EdgeKvBinding,
23
+ type EdgeKvPutOptions,
24
+ } from "./edge-facades.ts";
25
+ import { nowSeconds, readStream } from "./shared.ts";
26
+
27
+ /** A put option the facade cannot express. */
28
+ export class EdgeKeyValueOptionError extends TypeError {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = "EdgeKeyValueOptionError";
32
+ }
33
+ }
34
+
35
+ /** A stored value could not be read back in the requested shape. */
36
+ export class EdgeKeyValueValueError extends Error {
37
+ constructor(message: string) {
38
+ super(message);
39
+ this.name = "EdgeKeyValueValueError";
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Resolve the port's expiry pair into the single value `edge.kv` takes.
45
+ *
46
+ * `expiration` is an absolute UNIX second; the facade has no equivalent, so it
47
+ * becomes the remaining seconds from now. Both backends reject a TTL under 60
48
+ * seconds (as does Cloudflare KV itself), so a shorter one is refused here with
49
+ * a message that names the caller's own value instead of surfacing the Host's
50
+ * bare `invalid_value`.
51
+ */
52
+ export function resolveEdgeKvExpirationTtl(
53
+ options?: { readonly expirationTtl?: number; readonly expiration?: number },
54
+ now: () => number = nowSeconds,
55
+ ): number | undefined {
56
+ const ttl =
57
+ options?.expirationTtl !== undefined
58
+ ? options.expirationTtl
59
+ : options?.expiration !== undefined
60
+ ? Math.ceil(options.expiration - now())
61
+ : undefined;
62
+ if (ttl === undefined) return undefined;
63
+ const seconds = Math.ceil(ttl);
64
+ if (!Number.isSafeInteger(seconds)) {
65
+ throw new EdgeKeyValueOptionError(
66
+ `edge.kv: expiration resolves to ${ttl}, which is not a whole number of seconds`,
67
+ );
68
+ }
69
+ if (seconds < EDGE_KV_MIN_EXPIRATION_TTL_SECONDS) {
70
+ throw new EdgeKeyValueOptionError(
71
+ `edge.kv: expiration resolves to ${seconds}s, under the ` +
72
+ `${EDGE_KV_MIN_EXPIRATION_TTL_SECONDS}s floor both Takoserver and ` +
73
+ `Cloudflare KV enforce; store a longer-lived entry or carry the ` +
74
+ `deadline inside the value`,
75
+ );
76
+ }
77
+ if (seconds > EDGE_KV_MAX_EXPIRATION_TTL_SECONDS) {
78
+ throw new EdgeKeyValueOptionError(
79
+ `edge.kv: expiration resolves to ${seconds}s, over the ` +
80
+ `${EDGE_KV_MAX_EXPIRATION_TTL_SECONDS}s ceiling`,
81
+ );
82
+ }
83
+ return seconds;
84
+ }
85
+
86
+ /**
87
+ * The facade stores a record of STRINGS. The port's `Record<string, unknown>`
88
+ * is therefore only usable when every value already is one; anything else is
89
+ * refused rather than stringified, because a silent `String(value)` would round
90
+ * -trip differently on the two lanes.
91
+ */
92
+ function projectMetadata(
93
+ metadata: Record<string, unknown> | undefined,
94
+ ): Record<string, string> | undefined {
95
+ if (metadata === undefined) return undefined;
96
+ const projected: Record<string, string> = {};
97
+ for (const [key, value] of Object.entries(metadata)) {
98
+ if (typeof value !== "string") {
99
+ throw new EdgeKeyValueOptionError(
100
+ `edge.kv: metadata."${key}" is ${typeof value}; the facade stores ` +
101
+ `string values only`,
102
+ );
103
+ }
104
+ projected[key] = value;
105
+ }
106
+ return projected;
107
+ }
108
+
109
+ async function toBytes(
110
+ value: string | ArrayBuffer | ReadableStream,
111
+ ): Promise<string | ArrayBuffer | Uint8Array> {
112
+ if (typeof value === "string" || value instanceof ArrayBuffer) return value;
113
+ return await readStream(value as ReadableStream<Uint8Array>);
114
+ }
115
+
116
+ export class EdgeKeyValueStore implements IKeyValueStore {
117
+ constructor(
118
+ private readonly kv: EdgeKvBinding,
119
+ private readonly now: () => number = nowSeconds,
120
+ ) {}
121
+
122
+ get(key: string, options?: { type?: "text" }): Promise<string | null>;
123
+ get<T = unknown>(key: string, options: { type: "json" }): Promise<T | null>;
124
+ get(
125
+ key: string,
126
+ options: { type: "arrayBuffer" },
127
+ ): Promise<ArrayBuffer | null>;
128
+ async get(
129
+ key: string,
130
+ options?: { type?: "text" | "json" | "arrayBuffer" },
131
+ ): Promise<string | ArrayBuffer | unknown | null> {
132
+ const value = await this.kv.get(key);
133
+ if (value === null) return null;
134
+ const type = options?.type ?? "text";
135
+ if (type === "arrayBuffer") return value;
136
+ const text = new TextDecoder().decode(value);
137
+ if (type === "json") {
138
+ // A present-but-unparseable entry is a fault, not a miss: reporting it as
139
+ // `null` would be indistinguishable from "no such key" and would silently
140
+ // reset whatever state the entry held. The other lanes in this repo
141
+ // (MemoryKV, the managed runtime) both raise here too.
142
+ try {
143
+ return JSON.parse(text) as unknown;
144
+ } catch (error) {
145
+ throw new EdgeKeyValueValueError(
146
+ `edge.kv: "${key}" is present but is not JSON: ${String(error)}`,
147
+ );
148
+ }
149
+ }
150
+ return text;
151
+ }
152
+
153
+ async put(
154
+ key: string,
155
+ value: string | ArrayBuffer | ReadableStream,
156
+ options?: {
157
+ expirationTtl?: number;
158
+ expiration?: number;
159
+ metadata?: Record<string, unknown>;
160
+ },
161
+ ): Promise<void> {
162
+ const expirationTtlSeconds = resolveEdgeKvExpirationTtl(options, this.now);
163
+ const metadata = projectMetadata(options?.metadata);
164
+ const put: EdgeKvPutOptions = {
165
+ ...(expirationTtlSeconds === undefined ? {} : { expirationTtlSeconds }),
166
+ ...(metadata === undefined ? {} : { metadata }),
167
+ };
168
+ await this.kv.put(key, await toBytes(value), put);
169
+ }
170
+
171
+ async delete(key: string): Promise<void> {
172
+ await this.kv.delete(key);
173
+ }
174
+
175
+ async list(options?: {
176
+ prefix?: string;
177
+ limit?: number;
178
+ cursor?: string;
179
+ }): Promise<{
180
+ keys: Array<{ name: string; expiration?: number; metadata?: unknown }>;
181
+ list_complete: boolean;
182
+ cursor?: string;
183
+ }> {
184
+ const result = await this.kv.list({
185
+ ...(options?.prefix === undefined ? {} : { prefix: options.prefix }),
186
+ ...(options?.cursor === undefined ? {} : { cursor: options.cursor }),
187
+ ...(options?.limit === undefined ? {} : { limit: options.limit }),
188
+ });
189
+ return {
190
+ keys: result.keys.map((entry) => ({ name: entry.name })),
191
+ list_complete: result.listComplete,
192
+ // The facade omits the cursor once the listing is complete; the port
193
+ // says the same thing with `undefined`.
194
+ cursor: result.listComplete ? undefined : result.cursor,
195
+ };
196
+ }
197
+ }
198
+
199
+ export function wrapEdgeKv(
200
+ kv: EdgeKvBinding,
201
+ now?: () => number,
202
+ ): IKeyValueStore {
203
+ return new EdgeKeyValueStore(kv, now);
204
+ }