Skip to content

Commit 513bf9d

Browse files
committed
productionize
1 parent 5b54d7a commit 513bf9d

15 files changed

Lines changed: 453 additions & 41 deletions

.sqlx/query-5c4b0ca90761c24ad202cf91affecae645162448622ff5b19df624e791b85b04.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mise.toml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,29 @@ run = "npm test"
5959
dir = "sdk/ts/events"
6060
depends = ["generate:types", "build:rs"]
6161

62+
[tasks."test:ts"]
63+
depends = [
64+
"test:integration:queue",
65+
"test:integration:events",
66+
"test:types:queue",
67+
"test:types:events",
68+
]
69+
6270
[tasks."test:integration:ts"]
63-
depends = ["test:integration:queue", "test:integration:events"]
71+
depends = ["test:ts"]
72+
73+
[tasks."test:types:queue"]
74+
run = "npx vitest --config vitest.typecheck.config.ts --run"
75+
dir = "sdk/ts/queue"
76+
depends = ["generate:types"]
77+
78+
[tasks."test:types:events"]
79+
run = "npx vitest --config vitest.typecheck.config.ts --run"
80+
dir = "sdk/ts/events"
81+
depends = ["generate:types"]
82+
83+
[tasks."test:types:ts"]
84+
depends = ["test:types:queue", "test:types:events"]
6485

6586
[tasks."generate:openapi"]
6687
run = "cargo run -- generate-openapi"

