@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,291 @@
1
+ /**
2
+ * Which runtime the Worker was published onto, and the bindings that follow.
3
+ *
4
+ * The same bundle runs on two backends that look identical from inside:
5
+ *
6
+ * `cloudflare` — RAW Cloudflare bindings. `env.DB` is a `D1Database`,
7
+ * `env.KV` a `KVNamespace`, `env.MEDIA` an `R2Bucket`,
8
+ * `env.DELIVERY_QUEUE` a `Queue`. This is a Worker deployed
9
+ * straight to Cloudflare, and equally an ordinary-Workers
10
+ * Takoserver backend, which projects those same raw bindings.
11
+ * `portable` — the PORTABLE FACADES. A wrapper host — a self-hosted
12
+ * Takoserver, or a managed Workers-for-Platforms backend —
13
+ * replaces `env` before the module sees it, and each binding
14
+ * is the facade its Interface names: `edge.sql`, `edge.kv`,
15
+ * `edge.objects`, `edge.queue`.
16
+ *
17
+ * THE LANE NAMES THE BINDING SHAPE, not the tool that published the Worker. A
18
+ * deployment authored in Takoform lands on either one depending on the host it
19
+ * targets, so the lane cannot be inferred from the IaC that produced it.
20
+ *
21
+ * THE LANE IS DECLARED, NOT SNIFFED. Two of the bindings cannot be told apart
22
+ * by shape at all — `edge.kv` and `KVNamespace` expose the same five method
23
+ * names, and both queue producers are `send`/`sendBatch`. A Worker that guessed
24
+ * would call `kv.get(key, {type:"json"})` on a facade that ignores the second
25
+ * argument and returns bytes, and the failure would surface much later as a
26
+ * corrupt session or a rate-limit that never trips.
27
+ *
28
+ * So the lane comes from `YURUCOMMU_RUNTIME_LANE`, which a self-host or managed
29
+ * Workers-for-Platforms deployment sets to `portable` and every raw-binding
30
+ * deployment leaves unset (or `cloudflare`). The declaration is then
31
+ * cross-checked against the bindings that ARE decisive — `DB` always, `MEDIA`
32
+ * when it is bound. A disagreement refuses to start.
33
+ */
34
+
35
+ import type {
36
+ D1Database,
37
+ Fetcher,
38
+ KVNamespace,
39
+ MessageBatch,
40
+ Queue,
41
+ R2Bucket,
42
+ } from "@cloudflare/workers-types";
43
+
44
+ import type { Database } from "../../db/index.ts";
45
+ import {
46
+ isEdgeObjectsBinding,
47
+ isEdgeQueueBatch,
48
+ isEdgeSqlBinding,
49
+ isNativeD1Database,
50
+ isNativeR2Bucket,
51
+ type EdgeKvBinding,
52
+ type EdgeObjectsBinding,
53
+ type EdgeQueueBatch,
54
+ type EdgeQueueBinding,
55
+ type EdgeSqlBinding,
56
+ } from "./edge-facades.ts";
57
+ import { createEdgeSqlDatabase } from "./edge-sql.ts";
58
+ import { wrapEdgeKv } from "./edge-kv.ts";
59
+ import { wrapEdgeMessageBatch, wrapEdgeQueue } from "./edge-queue.ts";
60
+ import { wrapEdgeObjects } from "./edge-objects.ts";
61
+ import {
62
+ wrapCloudflareBindings,
63
+ wrapCloudflareMessageBatch,
64
+ } from "./cloudflare.ts";
65
+ import type { IKeyValueStore, IStaticAssets, ObjectStore } from "./types.ts";
66
+ import type { IQueueBatch, IQueueProducer } from "./queue.ts";
67
+
68
+ /** The variable that names the lane. Set it in the deployment's plain vars. */
69
+ export const RUNTIME_LANE_VAR = "YURUCOMMU_RUNTIME_LANE";
70
+
71
+ /** Every lane this build knows how to run on. */
72
+ export const RUNTIME_LANES = ["cloudflare", "portable"] as const;
73
+
74
+ export type RuntimeLane = (typeof RUNTIME_LANES)[number];
75
+
76
+ /** The lane when the variable is absent: a plain Cloudflare Worker. */
77
+ export const DEFAULT_RUNTIME_LANE: RuntimeLane = "cloudflare";
78
+
79
+ /** The declared lane is unknown, or disagrees with the bindings that arrived. */
80
+ export class RuntimeLaneError extends Error {
81
+ constructor(message: string) {
82
+ super(message);
83
+ this.name = "RuntimeLaneError";
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read the declared lane.
89
+ *
90
+ * An unset variable is the Cloudflare lane, because that is what a Worker
91
+ * deployed without Takoform is. An UNRECOGNISED value is refused rather than
92
+ * defaulted: a future Host that names a lane this build has never heard of must
93
+ * not be served by guessing that its bindings are Cloudflare's.
94
+ */
95
+ export function resolveRuntimeLane(declared: unknown): RuntimeLane {
96
+ if (declared === undefined || declared === null || declared === "") {
97
+ return DEFAULT_RUNTIME_LANE;
98
+ }
99
+ if (typeof declared !== "string") {
100
+ throw new RuntimeLaneError(
101
+ `${RUNTIME_LANE_VAR} must be a string; received ${typeof declared}`,
102
+ );
103
+ }
104
+ const lane = declared.trim();
105
+ if ((RUNTIME_LANES as readonly string[]).includes(lane)) {
106
+ return lane as RuntimeLane;
107
+ }
108
+ throw new RuntimeLaneError(
109
+ `${RUNTIME_LANE_VAR}="${declared}" is not a runtime lane this build ` +
110
+ `supports (${RUNTIME_LANES.join(", ")}). Refusing to start rather than ` +
111
+ `assume a binding shape.`,
112
+ );
113
+ }
114
+
115
+ interface LaneBindings {
116
+ readonly DB?: unknown;
117
+ readonly MEDIA?: unknown;
118
+ }
119
+
120
+ /**
121
+ * Prove the declared lane against the bindings that can actually be identified.
122
+ *
123
+ * `DB` is always decisive: `execute`/`query`/`transaction` and
124
+ * `prepare`/`batch` are disjoint. `MEDIA` is decisive only in one direction —
125
+ * an `R2Bucket` is recognisable by its multipart helpers, whereas a plain
126
+ * five-method object could be the facade or an adapter a host repository
127
+ * supplied — so only the direction that can be proven is checked.
128
+ */
129
+ export function assertRuntimeLaneBindings(
130
+ lane: RuntimeLane,
131
+ bindings: LaneBindings,
132
+ ): void {
133
+ const { DB, MEDIA } = bindings;
134
+ if (lane === "portable") {
135
+ if (isNativeD1Database(DB)) {
136
+ throw new RuntimeLaneError(
137
+ `${RUNTIME_LANE_VAR}="portable" declares the portable-facade lane, ` +
138
+ `but env.DB is a native D1Database (prepare/batch). A host that ` +
139
+ `projects raw Cloudflare bindings — including an ordinary-Workers ` +
140
+ `Takoserver backend — is the cloudflare lane; leave the variable ` +
141
+ `unset there.`,
142
+ );
143
+ }
144
+ if (!isEdgeSqlBinding(DB)) {
145
+ throw new RuntimeLaneError(
146
+ `${RUNTIME_LANE_VAR}="portable" requires env.DB to be the ` +
147
+ `edge.sql@1.0.0 facade (execute/query/transaction); it exposes ` +
148
+ `neither that nor D1's prepare/batch.`,
149
+ );
150
+ }
151
+ if (MEDIA !== undefined && isNativeR2Bucket(MEDIA)) {
152
+ throw new RuntimeLaneError(
153
+ `${RUNTIME_LANE_VAR}="portable" declares the portable-facade lane, ` +
154
+ `but env.MEDIA is a native R2Bucket. A portable bucket binding ` +
155
+ `arrives as the edge.objects@1.0.0 facade.`,
156
+ );
157
+ }
158
+ return;
159
+ }
160
+ if (isEdgeSqlBinding(DB)) {
161
+ throw new RuntimeLaneError(
162
+ `env.DB is the edge.sql@1.0.0 facade (execute/query/transaction), but ` +
163
+ `${RUNTIME_LANE_VAR} does not declare the portable lane. A Worker on a ` +
164
+ `wrapper host must declare it; without that this build would hand the ` +
165
+ `facade to drizzle-orm/d1 and every query would fail at the first ` +
166
+ `prepare().`,
167
+ );
168
+ }
169
+ if (!isNativeD1Database(DB)) {
170
+ throw new RuntimeLaneError(
171
+ `env.DB is neither a D1Database nor the edge.sql@1.0.0 facade; the ` +
172
+ `Cloudflare lane cannot build a database client from it.`,
173
+ );
174
+ }
175
+ }
176
+
177
+ /** Bindings a Worker receives from a host that projects portable facades. */
178
+ export interface PortableWorkerBindings {
179
+ DB: EdgeSqlBinding;
180
+ KV: EdgeKvBinding;
181
+ MEDIA?: EdgeObjectsBinding;
182
+ ASSETS?: IStaticAssets;
183
+ DELIVERY_QUEUE?: EdgeQueueBinding;
184
+ DELIVERY_DLQ?: EdgeQueueBinding;
185
+ }
186
+
187
+ /** Bindings a Worker deployed straight to Cloudflare receives. */
188
+ export interface CloudflareWorkerBindings {
189
+ DB: D1Database;
190
+ KV: KVNamespace;
191
+ MEDIA?: R2Bucket;
192
+ ASSETS?: Fetcher;
193
+ DELIVERY_QUEUE?: Queue<unknown>;
194
+ DELIVERY_DLQ?: Queue<unknown>;
195
+ }
196
+
197
+ type WrappedRuntime<T> = Omit<
198
+ T,
199
+ "DB" | "MEDIA" | "KV" | "ASSETS" | "DELIVERY_QUEUE" | "DELIVERY_DLQ"
200
+ > & {
201
+ DB_INSTANCE: Database;
202
+ MEDIA?: ObjectStore;
203
+ KV: IKeyValueStore;
204
+ ASSETS?: IStaticAssets;
205
+ DELIVERY_QUEUE?: IQueueProducer<unknown>;
206
+ DELIVERY_DLQ?: IQueueProducer<unknown>;
207
+ };
208
+
209
+ /**
210
+ * Wrap the portable facades into the runtime ports the app speaks.
211
+ *
212
+ * `ASSETS` passes through: a Takoform `external_services` entry is projected as
213
+ * a `{fetch}` adapter, which is already the port's whole surface.
214
+ */
215
+ export function wrapPortableBindings<T extends PortableWorkerBindings>(
216
+ bindings: T,
217
+ ): WrappedRuntime<T> {
218
+ const { DB, MEDIA, KV, ASSETS, DELIVERY_QUEUE, DELIVERY_DLQ, ...rest } =
219
+ bindings;
220
+ return {
221
+ ...rest,
222
+ DB_INSTANCE: createEdgeSqlDatabase(DB),
223
+ MEDIA: MEDIA ? wrapEdgeObjects(MEDIA) : undefined,
224
+ KV: wrapEdgeKv(KV),
225
+ ASSETS,
226
+ DELIVERY_QUEUE: DELIVERY_QUEUE ? wrapEdgeQueue(DELIVERY_QUEUE) : undefined,
227
+ DELIVERY_DLQ: DELIVERY_DLQ ? wrapEdgeQueue(DELIVERY_DLQ) : undefined,
228
+ } as unknown as WrappedRuntime<T>;
229
+ }
230
+
231
+ /**
232
+ * The single entry point a Worker should call.
233
+ *
234
+ * Reads {@link RUNTIME_LANE_VAR} off the bindings themselves — on both lanes it
235
+ * is an ordinary plain-text variable that arrives alongside them — proves the
236
+ * lane against the decisive bindings, and then wraps.
237
+ */
238
+ export function wrapRuntimeBindings<
239
+ // Deliberately structural. Which of the two binding sets this actually is,
240
+ // is the runtime question this function answers; a static union here would
241
+ // only force every caller to assert the answer before asking it.
242
+ T extends { DB: unknown; KV: unknown },
243
+ >(bindings: T): WrappedRuntime<T> {
244
+ const lane = resolveRuntimeLane(
245
+ (bindings as Record<string, unknown>)[RUNTIME_LANE_VAR],
246
+ );
247
+ assertRuntimeLaneBindings(lane, bindings as LaneBindings);
248
+ return lane === "portable"
249
+ ? (wrapPortableBindings(
250
+ bindings as unknown as PortableWorkerBindings,
251
+ ) as unknown as WrappedRuntime<T>)
252
+ : (wrapCloudflareBindings(
253
+ bindings as unknown as CloudflareWorkerBindings & {
254
+ DB: D1Database;
255
+ KV: KVNamespace;
256
+ },
257
+ ) as unknown as WrappedRuntime<T>);
258
+ }
259
+
260
+ /**
261
+ * Adapt one consumer batch for whichever lane produced it.
262
+ *
263
+ * A queue batch IS decisive — the facade settles with `acknowledgeAll`, the
264
+ * Cloudflare `MessageBatch` with `ackAll` — so the shape is checked against the
265
+ * declared lane rather than trusted on its own.
266
+ */
267
+ export function wrapRuntimeMessageBatch<T>(
268
+ batch: MessageBatch<T> | EdgeQueueBatch,
269
+ lane: RuntimeLane = DEFAULT_RUNTIME_LANE,
270
+ ): IQueueBatch<T> {
271
+ const isFacade = isEdgeQueueBatch(batch);
272
+ if (lane === "portable") {
273
+ if (!isFacade) {
274
+ throw new RuntimeLaneError(
275
+ `${RUNTIME_LANE_VAR}="portable" declares the portable-facade lane, ` +
276
+ `but the queue event is a Cloudflare MessageBatch (ackAll).`,
277
+ );
278
+ }
279
+ return wrapEdgeMessageBatch<T>(batch);
280
+ }
281
+ if (isFacade) {
282
+ throw new RuntimeLaneError(
283
+ `The queue event is a portable-facade batch (acknowledgeAll), but ` +
284
+ `${RUNTIME_LANE_VAR} does not declare the portable lane.`,
285
+ );
286
+ }
287
+ return wrapCloudflareMessageBatch(batch as MessageBatch<T>);
288
+ }
289
+
290
+ /** Re-exported so a Worker entry can probe MEDIA without importing internals. */
291
+ export { isEdgeObjectsBinding };
@@ -5,6 +5,7 @@ import {
5
5
  parseManagedRelationalBatchResponse,
6
6
  type ManagedRelationalMethod,
7
7
  type ManagedRelationalParameter,
8
+ type ManagedRelationalStatement,
8
9
  } from "@takosjp/takosumi-contract/managed-relational-runtime";
9
10
  import { parseManagedRuntimeConnectionMaterialization } from "@takosjp/takosumi-contract/managed-runtime-connections";
10
11
  import {
@@ -16,9 +17,17 @@ import {
16
17
  import * as schema from "../../db/schema.ts";
17
18
  import { ManagedRuntimeGatewayError } from "./managed-runtime.ts";
18
19
  import type { ManagedRuntimeGateway } from "./managed-runtime.ts";
20
+ import {
21
+ positionalRow,
22
+ rewriteProjection,
23
+ type ProjectedStatement,
24
+ } from "./sqlite-proxy-rows.ts";
19
25
 
20
26
  const DEFAULT_MAX_RELATIONAL_RESPONSE_BYTES = 8 * 1024 * 1024;
21
27
 
28
+ /** The lane name a row-shape refusal reports. */
29
+ const LANE = "managed relational";
30
+
22
31
  export interface ManagedRelationalDatabaseOptions {
23
32
  readonly materialization: unknown;
24
33
  readonly gateway: ManagedRuntimeGateway;
@@ -33,6 +42,11 @@ export interface ManagedRelationalDatabaseOptions {
33
42
  * Every callback is one bounded prepared statement. Drizzle `batch()` maps to
34
43
  * one ordered-atomic host call; transaction-control and migration SQL are
35
44
  * deliberately unavailable on this request path.
45
+ *
46
+ * Row shape is shared with the `edge.sql` lane (`sqlite-proxy-rows.ts`): the
47
+ * projection list is rewritten so no two result columns share a name, the
48
+ * column count that comes back is checked against the count that went out, and
49
+ * the row Drizzle indexes positionally also answers to its column names.
36
50
  */
37
51
  export function createManagedRelationalDatabase(
38
52
  options: ManagedRelationalDatabaseOptions,
@@ -64,13 +78,9 @@ export function createManagedRelationalDatabase(
64
78
  readonly method: ManagedRelationalMethod;
65
79
  }[],
66
80
  ) => {
67
- const canonical = statements.map((statement) => ({
68
- sql: statement.sql,
69
- params: statement.params.map(relationalParameter),
70
- method: statement.method,
71
- }));
81
+ const prepared = statements.map(prepare);
72
82
  const request = managedRelationalBatchGatewayRequest(connection.authority, {
73
- statements: canonical,
83
+ statements: prepared.map((entry) => entry.statement),
74
84
  idempotencyKey: idempotencyKey(),
75
85
  });
76
86
  const response = await boundedResponse(
@@ -91,40 +101,77 @@ export function createManagedRelationalDatabase(
91
101
  }
92
102
  const value = parseManagedRelationalBatchResponse(
93
103
  await response.json(),
94
- canonical.length,
104
+ prepared.length,
105
+ );
106
+ return value.results.map((result, index) =>
107
+ drizzleResult(result, prepared[index]!),
95
108
  );
96
- return value.results;
97
109
  };
98
110
 
99
111
  const callback: AsyncRemoteCallback = async (sql, params, method) => {
100
112
  const [result] = await execute([{ sql, params, method }]);
101
113
  if (!result) throw new Error("managed_relational_result_missing");
102
- return drizzleResult(result, method);
103
- };
104
- const batchCallback: AsyncBatchRemoteCallback = async (batch) => {
105
- const results = await execute(batch);
106
- return results.map((result, index) =>
107
- drizzleResult(result, batch[index]!.method),
108
- );
114
+ return result;
109
115
  };
116
+ const batchCallback: AsyncBatchRemoteCallback = async (batch) =>
117
+ await execute(batch);
110
118
 
111
119
  return drizzleProxy(callback, batchCallback, { schema });
112
120
  }
113
121
 
122
+ interface PreparedStatement {
123
+ readonly statement: ManagedRelationalStatement;
124
+ readonly projection: ProjectedStatement;
125
+ }
126
+
127
+ /**
128
+ * Trim first, then give every projected column a distinct name.
129
+ *
130
+ * The runtime contract refuses a statement whose text is not already trimmed,
131
+ * and Drizzle does not trim the raw `sql` template a call site wrote across
132
+ * several lines — so the personal block and mute gates' own statements would
133
+ * never reach the host at all.
134
+ */
135
+ function prepare(statement: {
136
+ readonly sql: string;
137
+ readonly params: readonly unknown[];
138
+ readonly method: ManagedRelationalMethod;
139
+ }): PreparedStatement {
140
+ const source = statement.sql.trim();
141
+ const rewritten = rewriteProjection(source);
142
+ return {
143
+ statement: {
144
+ sql: rewritten.sql,
145
+ params: statement.params.map(relationalParameter),
146
+ method: statement.method,
147
+ },
148
+ projection: { lane: LANE, sql: source, columns: rewritten.columns },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * `sqlite-proxy` wants a flat row for `get` and an array of rows otherwise.
154
+ *
155
+ * A `get` that matched nothing must yield `undefined`, not an empty array:
156
+ * Drizzle's `mapGetResult` short-circuits on a FALSY row, and `[]` is truthy,
157
+ * so an empty array is mapped into an object whose every field is `undefined` —
158
+ * a "row" for a query that found none, which `if (exact) return true` in the
159
+ * personal block gate reads as a hit.
160
+ */
114
161
  function drizzleResult(
115
162
  result: Awaited<
116
163
  ReturnType<typeof parseManagedRelationalBatchResponse>
117
164
  >["results"][number],
118
- method: ManagedRelationalMethod,
165
+ prepared: PreparedStatement,
119
166
  ) {
167
+ // sqlite-proxy exposes mutable `any[]` at this boundary even though the
168
+ // public runtime contract is intentionally immutable. `positionalRow` copies,
169
+ // so the provider-neutral contract never leaks a mutable result reference.
170
+ const rows = result.rows.map((row) =>
171
+ positionalRow(prepared.projection, result.columns, row),
172
+ );
120
173
  return {
121
- // sqlite-proxy exposes mutable `any[]` at this boundary even though the
122
- // public runtime contract is intentionally immutable. Copy here so the
123
- // provider-neutral contract never leaks a mutable result reference.
124
- rows:
125
- method === "get"
126
- ? [...(result.rows[0] ?? [])]
127
- : result.rows.map((row) => [...row]),
174
+ rows: (prepared.statement.method === "get" ? rows[0] : rows) as unknown[],
128
175
  meta: result.meta,
129
176
  };
130
177
  }