@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,309 @@
1
+ /**
2
+ * Row shape for every `drizzle-orm/sqlite-proxy` lane.
3
+ *
4
+ * `sqlite-proxy` maps `rows[i][j]` POSITIONALLY onto the fields Drizzle
5
+ * compiled, so a lane is only correct while the j-th cell it hands back is the
6
+ * j-th item of the projection list that went out. Two things break that, and
7
+ * both break it SILENTLY:
8
+ *
9
+ * 1. Duplicate result-column names. Drizzle's join SQL is
10
+ *
11
+ * select "inbox"."actor_ap_id", ..., "activities"."actor_ap_id", ...
12
+ * from "inbox" inner join "activities" on ...
13
+ *
14
+ * and SQLite names both result columns `actor_ap_id`. Any driver, facade, or
15
+ * Host hop that materializes a row as a RECORD keeps one of them and every
16
+ * later field shifts by one. (`created_at` collides in the same query, and
17
+ * JavaScript's key ordering adds a second trap: an integer-like name such as
18
+ * the `1` of `select 1` sorts ahead of every other key.) So this module
19
+ * rewrites the projection list before it is sent, giving every unaliased
20
+ * item a unique, non-numeric name (`__c0`, `__c1`, ...). Drizzle never looks
21
+ * at result-column names, so the rename is invisible to it. The rewrite is
22
+ * then BACKED BY A GUARD: a row that comes back with a different number of
23
+ * columns than the statement projected throws instead of being mapped.
24
+ *
25
+ * 2. Rows with no names at all. Drizzle reads a row positionally, but
26
+ * `db.get(sql`... AS matched`)` — a raw statement with no compiled fields —
27
+ * is handed the driver's row untouched, and on D1 that is a record: call
28
+ * sites read `row.matched`. A bare array answers `undefined` to those reads,
29
+ * and several of them gate block/mute enforcement, where `undefined` reads
30
+ * as "not blocked". So the row this module builds answers to BOTH access
31
+ * styles: an array Drizzle can index, carrying its column names as
32
+ * non-enumerable properties.
33
+ *
34
+ * Lanes differ only in how they obtain the (names, values) pair — `edge.sql`
35
+ * from a record per row, the managed relational runtime from a `columns` list
36
+ * beside positional rows — so both share everything below.
37
+ */
38
+
39
+ /** A row came back with a different column count than the statement projected. */
40
+ export class ProxyColumnMismatchError extends Error {
41
+ constructor(message: string) {
42
+ super(message);
43
+ this.name = "ProxyColumnMismatchError";
44
+ }
45
+ }
46
+
47
+ const PROJECTION_ALIAS_PREFIX = "__c";
48
+
49
+ const SELECT_LIST_TERMINATORS = new Set([
50
+ "from",
51
+ "where",
52
+ "group",
53
+ "having",
54
+ "window",
55
+ "order",
56
+ "limit",
57
+ "offset",
58
+ "union",
59
+ "except",
60
+ "intersect",
61
+ ]);
62
+
63
+ interface ScannedWord {
64
+ readonly word: string;
65
+ readonly start: number;
66
+ readonly end: number;
67
+ }
68
+
69
+ interface Scan {
70
+ readonly words: readonly ScannedWord[];
71
+ readonly commas: readonly number[];
72
+ }
73
+
74
+ function isWordChar(ch: string): boolean {
75
+ return /[A-Za-z0-9_$]/.test(ch);
76
+ }
77
+
78
+ /**
79
+ * Walk the statement recording the bare words and commas that sit at
80
+ * parenthesis depth zero. Quoted identifiers, string literals, and comments are
81
+ * skipped whole, so a `,` inside `'a,b'` or a `from` inside a subquery is never
82
+ * mistaken for structure.
83
+ */
84
+ function scanTopLevel(sql: string): Scan {
85
+ const words: ScannedWord[] = [];
86
+ const commas: number[] = [];
87
+ let depth = 0;
88
+ let index = 0;
89
+ while (index < sql.length) {
90
+ const ch = sql[index]!;
91
+ if (ch === "'" || ch === '"' || ch === "`") {
92
+ index += 1;
93
+ while (index < sql.length) {
94
+ if (sql[index] === ch) {
95
+ if (sql[index + 1] === ch) index += 2;
96
+ else {
97
+ index += 1;
98
+ break;
99
+ }
100
+ } else index += 1;
101
+ }
102
+ continue;
103
+ }
104
+ if (ch === "[") {
105
+ const close = sql.indexOf("]", index + 1);
106
+ index = close === -1 ? sql.length : close + 1;
107
+ continue;
108
+ }
109
+ if (ch === "-" && sql[index + 1] === "-") {
110
+ const newline = sql.indexOf("\n", index);
111
+ index = newline === -1 ? sql.length : newline + 1;
112
+ continue;
113
+ }
114
+ if (ch === "/" && sql[index + 1] === "*") {
115
+ const close = sql.indexOf("*/", index + 2);
116
+ index = close === -1 ? sql.length : close + 2;
117
+ continue;
118
+ }
119
+ if (ch === "(") {
120
+ depth += 1;
121
+ index += 1;
122
+ continue;
123
+ }
124
+ if (ch === ")") {
125
+ depth -= 1;
126
+ index += 1;
127
+ continue;
128
+ }
129
+ if (ch === ",") {
130
+ if (depth === 0) commas.push(index);
131
+ index += 1;
132
+ continue;
133
+ }
134
+ if (isWordChar(ch) && !/[0-9]/.test(ch)) {
135
+ const start = index;
136
+ while (index < sql.length && isWordChar(sql[index]!)) index += 1;
137
+ if (depth === 0) {
138
+ words.push({
139
+ word: sql.slice(start, index).toLowerCase(),
140
+ start,
141
+ end: index,
142
+ });
143
+ }
144
+ continue;
145
+ }
146
+ if (/[0-9]/.test(ch)) {
147
+ while (index < sql.length && isWordChar(sql[index]!)) index += 1;
148
+ continue;
149
+ }
150
+ index += 1;
151
+ }
152
+ return { words, commas };
153
+ }
154
+
155
+ /** Does this projection item already carry its own `as "name"` alias? */
156
+ function hasOwnAlias(item: string): boolean {
157
+ return scanTopLevel(item).words.some((word) => word.word === "as");
158
+ }
159
+
160
+ export interface RewrittenStatement {
161
+ readonly sql: string;
162
+ /**
163
+ * How many result columns the statement projects, when that could be
164
+ * determined. `undefined` means the guard cannot check this statement — a
165
+ * `select *`, or a shape with no projection list at all.
166
+ */
167
+ readonly columns: number | undefined;
168
+ }
169
+
170
+ /**
171
+ * Give every projected column a distinct, non-numeric name.
172
+ *
173
+ * Handles the two shapes Drizzle emits: a `select` list (including one that
174
+ * follows a `with` clause, whose CTE bodies are parenthesized and therefore
175
+ * invisible to a depth-zero scan) and a `returning` list on a write. For a
176
+ * compound `select ... union select ...` only the first arm is rewritten, which
177
+ * is sufficient: SQLite takes a compound select's result-column names from its
178
+ * first arm.
179
+ *
180
+ * Anything else — a `pragma`, a `create table`, a `select *` — is returned
181
+ * untouched with no column count, because there is nothing safe to rename.
182
+ */
183
+ export function rewriteProjection(sql: string): RewrittenStatement {
184
+ const scan = scanTopLevel(sql);
185
+ const first = scan.words[0];
186
+ if (!first) return { sql, columns: undefined };
187
+
188
+ let listStart: number;
189
+ let listEnd: number;
190
+
191
+ if (first.word === "select" || first.word === "with") {
192
+ const selectAt = scan.words.findIndex((word) => word.word === "select");
193
+ if (selectAt === -1) return { sql, columns: undefined };
194
+ const select = scan.words[selectAt]!;
195
+ // The list begins right after the keyword, NOT at the next bare word: an
196
+ // item usually opens with a quoted identifier, which the scanner skips.
197
+ listStart = select.end;
198
+ let cursor = selectAt + 1;
199
+ const next = scan.words[cursor];
200
+ if (
201
+ next &&
202
+ (next.word === "distinct" || next.word === "all") &&
203
+ sql.slice(select.end, next.start).trim() === ""
204
+ ) {
205
+ listStart = next.end;
206
+ cursor += 1;
207
+ }
208
+ const terminator = scan.words
209
+ .slice(cursor)
210
+ .find((word) => SELECT_LIST_TERMINATORS.has(word.word));
211
+ listEnd = terminator ? terminator.start : sql.length;
212
+ } else {
213
+ // insert / update / delete: only a `returning` list is projected.
214
+ const returning = [...scan.words]
215
+ .reverse()
216
+ .find((word) => word.word === "returning");
217
+ if (!returning) return { sql, columns: undefined };
218
+ listStart = returning.end;
219
+ listEnd = sql.length;
220
+ }
221
+
222
+ if (listEnd <= listStart) return { sql, columns: undefined };
223
+
224
+ const boundaries = [
225
+ listStart,
226
+ ...scan.commas.filter((at) => at > listStart && at < listEnd),
227
+ listEnd,
228
+ ];
229
+ const items: { text: string; start: number; end: number }[] = [];
230
+ for (let index = 0; index + 1 < boundaries.length; index += 1) {
231
+ const start = index === 0 ? boundaries[0]! : boundaries[index]! + 1;
232
+ const end = boundaries[index + 1]!;
233
+ items.push({ text: sql.slice(start, end), start, end });
234
+ }
235
+ if (items.length === 0) return { sql, columns: undefined };
236
+
237
+ // `select *` and `"t".*` cannot take an alias, and their column count is not
238
+ // knowable from the text. Leave the whole statement alone.
239
+ if (items.some((item) => /(^|\.)\s*\*\s*$/.test(item.text.trim()))) {
240
+ return { sql, columns: undefined };
241
+ }
242
+
243
+ let out = "";
244
+ let cursor = 0;
245
+ items.forEach((item, position) => {
246
+ out += sql.slice(cursor, item.end);
247
+ if (!hasOwnAlias(item.text)) {
248
+ const trailing = item.text.length - item.text.trimEnd().length;
249
+ // Insert before the item's own trailing whitespace so the statement keeps
250
+ // its shape.
251
+ out =
252
+ out.slice(0, out.length - trailing) +
253
+ ` as "${PROJECTION_ALIAS_PREFIX}${position}"` +
254
+ out.slice(out.length - trailing);
255
+ }
256
+ cursor = item.end;
257
+ });
258
+ out += sql.slice(cursor);
259
+ return { sql: out, columns: items.length };
260
+ }
261
+
262
+ /** What a lane must remember about a statement to shape its rows back. */
263
+ export interface ProjectedStatement {
264
+ /** Which lane is speaking, so a refusal names the surface that refused. */
265
+ readonly lane: string;
266
+ /** The statement as the caller wrote it, for the refusal message. */
267
+ readonly sql: string;
268
+ /** `rewriteProjection`'s column count, or `undefined` when unknowable. */
269
+ readonly columns: number | undefined;
270
+ }
271
+
272
+ /** Array indices and `length` are the only names an array cannot also carry. */
273
+ function canCarryName(key: string): boolean {
274
+ return key !== "length" && String(Number(key)) !== key;
275
+ }
276
+
277
+ /**
278
+ * Build the row `sqlite-proxy` indexes positionally, carrying its column names.
279
+ *
280
+ * Fails closed first: if the lane came back with a different number of columns
281
+ * than the statement projected, the cells no longer line up with the fields
282
+ * Drizzle compiled, and returning them would be a silent mis-read.
283
+ */
284
+ export function positionalRow(
285
+ statement: ProjectedStatement,
286
+ columns: readonly string[],
287
+ values: readonly unknown[],
288
+ ): unknown[] {
289
+ if (statement.columns !== undefined && columns.length !== statement.columns) {
290
+ throw new ProxyColumnMismatchError(
291
+ `${statement.lane} returned ${columns.length} columns for a statement ` +
292
+ `projecting ${statement.columns}; the row cannot be mapped ` +
293
+ `positionally. Statement: ${statement.sql}`,
294
+ );
295
+ }
296
+ const row = [...values];
297
+ for (let index = 0; index < columns.length; index += 1) {
298
+ const key = columns[index]!;
299
+ if (canCarryName(key)) {
300
+ Object.defineProperty(row, key, {
301
+ value: values[index],
302
+ enumerable: false,
303
+ configurable: true,
304
+ writable: true,
305
+ });
306
+ }
307
+ }
308
+ return row;
309
+ }
@@ -89,6 +89,11 @@ type LocalRuntimeEnvKey = Exclude<keyof EnvVars, "APP_URL">;
89
89
  // setting without an explicit local-runtime decision, instead of silently
