@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,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edge.kv@1.0.0` → {@link IKeyValueStore}.
|
|
3
|
+
*
|
|
4
|
+
* The app's port and the facade disagree in three places, and each disagreement
|
|
5
|
+
* is resolved here rather than at the call sites:
|
|
6
|
+
*
|
|
7
|
+
* - READS. `IKeyValueStore.get` selects `text` / `json` / `arrayBuffer`; the
|
|
8
|
+
* facade always returns bytes. The decode happens here.
|
|
9
|
+
* - EXPIRY. The port carries Cloudflare's pair (`expirationTtl` relative,
|
|
10
|
+
* `expiration` absolute); the facade accepts only `expirationTtlSeconds`. An
|
|
11
|
+
* absolute instant is converted against the current clock.
|
|
12
|
+
* - LISTING. The facade returns `{name}` only and calls the flag
|
|
13
|
+
* `listComplete`; the port expects `list_complete` and optional
|
|
14
|
+
* `expiration` / `metadata`. Those two fields are ABSENT on this lane — the
|
|
15
|
+
* Host does not return them — so nothing may be inferred from their absence.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { IKeyValueStore } from "./types.ts";
|
|
19
|
+
import {
|
|
20
|
+
EDGE_KV_MAX_EXPIRATION_TTL_SECONDS,
|
|
21
|
+
EDGE_KV_MIN_EXPIRATION_TTL_SECONDS,
|
|
22
|
+
type EdgeKvBinding,
|
|
23
|
+
type EdgeKvPutOptions,
|
|
24
|
+
} from "./edge-facades.ts";
|
|
25
|
+
import { nowSeconds, readStream } from "./shared.ts";
|
|
26
|
+
|
|
27
|
+
/** A put option the facade cannot express. */
|
|
28
|
+
export class EdgeKeyValueOptionError extends TypeError {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "EdgeKeyValueOptionError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A stored value could not be read back in the requested shape. */
|
|
36
|
+
export class EdgeKeyValueValueError extends Error {
|
|
37
|
+
constructor(message: string) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "EdgeKeyValueValueError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the port's expiry pair into the single value `edge.kv` takes.
|
|
45
|
+
*
|
|
46
|
+
* `expiration` is an absolute UNIX second; the facade has no equivalent, so it
|
|
47
|
+
* becomes the remaining seconds from now. Both backends reject a TTL under 60
|
|
48
|
+
* seconds (as does Cloudflare KV itself), so a shorter one is refused here with
|
|
49
|
+
* a message that names the caller's own value instead of surfacing the Host's
|
|
50
|
+
* bare `invalid_value`.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveEdgeKvExpirationTtl(
|
|
53
|
+
options?: { readonly expirationTtl?: number; readonly expiration?: number },
|
|
54
|
+
now: () => number = nowSeconds,
|
|
55
|
+
): number | undefined {
|
|
56
|
+
const ttl =
|
|
57
|
+
options?.expirationTtl !== undefined
|
|
58
|
+
? options.expirationTtl
|
|
59
|
+
: options?.expiration !== undefined
|
|
60
|
+
? Math.ceil(options.expiration - now())
|
|
61
|
+
: undefined;
|
|
62
|
+
if (ttl === undefined) return undefined;
|
|
63
|
+
const seconds = Math.ceil(ttl);
|
|
64
|
+
if (!Number.isSafeInteger(seconds)) {
|
|
65
|
+
throw new EdgeKeyValueOptionError(
|
|
66
|
+
`edge.kv: expiration resolves to ${ttl}, which is not a whole number of seconds`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (seconds < EDGE_KV_MIN_EXPIRATION_TTL_SECONDS) {
|
|
70
|
+
throw new EdgeKeyValueOptionError(
|
|
71
|
+
`edge.kv: expiration resolves to ${seconds}s, under the ` +
|
|
72
|
+
`${EDGE_KV_MIN_EXPIRATION_TTL_SECONDS}s floor both Takoserver and ` +
|
|
73
|
+
`Cloudflare KV enforce; store a longer-lived entry or carry the ` +
|
|
74
|
+
`deadline inside the value`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (seconds > EDGE_KV_MAX_EXPIRATION_TTL_SECONDS) {
|
|
78
|
+
throw new EdgeKeyValueOptionError(
|
|
79
|
+
`edge.kv: expiration resolves to ${seconds}s, over the ` +
|
|
80
|
+
`${EDGE_KV_MAX_EXPIRATION_TTL_SECONDS}s ceiling`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return seconds;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The facade stores a record of STRINGS. The port's `Record<string, unknown>`
|
|
88
|
+
* is therefore only usable when every value already is one; anything else is
|
|
89
|
+
* refused rather than stringified, because a silent `String(value)` would round
|
|
90
|
+
* -trip differently on the two lanes.
|
|
91
|
+
*/
|
|
92
|
+
function projectMetadata(
|
|
93
|
+
metadata: Record<string, unknown> | undefined,
|
|
94
|
+
): Record<string, string> | undefined {
|
|
95
|
+
if (metadata === undefined) return undefined;
|
|
96
|
+
const projected: Record<string, string> = {};
|
|
97
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
98
|
+
if (typeof value !== "string") {
|
|
99
|
+
throw new EdgeKeyValueOptionError(
|
|
100
|
+
`edge.kv: metadata."${key}" is ${typeof value}; the facade stores ` +
|
|
101
|
+
`string values only`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
projected[key] = value;
|
|
105
|
+
}
|
|
106
|
+
return projected;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function toBytes(
|
|
110
|
+
value: string | ArrayBuffer | ReadableStream,
|
|
111
|
+
): Promise<string | ArrayBuffer | Uint8Array> {
|
|
112
|
+
if (typeof value === "string" || value instanceof ArrayBuffer) return value;
|
|
113
|
+
return await readStream(value as ReadableStream<Uint8Array>);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class EdgeKeyValueStore implements IKeyValueStore {
|
|
117
|
+
constructor(
|
|
118
|
+
private readonly kv: EdgeKvBinding,
|
|
119
|
+
private readonly now: () => number = nowSeconds,
|
|
120
|
+
) {}
|
|
121
|
+
|
|
122
|
+
get(key: string, options?: { type?: "text" }): Promise<string | null>;
|
|
123
|
+
get<T = unknown>(key: string, options: { type: "json" }): Promise<T | null>;
|
|
124
|
+
get(
|
|
125
|
+
key: string,
|
|
126
|
+
options: { type: "arrayBuffer" },
|
|
127
|
+
): Promise<ArrayBuffer | null>;
|
|
128
|
+
async get(
|
|
129
|
+
key: string,
|
|
130
|
+
options?: { type?: "text" | "json" | "arrayBuffer" },
|
|
131
|
+
): Promise<string | ArrayBuffer | unknown | null> {
|
|
132
|
+
const value = await this.kv.get(key);
|
|
133
|
+
if (value === null) return null;
|
|
134
|
+
const type = options?.type ?? "text";
|
|
135
|
+
if (type === "arrayBuffer") return value;
|
|
136
|
+
const text = new TextDecoder().decode(value);
|
|
137
|
+
if (type === "json") {
|
|
138
|
+
// A present-but-unparseable entry is a fault, not a miss: reporting it as
|
|
139
|
+
// `null` would be indistinguishable from "no such key" and would silently
|
|
140
|
+
// reset whatever state the entry held. The other lanes in this repo
|
|
141
|
+
// (MemoryKV, the managed runtime) both raise here too.
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text) as unknown;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new EdgeKeyValueValueError(
|
|
146
|
+
`edge.kv: "${key}" is present but is not JSON: ${String(error)}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return text;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async put(
|
|
154
|
+
key: string,
|
|
155
|
+
value: string | ArrayBuffer | ReadableStream,
|
|
156
|
+
options?: {
|
|
157
|
+
expirationTtl?: number;
|
|
158
|
+
expiration?: number;
|
|
159
|
+
metadata?: Record<string, unknown>;
|
|
160
|
+
},
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const expirationTtlSeconds = resolveEdgeKvExpirationTtl(options, this.now);
|
|
163
|
+
const metadata = projectMetadata(options?.metadata);
|
|
164
|
+
const put: EdgeKvPutOptions = {
|
|
165
|
+
...(expirationTtlSeconds === undefined ? {} : { expirationTtlSeconds }),
|
|
166
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
167
|
+
};
|
|
168
|
+
await this.kv.put(key, await toBytes(value), put);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async delete(key: string): Promise<void> {
|
|
172
|
+
await this.kv.delete(key);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async list(options?: {
|
|
176
|
+
prefix?: string;
|
|
177
|
+
limit?: number;
|
|
178
|
+
cursor?: string;
|
|
179
|
+
}): Promise<{
|
|
180
|
+
keys: Array<{ name: string; expiration?: number; metadata?: unknown }>;
|
|
181
|
+
list_complete: boolean;
|
|
182
|
+
cursor?: string;
|
|
183
|
+
}> {
|
|
184
|
+
const result = await this.kv.list({
|
|
185
|
+
...(options?.prefix === undefined ? {} : { prefix: options.prefix }),
|
|
186
|
+
...(options?.cursor === undefined ? {} : { cursor: options.cursor }),
|
|
187
|
+
...(options?.limit === undefined ? {} : { limit: options.limit }),
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
keys: result.keys.map((entry) => ({ name: entry.name })),
|
|
191
|
+
list_complete: result.listComplete,
|
|
192
|
+
// The facade omits the cursor once the listing is complete; the port
|
|
193
|
+
// says the same thing with `undefined`.
|
|
194
|
+
cursor: result.listComplete ? undefined : result.cursor,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function wrapEdgeKv(
|
|
200
|
+
kv: EdgeKvBinding,
|
|
201
|
+
now?: () => number,
|
|
202
|
+
): IKeyValueStore {
|
|
203
|
+
return new EdgeKeyValueStore(kv, now);
|
|
204
|
+
}
|
|
@@ -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>;
|