@takosjp/yurucommu-core 4.0.0 → 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.
@@ -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
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `edge.objects@1.0.0` → {@link ObjectStore}.
3
+ *
4
+ * The facade is deliberately narrower than R2, and the narrow spots are the
5
+ * interesting ones:
6
+ *
7
+ * - NO CUSTOM METADATA. Only `contentType` survives a round trip, which is
8
+ * also all the provider-neutral {@link ObjectStorePutOptions} carries.
9
+ * - FIXED ARITIES. The Host counts `arguments.length`, so `get` and `put` are
10
+ * always called with their full argument list even when the options are
11
+ * absent.
12
+ * - A STREAMING `put` NEEDS `contentLength`. ADR 0005 is explicit that a Host
13
+ * enforces the declared count while streaming and never buffers a body to
14
+ * discover its size. Every body shape but a bare `ReadableStream` already
15
+ * knows its length — a `Blob` (the shape media uploads hand over), an
16
+ * `ArrayBuffer`, a string — so the length is declared and the bytes stream
17
+ * through. A stream that arrives without a knowable length is buffered
18
+ * HERE, in the Worker, which is the honest cost of not knowing the size.
19
+ * - `delete` TAKES ONE KEY. The port's array form becomes a sequence of calls,
20
+ * which is not atomic — the same as R2's, which also has no transaction.
21
+ * - NO ENUMERATION OR HEAD. The port does not carry them, so neither does the
22
+ * adapter, even though the Host projects both.
23
+ *
24
+ * AVAILABILITY: `edge.objects` is projected by the managed Cloudflare backend
25
+ * (`createEdgeObjectsR2Adapter`). The self-host backend projects only
26
+ * `edge.kv` and `edge.sql`, so a self-hosted Worker has no object binding and
27
+ * the core's existing "object storage unavailable" behaviour applies.
28
+ */
29
+
30
+ import type {
31
+ ObjectStore,
32
+ ObjectStoreBody,
33
+ ObjectStoreObject,
34
+ ObjectStorePutOptions,
35
+ } from "./types.ts";
36
+ import type { EdgeObjectsBinding } from "./edge-facades.ts";
37
+ import { readStream } from "./shared.ts";
38
+
39
+ /** A request or response the facade cannot express. */
40
+ export class EdgeObjectsShapeError extends TypeError {
41
+ constructor(message: string) {
42
+ super(message);
43
+ this.name = "EdgeObjectsShapeError";
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The byte length of a body the Host can be told up front, or `undefined` for
49
+ * a bare stream whose size only the producer knows.
50
+ */
51
+ function knownBodyLength(value: ObjectStoreBody): number | undefined {
52
+ if (value instanceof Blob) return value.size;
53
+ if (value instanceof ArrayBuffer) return value.byteLength;
54
+ if (typeof value === "string") {
55
+ return new TextEncoder().encode(value).byteLength;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ export class EdgeObjectStorage implements ObjectStore {
61
+ constructor(private readonly bucket: EdgeObjectsBinding) {}
62
+
63
+ async put(
64
+ key: string,
65
+ value: ObjectStoreBody,
66
+ options?: ObjectStorePutOptions,
67
+ ): Promise<void> {
68
+ const contentType = options?.contentType;
69
+ let contentLength = knownBodyLength(value);
70
+ // The facade's body slot has no `Blob`. A Blob's stream carries the same
71
+ // bytes and its size is already known, so it goes over as a declared-length
72
+ // stream rather than being buffered.
73
+ let body: string | ArrayBuffer | Uint8Array | ReadableStream =
74
+ value instanceof Blob ? value.stream() : value;
75
+ if (contentLength === undefined) {
76
+ // No knowable length and a stream: the size has to come from somewhere,
77
+ // and the Host will not discover it. Buffering is the only remaining
78
+ // option, so it happens where the memory cost is visible.
79
+ const buffered = await readStream(body as ReadableStream<Uint8Array>);
80
+ body = buffered;
81
+ contentLength = buffered.byteLength;
82
+ }
83
+ await this.bucket.put(key, body, {
84
+ contentLength,
85
+ ...(contentType === undefined ? {} : { contentType }),
86
+ });
87
+ }
88
+
89
+ async get(key: string): Promise<ObjectStoreObject | null> {
90
+ const found = await this.bucket.get(key, undefined);
91
+ if (!found) return null;
92
+ if (found.partial) {
93
+ // No range was asked for, so a partial body would be a truncated object
94
+ // served as if it were whole. Refuse rather than hand the caller bytes
95
+ // that do not add up to the object.
96
+ await found.body.cancel().catch(() => undefined);
97
+ throw new EdgeObjectsShapeError(
98
+ "edge.objects: the Host returned a partial body for an unranged get",
99
+ );
100
+ }
101
+ return {
102
+ key,
103
+ body: found.body as ReadableStream<Uint8Array>,
104
+ ...(found.contentType === undefined
105
+ ? {}
106
+ : { contentType: found.contentType }),
107
+ etag: found.etag,
108
+ byteLength: found.size,
109
+ };
110
+ }
111
+
112
+ async delete(key: string | readonly string[]): Promise<void> {
113
+ const keys = typeof key === "string" ? [key] : [...new Set(key)];
114
+ for (const one of keys) await this.bucket.delete(one);
115
+ }
116
+ }
117
+
118
+ export function wrapEdgeObjects(bucket: EdgeObjectsBinding): ObjectStore {
119
+ return new EdgeObjectStorage(bucket);
120
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `edge.queue@1.0.0` → {@link IQueueProducer} / {@link IQueueBatch}.
3
+ *
4
+ * Two differences from Cloudflare Queues, both of which would otherwise be
5
+ * discovered in production:
6
+ *
7
+ * - BODIES ARE BYTES. `queue.send(object)` works on Cloudflare because the
8
+ * runtime structured-clones the value. The facade runs the body through a
9
+ * bytes projection and rejects anything that is not a string, ArrayBuffer,
10
+ * or view, so a delivery message has to be serialized. JSON is the encoding
11
+ * on both ends, and the consumer side undoes it.
12
+ * - THE CONSUMER BATCH IS A DIFFERENT OBJECT. It is `acknowledge` /
13
+ * `acknowledgeAll` / `timestampMillis`, not `ack` / `ackAll` / `timestamp`,
14
+ * and the body arrives as `{encoding:"base64", data}`. `retry` also refuses
15
+ * `delaySeconds: 0`, which Cloudflare accepts as "no delay".
16
+ *
17
+ * AVAILABILITY: the managed Cloudflare backend projects queue bindings; the
18
+ * self-host backend projects only `edge.kv` and `edge.sql` today (see
19
+ * takoserver `selfhost-worker-wrapper.ts` `projectEnv`). A self-hosted Worker
20
+ * therefore has no queue binding at all, and the core's existing behaviour for
21
+ * an unbound `DELIVERY_QUEUE` — synchronous fallback delivery, reported by the
22
+ * readiness surface — is what applies there.
23
+ */
24
+
25
+ import {
26
+ EDGE_QUEUE_MAX_MESSAGES,
27
+ decodeEdgeBytes,
28
+ type EdgeQueueBatch,
29
+ type EdgeQueueBinding,
30
+ } from "./edge-facades.ts";
31
+ import type {
32
+ IQueueBatch,
33
+ IQueueMessage,
34
+ IQueueProducer,
35
+ QueueBatchItem,
36
+ QueueSendOptions,
37
+ } from "./queue.ts";
38
+
39
+ /** A message cannot be carried over the facade. */
40
+ export class EdgeQueueShapeError extends TypeError {
41
+ constructor(message: string) {
42
+ super(message);
43
+ this.name = "EdgeQueueShapeError";
44
+ }
45
+ }
46
+
47
+ const encoder = new TextEncoder();
48
+ const decoder = new TextDecoder();
49
+
50
+ function encodeBody(body: unknown): Uint8Array {
51
+ let json: string;
52
+ try {
53
+ json = JSON.stringify(body);
54
+ } catch (error) {
55
+ throw new EdgeQueueShapeError(
56
+ `edge.queue: the message body is not JSON-serializable: ${String(error)}`,
57
+ );
58
+ }
59
+ if (json === undefined) {
60
+ throw new EdgeQueueShapeError(
61
+ "edge.queue: the message body serialized to nothing",
62
+ );
63
+ }
64
+ return encoder.encode(json);
65
+ }
66
+
67
+ /**
68
+ * The facade takes `delaySeconds` only as a positive whole number; Cloudflare's
69
+ * `0` means the same as omitting it, so it is omitted.
70
+ */
71
+ function delayOption(
72
+ delaySeconds: number | undefined,
73
+ ): { delaySeconds: number } | Record<string, never> {
74
+ if (delaySeconds === undefined || delaySeconds <= 0) return {};
75
+ return { delaySeconds: Math.ceil(delaySeconds) };
76
+ }
77
+
78
+ class EdgeQueueProducer<T> implements IQueueProducer<T> {
79
+ constructor(private readonly queue: EdgeQueueBinding) {}
80
+
81
+ async send(body: T, options?: QueueSendOptions): Promise<void> {
82
+ await this.queue.send(encodeBody(body), delayOption(options?.delaySeconds));
83
+ }
84
+
85
+ async sendBatch(
86
+ messages: readonly QueueBatchItem<T>[],
87
+ options?: QueueSendOptions,
88
+ ): Promise<void> {
89
+ if (messages.length === 0) return;
90
+ if (messages.length > EDGE_QUEUE_MAX_MESSAGES) {
91
+ throw new EdgeQueueShapeError(
92
+ `edge.queue: ${messages.length} messages exceed the facade limit of ` +
93
+ `${EDGE_QUEUE_MAX_MESSAGES}`,
94
+ );
95
+ }
96
+ // `sendBatch` takes no batch-wide options, so a shared default delay is
97
+ // pushed down onto each message that did not set its own.
98
+ await this.queue.sendBatch(
99
+ messages.map(({ body, delaySeconds }) => ({
100
+ body: encodeBody(body),
101
+ ...delayOption(delaySeconds ?? options?.delaySeconds),
102
+ })),
103
+ );
104
+ }
105
+ }
106
+
107
+ export function wrapEdgeQueue<T>(queue: EdgeQueueBinding): IQueueProducer<T> {
108
+ return new EdgeQueueProducer<T>(queue);
109
+ }
110
+
111
+ /**
112
+ * Adapt one consumer batch. The body is decoded with the same JSON encoding
113
+ * {@link wrapEdgeQueue} writes, so a producer and consumer on this lane agree
114
+ * even though the Host only ever sees opaque bytes.
115
+ */
116
+ export function wrapEdgeMessageBatch<T>(batch: EdgeQueueBatch): IQueueBatch<T> {
117
+ const messages: readonly IQueueMessage<T>[] = batch.messages.map(
118
+ (message) => ({
119
+ id: message.id,
120
+ timestamp: new Date(message.timestampMillis),
121
+ body: JSON.parse(decoder.decode(decodeEdgeBytes(message.body))) as T,
122
+ attempts: message.attempts,
123
+ ack: () => message.acknowledge(),
124
+ // The facade rejects `delaySeconds: 0` on a retry; omitting it is the
125
+ // same request.
126
+ retry: (options) => message.retry(delayOption(options?.delaySeconds)),
127
+ }),
128
+ );
129
+ return {
130
+ queue: batch.queue,
131
+ messages,
132
+ ackAll: () => batch.acknowledgeAll(),
133
+ retryAll: (options) => batch.retryAll(delayOption(options?.delaySeconds)),
134
+ };
135
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `edge.sql@1.0.0` → Drizzle, through `drizzle-orm/sqlite-proxy`.
3
+ *
4
+ * Same seam as `managed-relational.ts`: one bounded prepared statement per
5
+ * callback, `batch()` as one ordered-atomic Host call. What is different is the
6
+ * ROW SHAPE. D1 hands Drizzle positional arrays (`stmt.raw()`) and
7
+ * `sqlite-proxy` maps `rows[i][j]` positionally onto the fields it compiled;
8
+ * `edge.sql` returns RECORDS keyed by result-column name, and a record cannot
9
+ * represent the duplicate names Drizzle's join SQL produces.
10
+ *
11
+ * The projection rewrite that makes those names distinct, the guard that
12
+ * refuses a row whose column count disagrees with the statement, and the row
13
+ * that answers to both positional and named reads all live in
14
+ * `sqlite-proxy-rows.ts`, shared with the managed relational lane. What is left
15
+ * here is the `edge.sql` value vocabulary and its request limits.
16
+ */
17
+
18
+ import {
19
+ drizzle as drizzleProxy,
20
+ type AsyncBatchRemoteCallback,
21
+ type AsyncRemoteCallback,
22
+ } from "drizzle-orm/sqlite-proxy";
23
+
24
+ import * as schema from "../../db/schema.ts";
25
+ import {
26
+ EDGE_SQL_MAX_PARAMETERS,
27
+ EDGE_SQL_MAX_STATEMENTS,
28
+ decodeEdgeBytes,
29
+ encodeEdgeBytes,
30
+ isEdgeEncodedBytes,
31
+ type EdgeSqlBinding,
32
+ type EdgeSqlResult,
33
+ type EdgeSqlValue,
34
+ } from "./edge-facades.ts";
35
+ import {
36
+ ProxyColumnMismatchError,
37
+ positionalRow,
38
+ rewriteProjection,
39
+ type ProjectedStatement,
40
+ } from "./sqlite-proxy-rows.ts";
41
+
42
+ /** The lane name a row-shape refusal reports. */
43
+ const LANE = "edge.sql";
44
+
45
+ /** The statement, or a value in it, cannot be expressed over `edge.sql`. */
46
+ export class EdgeSqlShapeError extends TypeError {
47
+ constructor(message: string) {
48
+ super(message);
49
+ this.name = "EdgeSqlShapeError";
50
+ }
51
+ }
52
+
53
+ /** Project one bound parameter into the facade's closed value vocabulary. */
54
+ export function toEdgeSqlValue(value: unknown): EdgeSqlValue {
55
+ if (value === null || value === undefined) return null;
56
+ if (typeof value === "string") return value;
57
+ if (typeof value === "boolean") return value ? 1 : 0;
58
+ if (typeof value === "number") {
59
+ if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) {
60
+ throw new EdgeSqlShapeError(
61
+ `edge.sql: ${value} is outside the range the facade carries`,
62
+ );
63
+ }
64
+ return value;
65
+ }
66
+ if (typeof value === "bigint") {
67
+ if (
68
+ value > BigInt(Number.MAX_SAFE_INTEGER) ||
69
+ value < BigInt(Number.MIN_SAFE_INTEGER)
70
+ ) {
71
+ throw new EdgeSqlShapeError(
72
+ `edge.sql: bigint ${value} is outside the safe-integer range`,
73
+ );
74
+ }
75
+ return Number(value);
76
+ }
77
+ if (value instanceof ArrayBuffer)
78
+ return encodeEdgeBytes(new Uint8Array(value));
79
+ if (ArrayBuffer.isView(value)) {
80
+ const view = value as ArrayBufferView;
81
+ return encodeEdgeBytes(
82
+ new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
83
+ );
84
+ }
85
+ throw new EdgeSqlShapeError(
86
+ `edge.sql: a ${typeof value} parameter has no portable encoding`,
87
+ );
88
+ }
89
+
90
+ /** Turn a returned value back into what the D1 driver would have produced. */
91
+ function fromEdgeSqlValue(value: EdgeSqlValue): unknown {
92
+ return isEdgeEncodedBytes(value) ? decodeEdgeBytes(value) : value;
93
+ }
94
+
95
+ function projectRows(
96
+ result: EdgeSqlResult,
97
+ projection: ProjectedStatement,
98
+ ): unknown[][] {
99
+ return result.rows.map((row) => {
100
+ const keys = Object.keys(row);
101
+ return positionalRow(
102
+ projection,
103
+ keys,
104
+ keys.map((key) => fromEdgeSqlValue(row[key]!)),
105
+ );
106
+ });
107
+ }
108
+
109
+ const TRANSACTION_CONTROL =
110
+ /^\s*(begin|commit|end|rollback|savepoint|release)\b/i;
111
+
112
+ interface PreparedStatement {
113
+ readonly sql: string;
114
+ readonly params: readonly EdgeSqlValue[];
115
+ readonly projection: ProjectedStatement;
116
+ }
117
+
118
+ function prepare(sql: string, params: readonly unknown[]): PreparedStatement {
119
+ if (TRANSACTION_CONTROL.test(sql)) {
120
+ throw new EdgeSqlShapeError(
121
+ `edge.sql: transaction control ("${sql.trim()}") is not on this request ` +
122
+ `path. Use db.batch([...]) — the facade's transaction() commits it ` +
123
+ `all-or-none in one Host call.`,
124
+ );
125
+ }
126
+ if (params.length > EDGE_SQL_MAX_PARAMETERS) {
127
+ throw new EdgeSqlShapeError(
128
+ `edge.sql: ${params.length} bound parameters exceed the facade limit of ` +
129
+ `${EDGE_SQL_MAX_PARAMETERS}; chunk the write (see src/db/d1-write.ts)`,
130
+ );
131
+ }
132
+ const rewritten = rewriteProjection(sql);
133
+ return {
134
+ sql: rewritten.sql,
135
+ params: params.map(toEdgeSqlValue),
136
+ projection: { lane: LANE, sql, columns: rewritten.columns },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Every statement goes through `execute`, never `query`.
142
+ *
143
+ * `query` is `execute` with an added refusal when the statement wrote anything,
144
+ * and Drizzle's read methods do not mean "reads nothing": an
145
+ * `insert ... returning` is compiled with method `all`. Choosing the method
146
+ * from Drizzle's would reject a legitimate write.
147
+ */
148
+ export function createEdgeSqlDatabase(binding: EdgeSqlBinding) {
149
+ const one = async (
150
+ statement: PreparedStatement,
151
+ method: "run" | "all" | "values" | "get",
152
+ ) => {
153
+ const result = await binding.execute(statement.sql, statement.params);
154
+ return shape(result, statement, method);
155
+ };
156
+
157
+ const callback: AsyncRemoteCallback = async (sql, params, method) =>
158
+ await one(prepare(sql, params), method);
159
+
160
+ const batchCallback: AsyncBatchRemoteCallback = async (batch) => {
161
+ if (batch.length > EDGE_SQL_MAX_STATEMENTS) {
162
+ throw new EdgeSqlShapeError(
163
+ `edge.sql: a batch of ${batch.length} statements exceeds the facade ` +
164
+ `limit of ${EDGE_SQL_MAX_STATEMENTS}`,
165
+ );
166
+ }
167
+ const prepared = batch.map((entry) => prepare(entry.sql, entry.params));
168
+ const results = await binding.transaction(
169
+ prepared.map((entry) => ({ sql: entry.sql, params: entry.params })),
170
+ );
171
+ if (results.length !== prepared.length) {
172
+ throw new ProxyColumnMismatchError(
173
+ `edge.sql: transaction returned ${results.length} results for ` +
174
+ `${prepared.length} statements`,
175
+ );
176
+ }
177
+ return results.map((result, index) =>
178
+ shape(result, prepared[index]!, batch[index]!.method),
179
+ );
180
+ };
181
+
182
+ return drizzleProxy(callback, batchCallback, { schema });
183
+ }
184
+
185
+ /**
186
+ * `sqlite-proxy` wants a flat row for `get` and an array of rows otherwise, and
187
+ * reads `run`'s result straight back to the caller — which is where
188
+ * `affectedRowCount` looks for `meta.changes`.
189
+ *
190
+ * A `get` that matched nothing must yield `undefined`, not an empty array:
191
+ * Drizzle's `mapGetResult` short-circuits on a falsy row, and an empty array is
192
+ * truthy, so `[]` would be mapped into an object whose every field is
193
+ * `undefined` — a "row" for a query that found none.
194
+ */
195
+ function shape(
196
+ result: EdgeSqlResult,
197
+ statement: PreparedStatement,
198
+ method: "run" | "all" | "values" | "get",
199
+ ): { rows: unknown[]; meta: { changes: number } } {
200
+ const rows = projectRows(result, statement.projection);
201
+ return {
202
+ rows: (method === "get" ? rows[0] : rows) as unknown[],
203
+ meta: { changes: result.rowsWritten },
204
+ };
205
+ }
206
+
207
+ export type EdgeSqlDatabase = ReturnType<typeof createEdgeSqlDatabase>;