@q32/core 0.1.4 → 0.1.6
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/dist/env.d.ts +5 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +4 -2
- package/dist/env.js.map +1 -1
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +2 -8
- package/dist/ids.js.map +1 -1
- package/package.json +2 -1
- package/src/ai.ts +42 -0
- package/src/api.ts +100 -0
- package/src/billing.ts +46 -0
- package/src/cloudflare.ts +36 -0
- package/src/crypto.ts +56 -0
- package/src/d1.ts +86 -0
- package/src/email.ts +49 -0
- package/src/encoding.ts +17 -0
- package/src/env.ts +63 -0
- package/src/hash.ts +57 -0
- package/src/http.ts +78 -0
- package/src/ids.ts +55 -0
- package/src/index.ts +22 -0
- package/src/jobs.ts +276 -0
- package/src/mcp.ts +52 -0
- package/src/oauth.ts +44 -0
- package/src/ops-events.ts +75 -0
- package/src/pg.ts +81 -0
- package/src/r2-json.ts +32 -0
- package/src/rate-limit.ts +77 -0
- package/src/seo.ts +73 -0
- package/src/session.ts +81 -0
- package/src/testing.ts +119 -0
- package/src/time.ts +18 -0
package/src/http.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type JsonResponseInit = ResponseInit & {
|
|
2
|
+
pretty?: boolean;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type FetchLike = typeof fetch;
|
|
6
|
+
|
|
7
|
+
export const defaultFetch: FetchLike = (input, init) => fetch(input, init);
|
|
8
|
+
|
|
9
|
+
export class HttpError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly status: number,
|
|
12
|
+
message: string,
|
|
13
|
+
public readonly code = "http_error",
|
|
14
|
+
) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "HttpError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function jsonResponse(value: unknown, init: JsonResponseInit = {}): Response {
|
|
21
|
+
const headers = new Headers(init.headers);
|
|
22
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8");
|
|
23
|
+
return new Response(JSON.stringify(value, null, init.pretty ? 2 : 0), {
|
|
24
|
+
...init,
|
|
25
|
+
headers,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function errorResponse(error: unknown, fallbackStatus = 500): Response {
|
|
30
|
+
if (error instanceof HttpError) {
|
|
31
|
+
return jsonResponse({ error: { code: error.code, message: error.message } }, { status: error.status });
|
|
32
|
+
}
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return jsonResponse({ error: { code: "internal_error", message } }, { status: fallbackStatus });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readJson<T = unknown>(request: Request): Promise<T> {
|
|
38
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
39
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
40
|
+
throw new HttpError(415, "Expected application/json request body.", "unsupported_media_type");
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return (await request.json()) as T;
|
|
44
|
+
} catch {
|
|
45
|
+
throw new HttpError(400, "Invalid JSON request body.", "invalid_json");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function requireBearerToken(request: Request): string {
|
|
50
|
+
const value = request.headers.get("authorization") ?? "";
|
|
51
|
+
const match = value.match(/^Bearer\s+(.+)$/i);
|
|
52
|
+
if (!match) throw new HttpError(401, "Missing bearer token.", "missing_bearer_token");
|
|
53
|
+
return match[1];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function requireAdminToken(request: Request, expected: string | undefined): void {
|
|
57
|
+
if (!expected) throw new HttpError(500, "Admin token is not configured.", "admin_token_not_configured");
|
|
58
|
+
const provided = request.headers.get("x-admin-token") ?? requireBearerToken(request);
|
|
59
|
+
if (provided !== expected) throw new HttpError(403, "Invalid admin token.", "invalid_admin_token");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function absoluteUrl(appUrl: string, path: string): string {
|
|
63
|
+
return `${appUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function escapeHtml(value: string | null | undefined): string {
|
|
67
|
+
return String(value ?? "")
|
|
68
|
+
.replaceAll("&", "&")
|
|
69
|
+
.replaceAll("<", "<")
|
|
70
|
+
.replaceAll(">", ">")
|
|
71
|
+
.replaceAll('"', """)
|
|
72
|
+
.replaceAll("'", "'");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function readResponseExcerpt(response: Response, maxLength = 800): Promise<string> {
|
|
76
|
+
const text = await response.text().catch(() => "");
|
|
77
|
+
return text.slice(0, maxLength);
|
|
78
|
+
}
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const DEFAULT_TOKEN_BYTES = 32;
|
|
2
|
+
const BASE36_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
3
|
+
|
|
4
|
+
export function createId(prefix: string, bytes = 12): string {
|
|
5
|
+
const cleanPrefix = prefix.trim().replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
6
|
+
if (!cleanPrefix) throw new Error("ID prefix is required.");
|
|
7
|
+
return `${cleanPrefix}_${randomBase64Url(bytes)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createToken(prefix = "tok", bytes = DEFAULT_TOKEN_BYTES): string {
|
|
11
|
+
return createId(prefix, bytes);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function randomBase36(length: number): string {
|
|
15
|
+
const safeLength = Math.max(1, Math.floor(length));
|
|
16
|
+
const bytes = new Uint8Array(safeLength);
|
|
17
|
+
crypto.getRandomValues(bytes);
|
|
18
|
+
let value = "";
|
|
19
|
+
for (const byte of bytes) value += BASE36_ALPHABET[byte % BASE36_ALPHABET.length];
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createBase36Id(prefix: string, length = 20): string {
|
|
24
|
+
return `${prefix}_${randomBase36(length)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createBase36Token(prefix: string, length = 40): string {
|
|
28
|
+
return `${prefix}_${randomBase36(length)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function randomBase64Url(bytes = DEFAULT_TOKEN_BYTES): string {
|
|
32
|
+
const data = new Uint8Array(Math.max(1, Math.floor(bytes)));
|
|
33
|
+
crypto.getRandomValues(data);
|
|
34
|
+
return toBase64Url(data);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function toBase64Url(input: string | Uint8Array): string {
|
|
38
|
+
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
39
|
+
let binary = "";
|
|
40
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
41
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function fromBase64Url(value: string): Uint8Array {
|
|
45
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
46
|
+
const binary = atob(padded);
|
|
47
|
+
const bytes = new Uint8Array(binary.length);
|
|
48
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
49
|
+
return bytes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function sha256Hex(input: string): Promise<string> {
|
|
53
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
54
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export * from "./api.js";
|
|
2
|
+
export * from "./ai.js";
|
|
3
|
+
export * from "./billing.js";
|
|
4
|
+
export * from "./cloudflare.js";
|
|
5
|
+
export * from "./crypto.js";
|
|
6
|
+
export * from "./d1.js";
|
|
7
|
+
export * from "./encoding.js";
|
|
8
|
+
export * from "./email.js";
|
|
9
|
+
export * from "./env.js";
|
|
10
|
+
export * from "./http.js";
|
|
11
|
+
export * from "./ids.js";
|
|
12
|
+
export * from "./jobs.js";
|
|
13
|
+
export * from "./mcp.js";
|
|
14
|
+
export * from "./oauth.js";
|
|
15
|
+
export * from "./ops-events.js";
|
|
16
|
+
export * from "./pg.js";
|
|
17
|
+
export * from "./r2-json.js";
|
|
18
|
+
export * from "./rate-limit.js";
|
|
19
|
+
export * from "./seo.js";
|
|
20
|
+
export * from "./session.js";
|
|
21
|
+
export * from "./testing.js";
|
|
22
|
+
export * from "./time.js";
|
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { D1DatabaseLike } from "./d1.js";
|
|
2
|
+
import { parseJsonColumn, stringifyJsonColumn } from "./d1.js";
|
|
3
|
+
import { createId } from "./ids.js";
|
|
4
|
+
import { isFutureIso, nowIso } from "./time.js";
|
|
5
|
+
|
|
6
|
+
export type JobStatus = "queued" | "running" | "succeeded" | "failed";
|
|
7
|
+
|
|
8
|
+
export type JobHandlerResult<TResult = unknown> =
|
|
9
|
+
| { kind: "done"; result?: TResult | null }
|
|
10
|
+
| { kind: "requeue"; result?: TResult | null; availableAt?: string | null };
|
|
11
|
+
|
|
12
|
+
export type JobRecord<TPayload = unknown, TResult = unknown> = {
|
|
13
|
+
jobId: string;
|
|
14
|
+
jobType: string;
|
|
15
|
+
status: JobStatus;
|
|
16
|
+
payload: TPayload;
|
|
17
|
+
result: TResult | null;
|
|
18
|
+
lockKey: string | null;
|
|
19
|
+
attemptCount: number;
|
|
20
|
+
maxAttempts: number;
|
|
21
|
+
availableAt: string | null;
|
|
22
|
+
startedAt: string | null;
|
|
23
|
+
completedAt: string | null;
|
|
24
|
+
lastError: string | null;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type EnqueueJobInput<TPayload = unknown> = {
|
|
30
|
+
jobType: string;
|
|
31
|
+
payload?: TPayload;
|
|
32
|
+
lockKey?: string | null;
|
|
33
|
+
maxAttempts?: number;
|
|
34
|
+
availableAt?: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class D1JobStore {
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly db: D1DatabaseLike,
|
|
40
|
+
private readonly options: { tableName?: string; idPrefix?: string } = {},
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
get tableName(): string {
|
|
44
|
+
return this.options.tableName ?? "jobs";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async enqueue<TPayload = unknown>(input: EnqueueJobInput<TPayload>): Promise<JobRecord<TPayload>> {
|
|
48
|
+
const jobId = createId(this.options.idPrefix ?? "job");
|
|
49
|
+
const now = nowIso();
|
|
50
|
+
await this.db
|
|
51
|
+
.prepare(
|
|
52
|
+
`INSERT INTO ${this.tableName} (
|
|
53
|
+
job_id, job_type, status, payload_json, result_json, lock_key,
|
|
54
|
+
attempt_count, max_attempts, available_at, created_at, updated_at
|
|
55
|
+
) VALUES (?, ?, 'queued', ?, NULL, ?, 0, ?, ?, ?, ?)`,
|
|
56
|
+
)
|
|
57
|
+
.bind(
|
|
58
|
+
jobId,
|
|
59
|
+
input.jobType,
|
|
60
|
+
stringifyJsonColumn(input.payload ?? {}),
|
|
61
|
+
input.lockKey ?? null,
|
|
62
|
+
Math.max(1, Math.floor(input.maxAttempts ?? 3)),
|
|
63
|
+
input.availableAt ?? null,
|
|
64
|
+
now,
|
|
65
|
+
now,
|
|
66
|
+
)
|
|
67
|
+
.run();
|
|
68
|
+
|
|
69
|
+
const job = await this.get<TPayload>(jobId);
|
|
70
|
+
if (!job) throw new Error(`Failed to load enqueued job: ${jobId}`);
|
|
71
|
+
return job;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async get<TPayload = unknown, TResult = unknown>(jobId: string): Promise<JobRecord<TPayload, TResult> | null> {
|
|
75
|
+
const row = await this.db
|
|
76
|
+
.prepare(
|
|
77
|
+
`SELECT
|
|
78
|
+
job_id AS jobId,
|
|
79
|
+
job_type AS jobType,
|
|
80
|
+
status,
|
|
81
|
+
payload_json AS payloadJson,
|
|
82
|
+
result_json AS resultJson,
|
|
83
|
+
lock_key AS lockKey,
|
|
84
|
+
attempt_count AS attemptCount,
|
|
85
|
+
max_attempts AS maxAttempts,
|
|
86
|
+
available_at AS availableAt,
|
|
87
|
+
started_at AS startedAt,
|
|
88
|
+
completed_at AS completedAt,
|
|
89
|
+
last_error AS lastError,
|
|
90
|
+
created_at AS createdAt,
|
|
91
|
+
updated_at AS updatedAt
|
|
92
|
+
FROM ${this.tableName}
|
|
93
|
+
WHERE job_id = ?
|
|
94
|
+
LIMIT 1`,
|
|
95
|
+
)
|
|
96
|
+
.bind(jobId)
|
|
97
|
+
.first<JobRow>();
|
|
98
|
+
return row ? rowToJob<TPayload, TResult>(row) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async listQueued(limit = 25): Promise<JobRecord[]> {
|
|
102
|
+
const now = nowIso();
|
|
103
|
+
const rows = await this.db
|
|
104
|
+
.prepare(
|
|
105
|
+
`SELECT
|
|
106
|
+
job_id AS jobId, job_type AS jobType, status, payload_json AS payloadJson,
|
|
107
|
+
result_json AS resultJson, lock_key AS lockKey, attempt_count AS attemptCount,
|
|
108
|
+
max_attempts AS maxAttempts, available_at AS availableAt, started_at AS startedAt,
|
|
109
|
+
completed_at AS completedAt, last_error AS lastError, created_at AS createdAt, updated_at AS updatedAt
|
|
110
|
+
FROM ${this.tableName}
|
|
111
|
+
WHERE status = 'queued' AND (available_at IS NULL OR available_at <= ?)
|
|
112
|
+
ORDER BY created_at ASC
|
|
113
|
+
LIMIT ?`,
|
|
114
|
+
)
|
|
115
|
+
.bind(now, Math.max(1, Math.min(100, Math.floor(limit))))
|
|
116
|
+
.all<JobRow>();
|
|
117
|
+
return (rows.results ?? []).map(rowToJob);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async claim<TPayload = unknown>(jobId: string): Promise<JobRecord<TPayload> | null> {
|
|
121
|
+
const current = await this.get<TPayload>(jobId);
|
|
122
|
+
if (!current || current.status === "succeeded" || current.status === "failed") return null;
|
|
123
|
+
if (current.status === "queued" && isFutureIso(current.availableAt)) return null;
|
|
124
|
+
if (current.attemptCount >= current.maxAttempts) {
|
|
125
|
+
await this.fail(jobId, "Job exhausted all attempts.");
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const now = nowIso();
|
|
130
|
+
const result = await this.db
|
|
131
|
+
.prepare(
|
|
132
|
+
`UPDATE ${this.tableName}
|
|
133
|
+
SET status = 'running',
|
|
134
|
+
attempt_count = attempt_count + 1,
|
|
135
|
+
started_at = COALESCE(started_at, ?),
|
|
136
|
+
updated_at = ?,
|
|
137
|
+
last_error = NULL
|
|
138
|
+
WHERE job_id = ?
|
|
139
|
+
AND status IN ('queued', 'running')
|
|
140
|
+
AND (available_at IS NULL OR available_at <= ?)`,
|
|
141
|
+
)
|
|
142
|
+
.bind(now, now, jobId, now)
|
|
143
|
+
.run();
|
|
144
|
+
|
|
145
|
+
if (!Number(result.meta.changes ?? 0)) return null;
|
|
146
|
+
return this.get<TPayload>(jobId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async succeed<TResult = unknown>(jobId: string, result: TResult | null = null): Promise<void> {
|
|
150
|
+
const now = nowIso();
|
|
151
|
+
await this.db
|
|
152
|
+
.prepare(
|
|
153
|
+
`UPDATE ${this.tableName}
|
|
154
|
+
SET status = 'succeeded', result_json = ?, completed_at = ?, updated_at = ?
|
|
155
|
+
WHERE job_id = ?`,
|
|
156
|
+
)
|
|
157
|
+
.bind(stringifyJsonColumn(result), now, now, jobId)
|
|
158
|
+
.run();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async requeue<TResult = unknown>(jobId: string, result: TResult | null = null, availableAt: string | null = null): Promise<void> {
|
|
162
|
+
const now = nowIso();
|
|
163
|
+
await this.db
|
|
164
|
+
.prepare(
|
|
165
|
+
`UPDATE ${this.tableName}
|
|
166
|
+
SET status = 'queued', result_json = ?, available_at = ?, updated_at = ?
|
|
167
|
+
WHERE job_id = ?`,
|
|
168
|
+
)
|
|
169
|
+
.bind(stringifyJsonColumn(result), availableAt, now, jobId)
|
|
170
|
+
.run();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async fail(jobId: string, error: unknown): Promise<void> {
|
|
174
|
+
const now = nowIso();
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
await this.db
|
|
177
|
+
.prepare(
|
|
178
|
+
`UPDATE ${this.tableName}
|
|
179
|
+
SET status = 'failed', last_error = ?, completed_at = ?, updated_at = ?
|
|
180
|
+
WHERE job_id = ?`,
|
|
181
|
+
)
|
|
182
|
+
.bind(message, now, now, jobId)
|
|
183
|
+
.run();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async run<TPayload = unknown, TResult = unknown>(
|
|
187
|
+
jobId: string,
|
|
188
|
+
handler: (job: JobRecord<TPayload, TResult>) => Promise<JobHandlerResult<TResult> | TResult | void>,
|
|
189
|
+
): Promise<JobHandlerResult<TResult>> {
|
|
190
|
+
const job = await this.claim<TPayload>(jobId);
|
|
191
|
+
if (!job) return { kind: "done", result: null };
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const output = await handler(job as JobRecord<TPayload, TResult>);
|
|
195
|
+
const normalized = normalizeJobResult<TResult>(output);
|
|
196
|
+
if (normalized.kind === "requeue") {
|
|
197
|
+
await this.requeue(jobId, normalized.result ?? null, normalized.availableAt ?? null);
|
|
198
|
+
} else {
|
|
199
|
+
await this.succeed(jobId, normalized.result ?? null);
|
|
200
|
+
}
|
|
201
|
+
return normalized;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const latest = await this.get(jobId);
|
|
204
|
+
if (latest && latest.attemptCount < latest.maxAttempts) {
|
|
205
|
+
await this.requeue(jobId, { retryError: error instanceof Error ? error.message : String(error) });
|
|
206
|
+
return { kind: "requeue", result: null };
|
|
207
|
+
}
|
|
208
|
+
await this.fail(jobId, error);
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const D1_JOBS_SCHEMA = `
|
|
215
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
216
|
+
job_id TEXT PRIMARY KEY,
|
|
217
|
+
job_type TEXT NOT NULL,
|
|
218
|
+
status TEXT NOT NULL,
|
|
219
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
220
|
+
result_json TEXT,
|
|
221
|
+
lock_key TEXT,
|
|
222
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
223
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
224
|
+
available_at TEXT,
|
|
225
|
+
started_at TEXT,
|
|
226
|
+
completed_at TEXT,
|
|
227
|
+
last_error TEXT,
|
|
228
|
+
created_at TEXT NOT NULL,
|
|
229
|
+
updated_at TEXT NOT NULL
|
|
230
|
+
);
|
|
231
|
+
CREATE INDEX IF NOT EXISTS jobs_status_available_idx ON jobs(status, available_at, created_at);
|
|
232
|
+
CREATE INDEX IF NOT EXISTS jobs_type_status_idx ON jobs(job_type, status, created_at);
|
|
233
|
+
CREATE UNIQUE INDEX IF NOT EXISTS jobs_active_lock_key_idx ON jobs(lock_key)
|
|
234
|
+
WHERE lock_key IS NOT NULL AND status IN ('queued', 'running');
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
type JobRow = {
|
|
238
|
+
jobId: string;
|
|
239
|
+
jobType: string;
|
|
240
|
+
status: JobStatus;
|
|
241
|
+
payloadJson: string;
|
|
242
|
+
resultJson: string | null;
|
|
243
|
+
lockKey: string | null;
|
|
244
|
+
attemptCount: number;
|
|
245
|
+
maxAttempts: number;
|
|
246
|
+
availableAt: string | null;
|
|
247
|
+
startedAt: string | null;
|
|
248
|
+
completedAt: string | null;
|
|
249
|
+
lastError: string | null;
|
|
250
|
+
createdAt: string;
|
|
251
|
+
updatedAt: string;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
function rowToJob<TPayload = unknown, TResult = unknown>(row: JobRow): JobRecord<TPayload, TResult> {
|
|
255
|
+
return {
|
|
256
|
+
jobId: row.jobId,
|
|
257
|
+
jobType: row.jobType,
|
|
258
|
+
status: row.status,
|
|
259
|
+
payload: parseJsonColumn<TPayload>(row.payloadJson, {} as TPayload),
|
|
260
|
+
result: parseJsonColumn<TResult | null>(row.resultJson, null),
|
|
261
|
+
lockKey: row.lockKey,
|
|
262
|
+
attemptCount: Number(row.attemptCount),
|
|
263
|
+
maxAttempts: Number(row.maxAttempts),
|
|
264
|
+
availableAt: row.availableAt,
|
|
265
|
+
startedAt: row.startedAt,
|
|
266
|
+
completedAt: row.completedAt,
|
|
267
|
+
lastError: row.lastError,
|
|
268
|
+
createdAt: row.createdAt,
|
|
269
|
+
updatedAt: row.updatedAt,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeJobResult<TResult>(output: JobHandlerResult<TResult> | TResult | void): JobHandlerResult<TResult> {
|
|
274
|
+
if (output && typeof output === "object" && "kind" in output) return output as JobHandlerResult<TResult>;
|
|
275
|
+
return { kind: "done", result: (output ?? null) as TResult | null };
|
|
276
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ApiOperationRegistry } from "./api.js";
|
|
2
|
+
|
|
3
|
+
export type McpToolDescriptor = {
|
|
4
|
+
name: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
inputSchema: Record<string, unknown>;
|
|
8
|
+
annotations?: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function mcpToolsFromApiRegistry<TContext>(
|
|
12
|
+
registry: ApiOperationRegistry<TContext>,
|
|
13
|
+
options: { includeScopes?: boolean } = {},
|
|
14
|
+
): McpToolDescriptor[] {
|
|
15
|
+
return Object.values(registry).map((operation) => ({
|
|
16
|
+
name: operation.name,
|
|
17
|
+
title: operation.title,
|
|
18
|
+
description: operation.description,
|
|
19
|
+
inputSchema: jsonSchemaPlaceholder(operation.path),
|
|
20
|
+
annotations: options.includeScopes && operation.scope ? { scope: operation.scope } : undefined,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mcpWellKnownServerMetadata(options: {
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
authorizationServers?: string[];
|
|
29
|
+
}): Record<string, unknown> {
|
|
30
|
+
return {
|
|
31
|
+
name: options.name,
|
|
32
|
+
description: options.description,
|
|
33
|
+
transport: "http",
|
|
34
|
+
url: options.url,
|
|
35
|
+
authorization_servers: options.authorizationServers,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function jsonSchemaPlaceholder(path: string): Record<string, unknown> {
|
|
40
|
+
const properties: Record<string, unknown> = {};
|
|
41
|
+
const required: string[] = [];
|
|
42
|
+
for (const match of path.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
43
|
+
properties[match[1]] = { type: "string" };
|
|
44
|
+
required.push(match[1]);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties,
|
|
49
|
+
required,
|
|
50
|
+
additionalProperties: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type OAuthMetadataOptions = {
|
|
2
|
+
issuer: string;
|
|
3
|
+
authorizationPath?: string;
|
|
4
|
+
tokenPath?: string;
|
|
5
|
+
registrationPath?: string;
|
|
6
|
+
revocationPath?: string;
|
|
7
|
+
scopes?: string[];
|
|
8
|
+
resourceDocumentation?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function oauthAuthorizationServerMetadata(options: OAuthMetadataOptions): Record<string, unknown> {
|
|
12
|
+
const issuer = options.issuer.replace(/\/$/, "");
|
|
13
|
+
return {
|
|
14
|
+
issuer,
|
|
15
|
+
authorization_endpoint: `${issuer}${options.authorizationPath ?? "/authorize"}`,
|
|
16
|
+
token_endpoint: `${issuer}${options.tokenPath ?? "/token"}`,
|
|
17
|
+
registration_endpoint: `${issuer}${options.registrationPath ?? "/register"}`,
|
|
18
|
+
revocation_endpoint: `${issuer}${options.revocationPath ?? "/revoke"}`,
|
|
19
|
+
response_types_supported: ["code"],
|
|
20
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
21
|
+
token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
|
|
22
|
+
code_challenge_methods_supported: ["S256"],
|
|
23
|
+
scopes_supported: options.scopes ?? ["mcp:read", "mcp:write"],
|
|
24
|
+
resource_documentation: options.resourceDocumentation ?? `${issuer}/mcp`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function oauthProtectedResourceMetadata(resource: string, authorizationServer: string, scopes?: string[]): Record<string, unknown> {
|
|
29
|
+
return {
|
|
30
|
+
resource,
|
|
31
|
+
authorization_servers: [authorizationServer.replace(/\/$/, "")],
|
|
32
|
+
scopes_supported: scopes ?? ["mcp:read", "mcp:write"],
|
|
33
|
+
bearer_methods_supported: ["header"],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mcpServerCard(options: { name: string; description?: string; url: string }): Record<string, unknown> {
|
|
38
|
+
return {
|
|
39
|
+
name: options.name,
|
|
40
|
+
description: options.description,
|
|
41
|
+
url: options.url,
|
|
42
|
+
transport: "http",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { D1DatabaseLike } from "./d1.js";
|
|
2
|
+
import { stringifyJsonColumn } from "./d1.js";
|
|
3
|
+
|
|
4
|
+
export type OpsSeverity = "debug" | "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
export type OpsEventInput = {
|
|
7
|
+
eventType: string;
|
|
8
|
+
severity?: OpsSeverity;
|
|
9
|
+
source: string;
|
|
10
|
+
fingerprint?: string | null;
|
|
11
|
+
payload?: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type OpsEventRow = {
|
|
15
|
+
id: number;
|
|
16
|
+
event_type: string;
|
|
17
|
+
severity: OpsSeverity;
|
|
18
|
+
source: string;
|
|
19
|
+
fingerprint: string | null;
|
|
20
|
+
payload_json: string;
|
|
21
|
+
created_at: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function recordOpsEvent(db: D1DatabaseLike, input: OpsEventInput): Promise<void> {
|
|
25
|
+
await db
|
|
26
|
+
.prepare(
|
|
27
|
+
`INSERT INTO ops_events (event_type, severity, source, fingerprint, payload_json)
|
|
28
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
29
|
+
)
|
|
30
|
+
.bind(input.eventType, input.severity ?? "info", input.source, input.fingerprint ?? null, stringifyJsonColumn(input.payload ?? {}))
|
|
31
|
+
.run();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function listRecentOpsEvents(
|
|
35
|
+
db: D1DatabaseLike,
|
|
36
|
+
options: { limit?: number; eventType?: string | null } = {},
|
|
37
|
+
): Promise<OpsEventRow[]> {
|
|
38
|
+
const limit = Math.max(1, Math.min(200, Math.floor(options.limit ?? 25)));
|
|
39
|
+
const eventType = options.eventType?.trim();
|
|
40
|
+
const statement = eventType
|
|
41
|
+
? db
|
|
42
|
+
.prepare(
|
|
43
|
+
`SELECT id, event_type, severity, source, fingerprint, payload_json, created_at
|
|
44
|
+
FROM ops_events
|
|
45
|
+
WHERE event_type = ?
|
|
46
|
+
ORDER BY created_at DESC, id DESC
|
|
47
|
+
LIMIT ?`,
|
|
48
|
+
)
|
|
49
|
+
.bind(eventType, limit)
|
|
50
|
+
: db
|
|
51
|
+
.prepare(
|
|
52
|
+
`SELECT id, event_type, severity, source, fingerprint, payload_json, created_at
|
|
53
|
+
FROM ops_events
|
|
54
|
+
ORDER BY created_at DESC, id DESC
|
|
55
|
+
LIMIT ?`,
|
|
56
|
+
)
|
|
57
|
+
.bind(limit);
|
|
58
|
+
const rows = await statement.all<OpsEventRow>();
|
|
59
|
+
return rows.results ?? [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const D1_OPS_EVENTS_SCHEMA = `
|
|
63
|
+
CREATE TABLE IF NOT EXISTS ops_events (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
event_type TEXT NOT NULL,
|
|
66
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
67
|
+
source TEXT NOT NULL,
|
|
68
|
+
fingerprint TEXT,
|
|
69
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
70
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
71
|
+
);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS ops_events_created_at_idx ON ops_events(created_at DESC);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS ops_events_type_created_idx ON ops_events(event_type, created_at DESC);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS ops_events_severity_created_idx ON ops_events(severity, created_at DESC);
|
|
75
|
+
`;
|
package/src/pg.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface PgQueryResult<Row = unknown> {
|
|
2
|
+
rows: Row[];
|
|
3
|
+
rowCount: number | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface PgClientLike {
|
|
7
|
+
query<Row = unknown>(text: string, values?: readonly unknown[]): Promise<PgQueryResult<Row>>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PgPoolLike extends PgClientLike {
|
|
11
|
+
connect(): Promise<PgClientLike & { release(): void }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PgMigration = {
|
|
15
|
+
id: string;
|
|
16
|
+
sql: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type PgMigrationResult = {
|
|
20
|
+
applied: string[];
|
|
21
|
+
skipped: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function ensurePgMigrationsTable(client: PgClientLike, tableName = "schema_migrations"): Promise<void> {
|
|
25
|
+
assertSafeIdentifier(tableName);
|
|
26
|
+
await client.query(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function applyPgMigrations(
|
|
35
|
+
pool: PgPoolLike,
|
|
36
|
+
migrations: PgMigration[],
|
|
37
|
+
options: { tableName?: string; lockKey?: number } = {},
|
|
38
|
+
): Promise<PgMigrationResult> {
|
|
39
|
+
const tableName = options.tableName ?? "schema_migrations";
|
|
40
|
+
assertSafeIdentifier(tableName);
|
|
41
|
+
const client = await pool.connect();
|
|
42
|
+
const applied: string[] = [];
|
|
43
|
+
const skipped: string[] = [];
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await client.query("BEGIN");
|
|
47
|
+
if (options.lockKey !== undefined) {
|
|
48
|
+
await client.query("SELECT pg_advisory_xact_lock($1)", [options.lockKey]);
|
|
49
|
+
}
|
|
50
|
+
await ensurePgMigrationsTable(client, tableName);
|
|
51
|
+
|
|
52
|
+
for (const migration of migrations) {
|
|
53
|
+
const existing = await client.query<{ id: string }>(`SELECT id FROM ${tableName} WHERE id = $1 LIMIT 1`, [migration.id]);
|
|
54
|
+
if (existing.rows[0]) {
|
|
55
|
+
skipped.push(migration.id);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
await client.query(migration.sql);
|
|
59
|
+
await client.query(`INSERT INTO ${tableName} (id) VALUES ($1)`, [migration.id]);
|
|
60
|
+
applied.push(migration.id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await client.query("COMMIT");
|
|
64
|
+
return { applied, skipped };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await client.query("ROLLBACK");
|
|
67
|
+
throw error;
|
|
68
|
+
} finally {
|
|
69
|
+
client.release();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pgJson<T>(value: T): string {
|
|
74
|
+
return JSON.stringify(value ?? null);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertSafeIdentifier(value: string): void {
|
|
78
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
79
|
+
throw new Error(`Unsafe SQL identifier: ${value}`);
|
|
80
|
+
}
|
|
81
|
+
}
|