@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.
@@ -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>;