@q32/core 0.1.5 → 0.1.7
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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pg-kysely.d.ts +53 -0
- package/dist/pg-kysely.d.ts.map +1 -0
- package/dist/pg-kysely.js +128 -0
- package/dist/pg-kysely.js.map +1 -0
- package/docs/commonality-adgiro-relin-dirtsignal.md +28 -0
- package/package.json +11 -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 +23 -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-kysely.ts +198 -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/email.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type EmailAddress = {
|
|
2
|
+
email: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type EmailAttachment = {
|
|
7
|
+
filename: string;
|
|
8
|
+
contentType: string;
|
|
9
|
+
data: Uint8Array | string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SendEmailInput = {
|
|
13
|
+
from: EmailAddress;
|
|
14
|
+
to: EmailAddress[];
|
|
15
|
+
cc?: EmailAddress[];
|
|
16
|
+
bcc?: EmailAddress[];
|
|
17
|
+
replyTo?: EmailAddress[];
|
|
18
|
+
subject: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
html?: string;
|
|
21
|
+
attachments?: EmailAttachment[];
|
|
22
|
+
tags?: Record<string, string>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SendEmailResult = {
|
|
26
|
+
id: string;
|
|
27
|
+
provider?: string;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface EmailProvider {
|
|
32
|
+
send(input: SendEmailInput): Promise<SendEmailResult>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatEmailAddress(address: EmailAddress): string {
|
|
36
|
+
if (!address.name) return address.email;
|
|
37
|
+
const escaped = address.name.replace(/"/g, '\\"');
|
|
38
|
+
return `"${escaped}" <${address.email}>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeEmailAddress(email: string): string {
|
|
42
|
+
const normalized = email.trim().toLowerCase();
|
|
43
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalized)) throw new Error(`Invalid email address: ${email}`);
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function appendUnsubscribeFooter(text: string, unsubscribeUrl: string): string {
|
|
48
|
+
return `${text.trim()}\n\nUnsubscribe: ${unsubscribeUrl}\n`;
|
|
49
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function base64ToArrayBuffer(value: string): ArrayBuffer {
|
|
2
|
+
const binary = atob(value);
|
|
3
|
+
const bytes = new Uint8Array(binary.length);
|
|
4
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
5
|
+
bytes[index] = binary.charCodeAt(index);
|
|
6
|
+
}
|
|
7
|
+
return bytes.buffer;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
11
|
+
const bytes = new Uint8Array(buffer);
|
|
12
|
+
let binary = "";
|
|
13
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
14
|
+
binary += String.fromCharCode(bytes[index]);
|
|
15
|
+
}
|
|
16
|
+
return btoa(binary);
|
|
17
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type EnvSource = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export class EnvError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "EnvError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function requiredString(env: EnvSource, key: string): string {
|
|
11
|
+
const value = env[key];
|
|
12
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
13
|
+
throw new EnvError(`Missing required env var: ${key}`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function optionalString(env: EnvSource, key: string, fallback?: string): string | undefined {
|
|
19
|
+
const value = env[key];
|
|
20
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
21
|
+
if (typeof value !== "string") throw new EnvError(`Expected ${key} to be a string.`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function requiredUrl(env: EnvSource, key: string): string {
|
|
26
|
+
const value = requiredString(env, key);
|
|
27
|
+
try {
|
|
28
|
+
return new URL(value).toString().replace(/\/$/, "");
|
|
29
|
+
} catch {
|
|
30
|
+
throw new EnvError(`Expected ${key} to be a valid URL.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function optionalBoolean(env: EnvSource, key: string, fallback = false): boolean {
|
|
35
|
+
const value = env[key];
|
|
36
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
37
|
+
if (typeof value === "boolean") return value;
|
|
38
|
+
if (typeof value !== "string") throw new EnvError(`Expected ${key} to be boolean-like.`);
|
|
39
|
+
if (/^(1|true|yes|on)$/i.test(value)) return true;
|
|
40
|
+
if (/^(0|false|no|off)$/i.test(value)) return false;
|
|
41
|
+
throw new EnvError(`Expected ${key} to be boolean-like.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function requiredBinding<T>(env: EnvSource, key: string): T {
|
|
45
|
+
const value = env[key];
|
|
46
|
+
if (value === undefined || value === null) throw new EnvError(`Missing required binding: ${key}`);
|
|
47
|
+
return value as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type AppUrlOptions = {
|
|
51
|
+
fallback?: string;
|
|
52
|
+
keys?: string[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function appUrl(
|
|
56
|
+
env: EnvSource,
|
|
57
|
+
fallbackOrOptions: string | AppUrlOptions = "http://localhost:8787",
|
|
58
|
+
): string {
|
|
59
|
+
const options = typeof fallbackOrOptions === "string" ? { fallback: fallbackOrOptions } : fallbackOrOptions;
|
|
60
|
+
const keys = options.keys ?? ["APP_URL", "BASE_URL", "PUBLIC_APP_URL"];
|
|
61
|
+
const value = keys.map((key) => optionalString(env, key)).find((candidate) => candidate !== undefined) ?? options.fallback ?? "http://localhost:8787";
|
|
62
|
+
return value.replace(/\/$/, "");
|
|
63
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
|
|
3
|
+
export function toHex(bytes: Uint8Array): string {
|
|
4
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
8
|
+
let binary = "";
|
|
9
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
10
|
+
return btoa(binary);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function sha256Hex(value: string | ArrayBuffer): Promise<string> {
|
|
14
|
+
const data = typeof value === "string" ? encoder.encode(value) : value;
|
|
15
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
16
|
+
return toHex(new Uint8Array(digest));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function hmacSha256Hex(
|
|
20
|
+
secret: string,
|
|
21
|
+
value: string | ArrayBuffer,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
const signature = await hmacSha256(secret, value);
|
|
24
|
+
return toHex(new Uint8Array(signature));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function hmacSha256Base64(
|
|
28
|
+
secret: string,
|
|
29
|
+
value: string | ArrayBuffer,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const signature = await hmacSha256(secret, value);
|
|
32
|
+
return toBase64(new Uint8Array(signature));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
36
|
+
if (a.length !== b.length) return false;
|
|
37
|
+
let mismatch = 0;
|
|
38
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
39
|
+
mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
40
|
+
}
|
|
41
|
+
return mismatch === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function hmacSha256(
|
|
45
|
+
secret: string,
|
|
46
|
+
value: string | ArrayBuffer,
|
|
47
|
+
): Promise<ArrayBuffer> {
|
|
48
|
+
const key = await crypto.subtle.importKey(
|
|
49
|
+
"raw",
|
|
50
|
+
encoder.encode(secret),
|
|
51
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
52
|
+
false,
|
|
53
|
+
["sign"],
|
|
54
|
+
);
|
|
55
|
+
const data = typeof value === "string" ? encoder.encode(value) : value;
|
|
56
|
+
return crypto.subtle.sign("HMAC", key, data);
|
|
57
|
+
}
|
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,23 @@
|
|
|
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 "./pg-kysely.js";
|
|
18
|
+
export * from "./r2-json.js";
|
|
19
|
+
export * from "./rate-limit.js";
|
|
20
|
+
export * from "./seo.js";
|
|
21
|
+
export * from "./session.js";
|
|
22
|
+
export * from "./testing.js";
|
|
23
|
+
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
|
+
}
|