@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.
- 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/lib/blocklist-purge.ts +6 -6
- package/src/backend/public.ts +69 -4
- package/src/backend/routes/account-teardown.ts +2 -2
- package/src/backend/routes/apps.ts +4 -5
- package/src/backend/routes/media.ts +8 -5
- package/src/backend/routes/posts/delete-cascade.ts +7 -7
- package/src/backend/routes/stories/query-helpers.ts +2 -2
- package/src/backend/routes/stories/routes.ts +2 -5
- package/src/backend/runtime/bun.ts +655 -320
- package/src/backend/runtime/cloudflare.ts +20 -58
- 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/managed-runtime.ts +117 -139
- package/src/backend/runtime/s3-fetch.ts +380 -0
- package/src/backend/runtime/sqlite-proxy-rows.ts +309 -0
- package/src/backend/runtime/types.ts +29 -61
- package/src/backend/server.ts +5 -0
- package/src/backend/types.ts +13 -4
- package/src/db/index.ts +2 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ObjectStore,
|
|
3
|
+
ObjectStoreBody,
|
|
4
|
+
ObjectStoreObject,
|
|
5
|
+
ObjectStorePutOptions,
|
|
6
|
+
} from "./types.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The host supplies a Fetch-compatible capability that is already scoped to
|
|
10
|
+
* one object bucket. The adapter never receives (or needs) an endpoint,
|
|
11
|
+
* bucket name, region, or credential.
|
|
12
|
+
*/
|
|
13
|
+
export interface S3ObjectFetcher {
|
|
14
|
+
fetch(request: Request): Promise<Response>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface S3FetchObjectStoreOptions {
|
|
18
|
+
/** Maximum number of bytes a GET may expose to the application. */
|
|
19
|
+
readonly maxObjectBytes?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type S3FetchObjectOperation = "put" | "get" | "delete";
|
|
23
|
+
|
|
24
|
+
/** An S3 protocol failure without response-body or endpoint details. */
|
|
25
|
+
export class S3FetchObjectStoreError extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly operation: S3FetchObjectOperation,
|
|
28
|
+
readonly code: string,
|
|
29
|
+
readonly status?: number,
|
|
30
|
+
) {
|
|
31
|
+
super(code);
|
|
32
|
+
this.name = "S3FetchObjectStoreError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SYNTHETIC_ORIGIN = "https://s3.invalid";
|
|
37
|
+
const DEFAULT_MAX_OBJECT_BYTES = 64 * 1024 * 1024;
|
|
38
|
+
const MAX_HEADER_VALUE_LENGTH = 8 * 1024;
|
|
39
|
+
const FETCH_REJECTION_CODES: Record<S3FetchObjectOperation, string> = {
|
|
40
|
+
put: "s3_fetcher_put_rejected",
|
|
41
|
+
get: "s3_fetcher_get_rejected",
|
|
42
|
+
delete: "s3_fetcher_delete_rejected",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Adapt a bucket-scoped S3 HTTP Fetcher to the core's provider-neutral object
|
|
47
|
+
* store contract.
|
|
48
|
+
*
|
|
49
|
+
* The URL is intentionally synthetic. A host-owned Fetcher receives the
|
|
50
|
+
* request and supplies the actual endpoint and credentials internally; no
|
|
51
|
+
* provider materialization can escape through this public adapter.
|
|
52
|
+
*/
|
|
53
|
+
export function createS3FetchObjectStore(
|
|
54
|
+
fetcher: S3ObjectFetcher,
|
|
55
|
+
options: S3FetchObjectStoreOptions = {},
|
|
56
|
+
): ObjectStore {
|
|
57
|
+
if (!fetcher || typeof fetcher.fetch !== "function") {
|
|
58
|
+
throw new TypeError("s3_fetcher_invalid");
|
|
59
|
+
}
|
|
60
|
+
const maxObjectBytes = options.maxObjectBytes ?? DEFAULT_MAX_OBJECT_BYTES;
|
|
61
|
+
assertMaxObjectBytes(maxObjectBytes);
|
|
62
|
+
|
|
63
|
+
return new S3FetchObjectStore(fetcher, maxObjectBytes);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class S3FetchObjectStore implements ObjectStore {
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly fetcher: S3ObjectFetcher,
|
|
69
|
+
private readonly maxObjectBytes: number,
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
async put(
|
|
73
|
+
key: string,
|
|
74
|
+
value: ObjectStoreBody,
|
|
75
|
+
options?: ObjectStorePutOptions,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const headers = new Headers();
|
|
78
|
+
if (options?.contentType !== undefined) {
|
|
79
|
+
setBoundedHeader(headers, "content-type", options.contentType, "put");
|
|
80
|
+
}
|
|
81
|
+
const byteLength = knownBodyLength(value);
|
|
82
|
+
if (byteLength !== undefined) {
|
|
83
|
+
headers.set("content-length", String(byteLength));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const response = await fetchFromS3(
|
|
87
|
+
this.fetcher,
|
|
88
|
+
new Request(objectUrl(key), {
|
|
89
|
+
method: "PUT",
|
|
90
|
+
headers,
|
|
91
|
+
body: value as BodyInit,
|
|
92
|
+
}),
|
|
93
|
+
"put",
|
|
94
|
+
);
|
|
95
|
+
await expectSuccess(response, "put");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async get(key: string): Promise<ObjectStoreObject | null> {
|
|
99
|
+
const response = await fetchFromS3(
|
|
100
|
+
this.fetcher,
|
|
101
|
+
new Request(objectUrl(key), { method: "GET" }),
|
|
102
|
+
"get",
|
|
103
|
+
);
|
|
104
|
+
if (response.status === 404) {
|
|
105
|
+
await cancelBody(response);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
await expectSuccess(response, "get", false);
|
|
109
|
+
|
|
110
|
+
let byteLength: number | undefined;
|
|
111
|
+
let contentType: string | undefined;
|
|
112
|
+
let etag: string | undefined;
|
|
113
|
+
try {
|
|
114
|
+
byteLength = parseContentLength(
|
|
115
|
+
response.headers.get("content-length"),
|
|
116
|
+
this.maxObjectBytes,
|
|
117
|
+
"get",
|
|
118
|
+
response.status,
|
|
119
|
+
);
|
|
120
|
+
contentType = boundedHeader(
|
|
121
|
+
response.headers,
|
|
122
|
+
"content-type",
|
|
123
|
+
"get",
|
|
124
|
+
response.status,
|
|
125
|
+
);
|
|
126
|
+
etag = boundedHeader(response.headers, "etag", "get", response.status);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
await cancelBody(response);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
key,
|
|
133
|
+
body:
|
|
134
|
+
response.body === null
|
|
135
|
+
? null
|
|
136
|
+
: boundedBody(response.body, this.maxObjectBytes, "get"),
|
|
137
|
+
...(contentType === undefined ? {} : { contentType }),
|
|
138
|
+
...(etag === undefined ? {} : { etag }),
|
|
139
|
+
...(byteLength === undefined ? {} : { byteLength }),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async delete(key: string | readonly string[]): Promise<void> {
|
|
144
|
+
const isBatch = typeof key !== "string";
|
|
145
|
+
const keys = [...new Set(isBatch ? key : [key])];
|
|
146
|
+
const failures: S3FetchObjectStoreError[] = [];
|
|
147
|
+
for (const entry of keys) {
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetchFromS3(
|
|
150
|
+
this.fetcher,
|
|
151
|
+
new Request(objectUrl(entry), { method: "DELETE" }),
|
|
152
|
+
"delete",
|
|
153
|
+
);
|
|
154
|
+
await expectSuccess(response, "delete");
|
|
155
|
+
} catch (error) {
|
|
156
|
+
failures.push(
|
|
157
|
+
error instanceof S3FetchObjectStoreError
|
|
158
|
+
? error
|
|
159
|
+
: new S3FetchObjectStoreError(
|
|
160
|
+
"delete",
|
|
161
|
+
"s3_delete_operation_failed",
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (failures.length === 0) return;
|
|
167
|
+
if (!isBatch) {
|
|
168
|
+
throw failures[0];
|
|
169
|
+
}
|
|
170
|
+
throw new AggregateError(failures, "s3_batch_delete_failed");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function fetchFromS3(
|
|
175
|
+
fetcher: S3ObjectFetcher,
|
|
176
|
+
request: Request,
|
|
177
|
+
operation: S3FetchObjectOperation,
|
|
178
|
+
): Promise<Response> {
|
|
179
|
+
try {
|
|
180
|
+
return await fetcher.fetch(request);
|
|
181
|
+
} catch {
|
|
182
|
+
// Never retain the supplied rejection as `cause` or copy any of its text:
|
|
183
|
+
// the host Fetcher may contain endpoint and credential diagnostics.
|
|
184
|
+
throw new S3FetchObjectStoreError(
|
|
185
|
+
operation,
|
|
186
|
+
FETCH_REJECTION_CODES[operation],
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function objectUrl(key: string): string {
|
|
192
|
+
return `${SYNTHETIC_ORIGIN}/${key
|
|
193
|
+
.split("/")
|
|
194
|
+
.map(encodePathSegment)
|
|
195
|
+
.join("/")}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Encode every path segment, including dot segments, so URL normalization
|
|
199
|
+
* cannot reinterpret a user key as traversal. */
|
|
200
|
+
function encodePathSegment(value: string): string {
|
|
201
|
+
const encoded = encodeURIComponent(value).replace(
|
|
202
|
+
/[.!'()*]/gu,
|
|
203
|
+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
204
|
+
);
|
|
205
|
+
// URL parsers normalize `%2E`/`%2E%2E` path segments as traversal before a
|
|
206
|
+
// Fetcher sees the request. Double-escape only those complete segments so
|
|
207
|
+
// the host can decode the wire path without losing the key boundary.
|
|
208
|
+
return value === "." || value === ".."
|
|
209
|
+
? encoded.replace(/%2E/gu, "%252E")
|
|
210
|
+
: encoded;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function knownBodyLength(value: ObjectStoreBody): number | undefined {
|
|
214
|
+
if (value instanceof Blob) return value.size;
|
|
215
|
+
if (value instanceof ArrayBuffer) return value.byteLength;
|
|
216
|
+
if (typeof value === "string") {
|
|
217
|
+
return new TextEncoder().encode(value).byteLength;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function expectSuccess(
|
|
223
|
+
response: Response,
|
|
224
|
+
operation: S3FetchObjectOperation,
|
|
225
|
+
cancelSuccessfulBody = true,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
if (response.status >= 200 && response.status < 300) {
|
|
228
|
+
if (cancelSuccessfulBody) await cancelBody(response);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
await cancelBody(response);
|
|
232
|
+
throw new S3FetchObjectStoreError(
|
|
233
|
+
operation,
|
|
234
|
+
"s3_response_unexpected_status",
|
|
235
|
+
response.status,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function cancelBody(response: Response): Promise<void> {
|
|
240
|
+
await response.body?.cancel().catch(() => undefined);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function boundedHeader(
|
|
244
|
+
headers: Headers,
|
|
245
|
+
name: string,
|
|
246
|
+
operation: S3FetchObjectOperation,
|
|
247
|
+
status: number,
|
|
248
|
+
): string | undefined {
|
|
249
|
+
const value = headers.get(name);
|
|
250
|
+
if (value === null || value.length === 0) return undefined;
|
|
251
|
+
if (value.length > MAX_HEADER_VALUE_LENGTH) {
|
|
252
|
+
throw new S3FetchObjectStoreError(
|
|
253
|
+
operation,
|
|
254
|
+
"s3_response_header_too_large",
|
|
255
|
+
status,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function setBoundedHeader(
|
|
262
|
+
headers: Headers,
|
|
263
|
+
name: string,
|
|
264
|
+
value: string,
|
|
265
|
+
operation: S3FetchObjectOperation,
|
|
266
|
+
): void {
|
|
267
|
+
if (value.length > MAX_HEADER_VALUE_LENGTH) {
|
|
268
|
+
throw new S3FetchObjectStoreError(operation, "s3_request_header_too_large");
|
|
269
|
+
}
|
|
270
|
+
headers.set(name, value);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseContentLength(
|
|
274
|
+
value: string | null,
|
|
275
|
+
maxObjectBytes: number,
|
|
276
|
+
operation: S3FetchObjectOperation,
|
|
277
|
+
status: number,
|
|
278
|
+
): number | undefined {
|
|
279
|
+
if (value === null) return undefined;
|
|
280
|
+
if (
|
|
281
|
+
value.length > MAX_HEADER_VALUE_LENGTH ||
|
|
282
|
+
!/^\d+$/u.test(value) ||
|
|
283
|
+
!Number.isSafeInteger(Number(value))
|
|
284
|
+
) {
|
|
285
|
+
throw new S3FetchObjectStoreError(
|
|
286
|
+
operation,
|
|
287
|
+
"s3_response_content_length_invalid",
|
|
288
|
+
status,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const parsed = Number(value);
|
|
292
|
+
if (parsed > maxObjectBytes) {
|
|
293
|
+
throw new S3FetchObjectStoreError(
|
|
294
|
+
operation,
|
|
295
|
+
"s3_response_object_too_large",
|
|
296
|
+
status,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return parsed;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function boundedBody(
|
|
303
|
+
stream: ReadableStream<Uint8Array>,
|
|
304
|
+
maxObjectBytes: number,
|
|
305
|
+
operation: S3FetchObjectOperation,
|
|
306
|
+
): ReadableStream<Uint8Array> {
|
|
307
|
+
const reader = stream.getReader();
|
|
308
|
+
let size = 0;
|
|
309
|
+
let finished = false;
|
|
310
|
+
const release = (): void => {
|
|
311
|
+
if (finished) return;
|
|
312
|
+
finished = true;
|
|
313
|
+
try {
|
|
314
|
+
reader.releaseLock();
|
|
315
|
+
} catch {
|
|
316
|
+
// The bounded adapter owns no useful diagnostic at this boundary. A
|
|
317
|
+
// lock-release failure after read/cancel completion must not surface a
|
|
318
|
+
// provider error or retain its cause.
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return new ReadableStream<Uint8Array>(
|
|
323
|
+
{
|
|
324
|
+
async pull(controller) {
|
|
325
|
+
try {
|
|
326
|
+
const result = await reader.read();
|
|
327
|
+
if (result.done) {
|
|
328
|
+
controller.close();
|
|
329
|
+
release();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
size += result.value.byteLength;
|
|
333
|
+
if (size > maxObjectBytes) {
|
|
334
|
+
await reader
|
|
335
|
+
.cancel("s3_response_object_too_large")
|
|
336
|
+
.catch(() => undefined);
|
|
337
|
+
controller.error(
|
|
338
|
+
new S3FetchObjectStoreError(
|
|
339
|
+
operation,
|
|
340
|
+
"s3_response_object_too_large",
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
release();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
controller.enqueue(result.value);
|
|
347
|
+
} catch {
|
|
348
|
+
controller.error(
|
|
349
|
+
new S3FetchObjectStoreError(
|
|
350
|
+
operation,
|
|
351
|
+
"s3_response_body_read_failed",
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
release();
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
async cancel() {
|
|
358
|
+
try {
|
|
359
|
+
// Do not forward an arbitrary caller reason into a provider-owned
|
|
360
|
+
// stream, and never retain a provider rejection as cause/text.
|
|
361
|
+
await reader.cancel("s3_response_body_cancelled");
|
|
362
|
+
} catch {
|
|
363
|
+
throw new S3FetchObjectStoreError(
|
|
364
|
+
operation,
|
|
365
|
+
"s3_response_body_cancel_failed",
|
|
366
|
+
);
|
|
367
|
+
} finally {
|
|
368
|
+
release();
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
{ highWaterMark: 0 },
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function assertMaxObjectBytes(value: number): void {
|
|
377
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
378
|
+
throw new TypeError("s3_object_response_limit_invalid");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -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
|
+
}
|