90
90
  // dropping authority/security configuration such as OIDC_OWNER_SUB.
91
91
  const ENV_PASSTHROUGH_KEY_SET: Record<LocalRuntimeEnvKey, true> = {
92
+ // Carried so a shared env file reads the same everywhere, but nothing here
93
+ // consults it: this server builds the runtime ports directly from its own
94
+ // compat classes rather than wrapping a Worker's bindings, so it is neither
95
+ // the `cloudflare` nor the `portable` lane. See runtime/lane.ts.
96
+ YURUCOMMU_RUNTIME_LANE: true,
92
97
  ENABLE_TAKOS_TOOLS: true,
93
98
  AUTH_PASSWORD_HASH: true,
94
99
  GOOGLE_CLIENT_ID: true,
@@ -16,6 +16,15 @@ import type {
16
16
  export interface EnvVars {
17
17
  APP_URL: string;
18
18
 
19
+ // What shape the Worker's bindings arrive in. Unset (or "cloudflare") = raw
20
+ // Cloudflare bindings, which is also what an ordinary-Workers Takoserver
21
+ // backend projects; "portable" = the edge.sql / edge.kv / edge.objects /
22
+ // edge.queue facades a wrapper host (self-host, managed
23
+ // Workers-for-Platforms) projects. The value is checked against the bindings
24
+ // and a disagreement refuses to start — see runtime/lane.ts and
25
+ // docs/design/runtime-lanes.md.
26
+ YURUCOMMU_RUNTIME_LANE?: string;
27
+
19
28
  // Takos-specific endpoints are opt-in (fail-close by default).
20
29
  ENABLE_TAKOS_TOOLS?: string;
21
30
 
package/src/db/index.ts CHANGED
@@ -18,12 +18,13 @@ import { drizzle as drizzleD1, type DrizzleD1Database } from "drizzle-orm/d1";
18
18
  import type { LibSQLDatabase } from "drizzle-orm/libsql";
19
19
  import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
20
20
  import type { ResultSet } from "@libsql/client";
21
+ import type { SqliteRemoteResult } from "drizzle-orm/sqlite-proxy";
21
22
  import { isNull } from "drizzle-orm";
22
23
  import * as schema from "./schema.ts";
23
24
 
24
25
  export type Database = BaseSQLiteDatabase<
25
26
  "async",
26
- D1Result | ResultSet,
27
+ D1Result | ResultSet | SqliteRemoteResult,
27
28
  typeof schema
28
29
  >;
29
30