@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.
- 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/public.ts +60 -0
- package/src/backend/routes/media.ts +4 -0
- 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/sqlite-proxy-rows.ts +309 -0
- package/src/backend/server.ts +5 -0
- package/src/backend/types.ts +9 -0
- package/src/db/index.ts +2 -1
|
@@ -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
|
+
}
|
package/src/backend/server.ts
CHANGED
|
@@ -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,
|
package/src/backend/types.ts
CHANGED
|
@@ -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
|
|