@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.
- package/README.en.md +3 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/packages/api/package.json +1 -1
- package/src/backend/index.ts +44 -16
- package/src/backend/lib/blocklist-purge.ts +6 -6
- package/src/backend/public.ts +69 -4
- package/src/backend/routes/account-teardown.ts +2 -2
- package/src/backend/routes/apps.ts +4 -5
- package/src/backend/routes/media.ts +8 -5
- package/src/backend/routes/posts/delete-cascade.ts +7 -7
- package/src/backend/routes/stories/query-helpers.ts +2 -2
- package/src/backend/routes/stories/routes.ts +2 -5
- package/src/backend/runtime/bun.ts +655 -320
- package/src/backend/runtime/cloudflare.ts +20 -58
- package/src/backend/runtime/edge-facades.ts +336 -0
- package/src/backend/runtime/edge-kv.ts +204 -0
- package/src/backend/runtime/edge-objects.ts +120 -0
- package/src/backend/runtime/edge-queue.ts +135 -0
- package/src/backend/runtime/edge-sql.ts +207 -0
- package/src/backend/runtime/lane.ts +291 -0
- package/src/backend/runtime/managed-relational.ts +70 -23
- package/src/backend/runtime/managed-runtime.ts +117 -139
- package/src/backend/runtime/s3-fetch.ts +380 -0
- package/src/backend/runtime/sqlite-proxy-rows.ts +309 -0
- package/src/backend/runtime/types.ts +29 -61
- package/src/backend/server.ts +5 -0
- package/src/backend/types.ts +13 -4
- package/src/db/index.ts +2 -1
|
@@ -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
|
-
|
|
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
|
|
35
|
+
class CloudflareStorage implements ObjectStore {
|
|
37
36
|
constructor(private bucket: R2Bucket) {}
|
|
38
37
|
|
|
39
38
|
async put(
|
|
40
39
|
key: string,
|
|
41
|
-
value:
|
|
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:
|
|
49
|
-
|
|
44
|
+
httpMetadata:
|
|
45
|
+
options?.contentType === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: { contentType: options.contentType },
|
|
50
48
|
});
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
async get(key: string): Promise<
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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?:
|
|
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
|
+
}
|