sdk/ts/events/__tests__/global-setup.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ async function waitForHealthy(url: string, timeoutMs = 30_000): Promise<void> {
2929
const deadline = Date.now() + timeoutMs;
3030
while (Date.now() < deadline) {
3131
try {
32-
const res = await fetch(`${url}/healthz`);
32+
const res = await fetch(`${url}/livez`);
3333
if (res.ok) return;
3434
} catch {
3535
// server not up yet
@@ -50,18 +50,18 @@ export async function setup(): Promise<void> {
5050
const sql = postgres(databaseUrl, { max: 1 });
5151
const schemaPath = resolve(
5252
__dirname,
53-
"../../../../../beyond-queue-extension/sql/schema.sql",
53+
"../../../../beyond-queue-extension/sql/schema.sql",
5454
);
5555
const hotPathsPath = resolve(
5656
__dirname,
57-
"../../../../../tests/fixtures/hot_paths.sql",
57+
"../../../../tests/fixtures/hot_paths.sql",
5858
);
5959
await sql.unsafe(readFileSync(schemaPath, "utf8"));
6060
await sql.unsafe(readFileSync(hotPathsPath, "utf8"));
6161
await sql.end();
6262

6363
const binaryPath = process.env["BEYOND_QUEUE_BINARY"]
64-
?? resolve(__dirname, "../../../../../target/debug/beyond-queue");
64+
?? resolve(__dirname, "../../../../target/debug/beyond-queue");
6565

6666
serverProcess = spawn(binaryPath, ["serve"], {
6767
env: {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expectTypeOf, it } from "vitest";
2+
import {
3+
createEventClient,
4+
type EventClient,
5+
type EventSchemaClient,
6+
} from "../src/index.js";
7+
8+
const schema = {
9+
"user.*": { parse: (v: unknown) => v as { userId: string } },
10+
"order.placed": {
11+
parse: (v: unknown) => v as { orderId: string; amount: number },
12+
},
13+
};
14+
15+
const ev = createEventClient({ schema, url: "http://localhost" });
16+
const plain = createEventClient({ url: "http://localhost" });
17+
18+
describe("createEventClient overloads", () => {
19+
it("returns EventSchemaClient when schema is provided", () => {
20+
expectTypeOf(ev).toMatchTypeOf<EventSchemaClient<typeof schema>>();
21+
});
22+
23+
it("returns EventClient when no schema is provided", () => {
24+
expectTypeOf(plain).toMatchTypeOf<EventClient>();
25+
});
26+
});
27+
28+
describe("publish — payload type", () => {
29+
it("infers payload from exact key match", () => {
30+
expectTypeOf(ev.publish<"order.placed">).parameter(1).toMatchTypeOf<{
31+
orderId: string;
32+
amount: number;
33+
}>();
34+
});
35+
36+
it("infers payload via glob match (user.created → user.*)", () => {
37+
expectTypeOf(ev.publish<"user.created">).parameter(1).toMatchTypeOf<{
38+
userId: string;
39+
}>();
40+
});
41+
42+
it("infers payload via glob match (user.deleted → user.*)", () => {
43+
expectTypeOf(ev.publish<"user.deleted">).parameter(1).toMatchTypeOf<{
44+
userId: string;
45+
}>();
46+
});
47+
48+
it("falls back to JsonValue for unmatched routing key", () => {
49+
expectTypeOf(ev.publish<"payment.failed">).parameter(1).toMatchTypeOf<
50+
string | number | boolean | null | object
51+
>();
52+
});
53+
54+
it("plain client accepts JsonValue for any routing key", () => {
55+
expectTypeOf(plain.publish).parameter(1).toMatchTypeOf<
56+
string | number | boolean | null | object
57+
>();
58+
});
59+
});
60+
61+
describe("subscriptions passthrough", () => {
62+
it("schema client retains subscriptions interface unchanged", () => {
63+
expectTypeOf(ev.subscriptions).toMatchTypeOf<
64+
EventClient["subscriptions"]
65+
>();
66+
});
67+
});

sdk/ts/events/src/client.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ export interface EventClientOptions {
7878
onResponse?: (event: EventResponseEvent) => void;
7979
}
8080

81+
// ── Schema types ──────────────────────────────────────────────────────────────
82+
83+
/** A value parser. Compatible with Zod schemas and any object with `.parse`. */
84+
export type Schema<T> = { parse: (value: unknown) => T };
85+
86+
/** Maps routing key patterns (including globs) to their payload schemas. */
87+
export type EventSchemaMap = Record<string, Schema<unknown>>;
88+
89+
type GlobMatch<K extends string, P extends string> = P extends
90+
`${infer Pre}*${infer Suf}` ? K extends `${Pre}${string}${Suf}` ? true : false
91+
: K extends P ? true
92+
: false;
93+
94+
type MatchedPattern<K extends string, Map extends EventSchemaMap> = {
95+
[P in keyof Map & string]: GlobMatch<K, P> extends true ? P : never;
96+
}[keyof Map & string];
97+
98+
export type EventPayloadType<K extends string, Map extends EventSchemaMap> =
99+
[MatchedPattern<K, Map>] extends [never] ? JsonValue
100+
: Map[MatchedPattern<K, Map> & keyof Map] extends Schema<infer T> ? T
101+
: JsonValue;
102+
103+
// ── Client interfaces ─────────────────────────────────────────────────────────
104+
81105
export interface EventClient {
82106
/**
83107
* Publish a message to a routing key. All subscriptions whose pattern
@@ -110,6 +134,21 @@ export interface EventClient {
110134
close(): Promise<void>;
111135
}
112136

137+
/**
138+
* An event client with schema-aware payload types. Returned by `createEventClient`
139+
* when a `schema` map is provided. Routing keys matching a schema pattern get typed
140+
* payloads; unmatched keys fall back to `JsonValue`.
141+
*/
142+
export interface EventSchemaClient<Map extends EventSchemaMap>
143+
extends Omit<EventClient, "publish">
144+
{
145+
publish<K extends string>(
146+
routingKey: K,
147+
payload: EventPayloadType<K, Map>,
148+
opts?: PublishOptions,
149+
): EventResult<PublishResult>;
150+
}
151+
113152
// ── Internal helpers ──────────────────────────────────────────────────────────
114153

115154
function toEventError(error: unknown, response: Response): EventError {
@@ -184,22 +223,29 @@ function buildFetch(
184223

185224
// ── Factory ───────────────────────────────────────────────────────────────────
186225

226+
/** Creates a schema-aware event client. Publish payload types are inferred from the schema map. */
227+
export function createEventClient<Map extends EventSchemaMap>(
228+
opts: EventClientOptions & { schema: Map },
229+
): EventSchemaClient<Map>;
187230
/** Creates an event client backed by the beyond-queue HTTP API. */
188-
export function createEventClient(opts: EventClientOptions = {}): EventClient {
189-
const url = opts.url ?? env["BEYOND_EVENTS_URL"];
231+
export function createEventClient(opts?: EventClientOptions): EventClient;
232+
export function createEventClient(
233+
opts?: EventClientOptions & { schema?: EventSchemaMap },
234+
): EventClient {
235+
const url = opts?.url ?? env["BEYOND_EVENTS_URL"];
190236
if (!url) {
191237
throw new Error(
192238
"BEYOND_EVENTS_URL is required (pass `url` or set the BEYOND_EVENTS_URL env var)",
193239
);
194240
}
195241
const base = url.replace(/\/+$/, "");
196-
const token = opts.token ?? env["BEYOND_EVENTS_TOKEN"];
197-
const { onRequest, onResponse } = opts;
242+
const token = opts?.token ?? env["BEYOND_EVENTS_TOKEN"];
243+
const { onRequest, onResponse } = opts ?? {};
198244

199245
const client = createFetchClient<paths>({
200246
baseUrl: base,
201247
headers: { Authorization: `Bearer ${token ?? "anon"}` },
202-
fetch: buildFetch(opts.fetch, opts.retries ?? 2, opts.timeout),
248+
fetch: buildFetch(opts?.fetch, opts?.retries ?? 2, opts?.timeout),
203249
});
204250

205251
function cmd<A extends unknown[], R>(

sdk/ts/events/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ export { createEventClient } from "./client.js";
1818
export type {
1919
EventClient,
2020
EventClientOptions,
21+
EventPayloadType,
2122
EventResult,
23+
EventSchemaClient,
24+
EventSchemaMap,
2225
EventTarget,
2326
JsonValue,
2427
PublishOptions,
2528
PublishResult,
29+
Schema,
2630
Subscription,
2731
} from "./client.js";
2832
export { EventError } from "./errors.js";
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
environment: "node",
6+
include: ["__tests__/**/*.test-d.ts"],
7+
typecheck: {
8+
include: ["__tests__/**/*.test-d.ts"],
9+
},
10+
},
11+
});

sdk/ts/queue/__tests__/global-setup.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ async function waitForHealthy(url: string, timeoutMs = 30_000): Promise<void> {
2929
const deadline = Date.now() + timeoutMs;
3030
while (Date.now() < deadline) {
3131
try {
32-
const res = await fetch(`${url}/healthz`);
32+
const res = await fetch(`${url}/livez`);
3333
if (res.ok) return;
3434
} catch {
3535
// server not up yet
@@ -50,18 +50,18 @@ export async function setup(): Promise<void> {
5050
const sql = postgres(databaseUrl, { max: 1 });
5151
const schemaPath = resolve(
5252
__dirname,
53-
"../../../../../beyond-queue-extension/sql/schema.sql",
53+
"../../../../beyond-queue-extension/sql/schema.sql",
5454
);
5555
const hotPathsPath = resolve(
5656
__dirname,
57-
"../../../../../tests/fixtures/hot_paths.sql",
57+
"../../../../tests/fixtures/hot_paths.sql",
5858
);
5959
await sql.unsafe(readFileSync(schemaPath, "utf8"));
6060
await sql.unsafe(readFileSync(hotPathsPath, "utf8"));
6161
await sql.end();
6262

6363
const binaryPath = process.env["BEYOND_QUEUE_BINARY"]
64-
?? resolve(__dirname, "../../../../../target/debug/beyond-queue");
64+
?? resolve(__dirname, "../../../../target/debug/beyond-queue");
6565

6666
serverProcess = spawn(binaryPath, ["serve"], {
6767
env: {

0 commit comments

Comments
 (0)