@pylonsync/functions 0.2.4

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/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@pylonsync/functions",
3
+ "version": "0.2.4",
4
+ "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./runtime": "./src/runtime.ts"
14
+ },
15
+ "bin": {
16
+ "pylon-functions-runtime": "src/runtime.ts"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit",
24
+ "build": "tsc"
25
+ },
26
+ "keywords": [
27
+ "pylon",
28
+ "typescript",
29
+ "functions",
30
+ "database"
31
+ ],
32
+ "license": "MIT OR Apache-2.0",
33
+ "devDependencies": {
34
+ "typescript": "^5.5"
35
+ },
36
+ "peerDependencies": {
37
+ "bun-types": "*"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "bun-types": {
41
+ "optional": true
42
+ }
43
+ }
44
+ }
package/src/define.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Function definition constructors.
3
+ *
4
+ * These are the primary API for defining server-side functions.
5
+ */
6
+
7
+ import type {
8
+ FnDefinition,
9
+ QueryCtx,
10
+ MutationCtx,
11
+ ActionCtx,
12
+ Validator,
13
+ } from "./types";
14
+
15
+ interface QueryDef<TArgs, TReturn> {
16
+ args?: Record<string, Validator>;
17
+ handler: (ctx: QueryCtx, args: TArgs) => Promise<TReturn>;
18
+ }
19
+
20
+ interface MutationDef<TArgs, TReturn> {
21
+ args?: Record<string, Validator>;
22
+ handler: (ctx: MutationCtx, args: TArgs) => Promise<TReturn>;
23
+ }
24
+
25
+ interface ActionDef<TArgs, TReturn> {
26
+ args?: Record<string, Validator>;
27
+ handler: (ctx: ActionCtx, args: TArgs) => Promise<TReturn>;
28
+ }
29
+
30
+ /**
31
+ * Define a read-only query function.
32
+ *
33
+ * Queries use the read pool — they never block writes and can run
34
+ * concurrently. They cannot modify data.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * export default query({
39
+ * args: { auctionId: v.string() },
40
+ * async handler(ctx, args) {
41
+ * return ctx.db.query("Lot", {
42
+ * auctionId: args.auctionId,
43
+ * $order: { closesAt: "asc" },
44
+ * });
45
+ * },
46
+ * });
47
+ * ```
48
+ */
49
+ export function query<TArgs = Record<string, unknown>, TReturn = unknown>(
50
+ def: QueryDef<TArgs, TReturn>
51
+ ): FnDefinition<TArgs, TReturn> {
52
+ return { type: "query", args: def.args, handler: def.handler };
53
+ }
54
+
55
+ /**
56
+ * Define a transactional mutation function.
57
+ *
58
+ * The entire handler IS the transaction. If it returns, all writes commit
59
+ * atomically. If it throws, all writes roll back — including scheduled
60
+ * functions.
61
+ *
62
+ * Mutations can stream data to the client via `ctx.stream.write()`.
63
+ * Stream chunks are sent immediately; DB writes commit at the end.
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * export default mutation({
68
+ * args: { lotId: v.string(), amount: v.number() },
69
+ * async handler(ctx, args) {
70
+ * const lot = await ctx.db.get("Lot", args.lotId);
71
+ * if (!lot) throw ctx.error("NOT_FOUND", "Lot not found");
72
+ * await ctx.db.insert("Bid", { lotId: args.lotId, amount: args.amount });
73
+ * return { accepted: true };
74
+ * },
75
+ * });
76
+ * ```
77
+ */
78
+ export function mutation<TArgs = Record<string, unknown>, TReturn = unknown>(
79
+ def: MutationDef<TArgs, TReturn>
80
+ ): FnDefinition<TArgs, TReturn> {
81
+ return { type: "mutation", args: def.args, handler: def.handler };
82
+ }
83
+
84
+ /**
85
+ * Define an action function (external I/O allowed).
86
+ *
87
+ * Actions can call external APIs (fetch, email, Stripe, etc.) but cannot
88
+ * access the database directly. Use `ctx.runQuery()` and `ctx.runMutation()`
89
+ * for DB access — each runs in its own transaction.
90
+ *
91
+ * Actions are NOT automatically retried because they may have side effects.
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * export default action({
96
+ * args: { lotId: v.string() },
97
+ * async handler(ctx, args) {
98
+ * const lot = await ctx.runQuery("lotDetails", { lotId: args.lotId });
99
+ * await fetch("https://api.sendgrid.com/...", { ... });
100
+ * await ctx.runMutation("markNotified", { lotId: args.lotId });
101
+ * },
102
+ * });
103
+ * ```
104
+ */
105
+ export function action<TArgs = Record<string, unknown>, TReturn = unknown>(
106
+ def: ActionDef<TArgs, TReturn>
107
+ ): FnDefinition<TArgs, TReturn> {
108
+ return { type: "action", args: def.args, handler: def.handler };
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @pylonsync/functions — TypeScript function definitions for pylon.
3
+ *
4
+ * This is the developer-facing API. App developers import from here
5
+ * to define queries, mutations, and actions.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { mutation, v } from "@pylonsync/functions";
10
+ *
11
+ * export default mutation({
12
+ * args: { lotId: v.string(), amount: v.number() },
13
+ * async handler(ctx, args) {
14
+ * const lot = await ctx.db.get("Lot", args.lotId);
15
+ * // ...
16
+ * },
17
+ * });
18
+ * ```
19
+ */
20
+
21
+ export { query, mutation, action } from "./define";
22
+ export { v } from "./validators";
23
+ export { resetDb, installTestIsolation } from "./testing";
24
+ export type {
25
+ QueryCtx,
26
+ MutationCtx,
27
+ ActionCtx,
28
+ DbReader,
29
+ DbWriter,
30
+ Stream,
31
+ Scheduler,
32
+ AuthInfo,
33
+ FnDefinition,
34
+ } from "./types";
package/src/runtime.ts ADDED
@@ -0,0 +1,668 @@
1
+ /**
2
+ * Function runtime — the Bun process that loads and executes TypeScript functions.
3
+ *
4
+ * Protocol: NDJSON over stdin/stdout.
5
+ *
6
+ * Usage:
7
+ * bun run packages/functions/src/runtime.ts ./functions
8
+ *
9
+ * Design:
10
+ * - A single reader consumes lines from stdin and dispatches by message type.
11
+ * - Incoming `call` messages launch a handler.
12
+ * - Incoming `result` messages resolve a pending RPC keyed by call_id.
13
+ * - Each call's handler has at most ONE outstanding RPC at a time (it awaits
14
+ * each ctx.db / ctx.scheduler / ctx.runMutation call), so the map never
15
+ * needs to queue multiple RPCs per call_id.
16
+ */
17
+
18
+ import type {
19
+ DbReader,
20
+ DbWriter,
21
+ Stream,
22
+ Scheduler,
23
+ QueryCtx,
24
+ MutationCtx,
25
+ ActionCtx,
26
+ FnDefinition,
27
+ AuthInfo,
28
+ } from "./types";
29
+ import { validateArgs } from "./validators";
30
+ import { readdirSync } from "fs";
31
+ import { join, basename } from "path";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Protocol types
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface CallMessage {
38
+ type: "call";
39
+ call_id: string;
40
+ fn_name: string;
41
+ fn_type: "query" | "mutation" | "action";
42
+ args: Record<string, unknown>;
43
+ auth: AuthInfo;
44
+ }
45
+
46
+ interface ResultMessage {
47
+ type: "result";
48
+ call_id: string;
49
+ data?: unknown;
50
+ error?: { code: string; message: string };
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Send
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function send(msg: Record<string, unknown>): void {
58
+ const line = JSON.stringify(msg) + "\n";
59
+ Bun.write(Bun.stdout, line);
60
+ }
61
+
62
+ /**
63
+ * Redirect console.* from user code to stderr so handlers can't accidentally
64
+ * emit a line that looks like a protocol frame and confuse the Rust reader.
65
+ *
66
+ * Before this guard, a handler calling `console.log('{"type":"return",...}')`
67
+ * — either intentionally or by logging an object shaped that way — would be
68
+ * parsed by the host as a real protocol message. Moving all console output
69
+ * to stderr keeps stdout reserved for NDJSON protocol frames only.
70
+ *
71
+ * The original console methods are saved on the console object as
72
+ * `__stdoutLog` etc. in case the runtime itself needs to write diagnostics
73
+ * to stdout for some reason (it currently doesn't).
74
+ */
75
+ function fenceStdout(): void {
76
+ const toStderr = (prefix: string) => (...args: unknown[]) => {
77
+ const line = args
78
+ .map((a) => {
79
+ if (typeof a === "string") return a;
80
+ try {
81
+ return JSON.stringify(a);
82
+ } catch {
83
+ return String(a);
84
+ }
85
+ })
86
+ .join(" ");
87
+ Bun.write(Bun.stderr, `${prefix}${line}\n`);
88
+ };
89
+ // Intentional: we want console.* for user handlers to go to stderr.
90
+ // Overwrite the globals before any user code is loaded.
91
+ const c = globalThis.console as unknown as Record<string, unknown>;
92
+ c.__stdoutLog = c.log;
93
+ c.log = toStderr("");
94
+ c.info = toStderr("");
95
+ c.warn = toStderr("[warn] ");
96
+ c.error = toStderr("[error] ");
97
+ c.debug = toStderr("[debug] ");
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Single reader + dispatcher
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Pending RPCs keyed by op_id (with a fallback to call_id for legacy hosts
106
+ * that don't echo op_id). Each in-flight host → TS RPC gets its own
107
+ * op_id so two concurrent DB ops from the same handler —
108
+ * `Promise.all([ctx.db.get(a), ctx.db.get(b)])` — don't collide on the
109
+ * outer call_id. Scheduler/runFn replies still route by call_id (one
110
+ * outstanding per call is correct for those).
111
+ */
112
+ const pendingRpcs = new Map<
113
+ string,
114
+ {
115
+ resolve: (data: unknown) => void;
116
+ reject: (err: Error) => void;
117
+ timeout: ReturnType<typeof setTimeout>;
118
+ }
119
+ >();
120
+
121
+ let opSeq = 0;
122
+ function nextOpId(callId: string): string {
123
+ opSeq += 1;
124
+ return `${callId}#${opSeq}`;
125
+ }
126
+
127
+ /**
128
+ * Upper bound on how long an individual host → TS RPC (e.g. `ctx.db.get`)
129
+ * can wait for a reply. The Rust side enforces its own per-handler timeout
130
+ * (PYLON_FN_CALL_TIMEOUT, default 30s), but if a protocol frame gets
131
+ * truncated or dropped, the awaiting promise would hang forever. This is
132
+ * the safety net.
133
+ *
134
+ * 60s is deliberately longer than the Rust-side call timeout so that the
135
+ * host always gets to time out first (with a meaningful error), not the
136
+ * TS side (with a generic orphaned-rpc error).
137
+ */
138
+ const RPC_TIMEOUT_MS = 60_000;
139
+
140
+ async function readerLoop(): Promise<void> {
141
+ const reader = Bun.stdin.stream().getReader();
142
+ const decoder = new TextDecoder();
143
+ let buffer = "";
144
+
145
+ while (true) {
146
+ const { done, value } = await reader.read();
147
+ if (done) break;
148
+
149
+ buffer += decoder.decode(value, { stream: true });
150
+ const lines = buffer.split("\n");
151
+ buffer = lines.pop() || "";
152
+
153
+ for (const line of lines) {
154
+ if (!line.trim()) continue;
155
+ dispatch(line);
156
+ }
157
+ }
158
+
159
+ if (buffer.trim()) dispatch(buffer);
160
+
161
+ // stdin closed — the host is gone. Reject every pending RPC so awaiting
162
+ // handlers unwind instead of hanging and keeping the Bun process alive
163
+ // forever. Clearing timers avoids keeping the event loop ticking either.
164
+ for (const [callId, pending] of pendingRpcs) {
165
+ clearTimeout(pending.timeout);
166
+ pending.reject(
167
+ new Error(`host disconnected before reply (call_id=${callId})`),
168
+ );
169
+ }
170
+ pendingRpcs.clear();
171
+ }
172
+
173
+ function dispatch(line: string): void {
174
+ let msg: { type: string } & Record<string, unknown>;
175
+ try {
176
+ msg = JSON.parse(line);
177
+ } catch {
178
+ return;
179
+ }
180
+
181
+ if (msg.type === "call") {
182
+ // Launch handler; errors are reported back via the protocol, not thrown.
183
+ handleCall(msg as unknown as CallMessage).catch((err) => {
184
+ send({
185
+ type: "error",
186
+ call_id: (msg as unknown as CallMessage).call_id,
187
+ code: "HANDLER_CRASH",
188
+ message: err?.message || String(err),
189
+ });
190
+ });
191
+ } else if (msg.type === "result") {
192
+ const res = msg as unknown as ResultMessage & { op_id?: string };
193
+ // Prefer op_id when the host sent it. Fall back to call_id for replies
194
+ // that don't have one (scheduler / runFn) and for legacy hosts.
195
+ const key = res.op_id ?? res.call_id;
196
+ const pending = pendingRpcs.get(key);
197
+ if (!pending) return;
198
+ pendingRpcs.delete(key);
199
+ clearTimeout(pending.timeout);
200
+ if (res.error) {
201
+ const err = new Error(res.error.message);
202
+ (err as any).code = res.error.code;
203
+ pending.reject(err);
204
+ } else {
205
+ pending.resolve(res.data);
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * RPC for DB operations: mints a per-op id so two concurrent DB ops from
212
+ * the same handler can be in flight at once without colliding. The host
213
+ * echoes `op_id` back in the `result` reply, which the dispatcher uses
214
+ * to route the resolution.
215
+ */
216
+ function rpcDb(
217
+ callId: string,
218
+ msg: Record<string, unknown>,
219
+ ): Promise<unknown> {
220
+ const opId = nextOpId(callId);
221
+ return new Promise((resolve, reject) => {
222
+ const timeout = setTimeout(() => {
223
+ if (pendingRpcs.has(opId)) {
224
+ pendingRpcs.delete(opId);
225
+ reject(
226
+ new Error(
227
+ `RPC timed out after ${RPC_TIMEOUT_MS}ms (call_id=${callId} op_id=${opId})`,
228
+ ),
229
+ );
230
+ }
231
+ }, RPC_TIMEOUT_MS);
232
+ pendingRpcs.set(opId, { resolve, reject, timeout });
233
+ send({ ...msg, call_id: callId, op_id: opId });
234
+ });
235
+ }
236
+
237
+ /**
238
+ * RPC for non-db protocol replies (scheduler.runAfter, nested function
239
+ * calls, etc.) where at-most-one in-flight per call_id is the right
240
+ * contract. Keeps the legacy keying so these reply shapes don't need
241
+ * op_id support on the host.
242
+ */
243
+ function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
244
+ return new Promise((resolve, reject) => {
245
+ if (pendingRpcs.has(callId)) {
246
+ reject(
247
+ new Error(
248
+ `Internal: concurrent RPC attempted on same call_id (${callId})`,
249
+ ),
250
+ );
251
+ return;
252
+ }
253
+ const timeout = setTimeout(() => {
254
+ if (pendingRpcs.has(callId)) {
255
+ pendingRpcs.delete(callId);
256
+ reject(
257
+ new Error(
258
+ `RPC timed out after ${RPC_TIMEOUT_MS}ms (call_id=${callId})`,
259
+ ),
260
+ );
261
+ }
262
+ }, RPC_TIMEOUT_MS);
263
+ pendingRpcs.set(callId, { resolve, reject, timeout });
264
+ send({ ...msg, call_id: callId });
265
+ });
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Context builders
270
+ // ---------------------------------------------------------------------------
271
+
272
+ function buildDbReader(callId: string): DbReader {
273
+ // All DB ops use rpcDb so Promise.all over ctx.db reads can run in
274
+ // parallel without colliding on the outer call_id key.
275
+ return {
276
+ async get(entity, id) {
277
+ return (await rpcDb(callId, { type: "db", op: "get", entity, id })) as any;
278
+ },
279
+ async list(entity) {
280
+ return (await rpcDb(callId, { type: "db", op: "list", entity })) as any;
281
+ },
282
+ async lookup(entity, field, value) {
283
+ return (await rpcDb(callId, {
284
+ type: "db",
285
+ op: "lookup",
286
+ entity,
287
+ field,
288
+ value,
289
+ })) as any;
290
+ },
291
+ async query(entity, filter) {
292
+ return (await rpcDb(callId, {
293
+ type: "db",
294
+ op: "query",
295
+ entity,
296
+ data: filter,
297
+ })) as any;
298
+ },
299
+ async queryGraph(query) {
300
+ return (await rpcDb(callId, {
301
+ type: "db",
302
+ op: "query_graph",
303
+ entity: "",
304
+ data: query,
305
+ })) as any;
306
+ },
307
+ async paginate(entity, opts) {
308
+ // Clamp on the client side too so a caller never wastes a round trip
309
+ // with out-of-range values. The Rust side re-clamps.
310
+ const numItems = Math.max(1, Math.min(1000, opts.numItems | 0));
311
+ return (await rpcDb(callId, {
312
+ type: "db",
313
+ op: "paginate",
314
+ entity,
315
+ after: opts.cursor ?? undefined,
316
+ limit: numItems,
317
+ })) as any;
318
+ },
319
+ };
320
+ }
321
+
322
+ function buildDbWriter(callId: string): DbWriter {
323
+ const reader = buildDbReader(callId);
324
+ return {
325
+ ...reader,
326
+ async insert(entity, data) {
327
+ const r = (await rpcDb(callId, {
328
+ type: "db",
329
+ op: "insert",
330
+ entity,
331
+ data,
332
+ })) as { id: string };
333
+ return r.id;
334
+ },
335
+ async update(entity, id, data) {
336
+ const r = (await rpcDb(callId, {
337
+ type: "db",
338
+ op: "update",
339
+ entity,
340
+ id,
341
+ data,
342
+ })) as { updated: boolean };
343
+ return r.updated;
344
+ },
345
+ async delete(entity, id) {
346
+ const r = (await rpcDb(callId, {
347
+ type: "db",
348
+ op: "delete",
349
+ entity,
350
+ id,
351
+ })) as { deleted: boolean };
352
+ return r.deleted;
353
+ },
354
+ async link(entity, id, relation, targetId) {
355
+ const r = (await rpcDb(callId, {
356
+ type: "db",
357
+ op: "link",
358
+ entity,
359
+ id,
360
+ relation,
361
+ target_id: targetId,
362
+ })) as { linked: boolean };
363
+ return r.linked;
364
+ },
365
+ async unlink(entity, id, relation) {
366
+ const r = (await rpcDb(callId, {
367
+ type: "db",
368
+ op: "unlink",
369
+ entity,
370
+ id,
371
+ relation,
372
+ })) as { unlinked: boolean };
373
+ return r.unlinked;
374
+ },
375
+ };
376
+ }
377
+
378
+ function buildStream(callId: string): Stream {
379
+ return {
380
+ write(data: string) {
381
+ // Stream messages are fire-and-forget; they don't get a `result` reply.
382
+ send({ type: "stream", call_id: callId, data });
383
+ },
384
+ writeEvent(event: string, data: string) {
385
+ send({ type: "stream", call_id: callId, data, event });
386
+ },
387
+ };
388
+ }
389
+
390
+ function buildScheduler(callId: string): Scheduler {
391
+ return {
392
+ async runAfter(delayMs, fnName, args) {
393
+ const r = (await rpc(callId, {
394
+ type: "schedule",
395
+ fn_name: fnName,
396
+ args,
397
+ delay_ms: delayMs,
398
+ })) as { id?: string };
399
+ return r.id || "";
400
+ },
401
+ async runAt(timestamp, fnName, args) {
402
+ const r = (await rpc(callId, {
403
+ type: "schedule",
404
+ fn_name: fnName,
405
+ args,
406
+ run_at: timestamp,
407
+ })) as { id?: string };
408
+ return r.id || "";
409
+ },
410
+ async cancel(scheduleId) {
411
+ await rpc(callId, {
412
+ type: "cancel_schedule",
413
+ schedule_id: scheduleId,
414
+ });
415
+ },
416
+ };
417
+ }
418
+
419
+ function buildActionCtx(
420
+ callId: string,
421
+ auth: AuthInfo,
422
+ stream: Stream,
423
+ scheduler: Scheduler,
424
+ request?: unknown
425
+ ): ActionCtx {
426
+ // The host sends `request` as snake_case JSON (`raw_body`); normalize it
427
+ // to the camelCase shape documented in ActionCtx so action authors don't
428
+ // have to care about the transport. Absent when invoked programmatically.
429
+ let normalizedRequest: ActionCtx["request"];
430
+ if (request && typeof request === "object") {
431
+ const r = request as Record<string, unknown>;
432
+ normalizedRequest = {
433
+ method: String(r.method ?? ""),
434
+ path: String(r.path ?? ""),
435
+ headers: (r.headers as Record<string, string>) ?? {},
436
+ rawBody: String(r.raw_body ?? r.rawBody ?? ""),
437
+ };
438
+ }
439
+ return {
440
+ auth,
441
+ stream,
442
+ scheduler,
443
+ env: process.env as Record<string, string>,
444
+ async runQuery(fnName, args) {
445
+ return rpc(callId, {
446
+ type: "run_fn",
447
+ fn_name: fnName,
448
+ fn_type: "query",
449
+ args,
450
+ }) as Promise<any>;
451
+ },
452
+ async runMutation(fnName, args) {
453
+ return rpc(callId, {
454
+ type: "run_fn",
455
+ fn_name: fnName,
456
+ fn_type: "mutation",
457
+ args,
458
+ }) as Promise<any>;
459
+ },
460
+ error(code, message) {
461
+ const err = new Error(message);
462
+ (err as any).code = code;
463
+ return err;
464
+ },
465
+ request: normalizedRequest,
466
+ };
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Registry
471
+ // ---------------------------------------------------------------------------
472
+
473
+ const registry = new Map<string, FnDefinition>();
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Handler
477
+ // ---------------------------------------------------------------------------
478
+
479
+ async function handleCall(msg: CallMessage): Promise<void> {
480
+ const def = registry.get(msg.fn_name);
481
+
482
+ if (!def) {
483
+ send({
484
+ type: "error",
485
+ call_id: msg.call_id,
486
+ code: "FN_NOT_FOUND",
487
+ message: `Function "${msg.fn_name}" not registered`,
488
+ });
489
+ return;
490
+ }
491
+
492
+ // Enforce that the caller's declared fn_type matches what's registered.
493
+ // Without this, a buggy or malicious peer could label a mutation call as
494
+ // a query and break host-side assumptions about side effects / auth.
495
+ // Accept msg.fn_type undefined for backwards compatibility — the host
496
+ // always sends it in current versions.
497
+ if (msg.fn_type && msg.fn_type !== def.type) {
498
+ send({
499
+ type: "error",
500
+ call_id: msg.call_id,
501
+ code: "FN_TYPE_MISMATCH",
502
+ message: `Function "${msg.fn_name}" is registered as ${def.type}, not ${msg.fn_type}`,
503
+ });
504
+ return;
505
+ }
506
+
507
+ if (def.args) {
508
+ const { valid, errors } = validateArgs(msg.args, def.args);
509
+ if (!valid) {
510
+ send({
511
+ type: "error",
512
+ call_id: msg.call_id,
513
+ code: "INVALID_ARGS",
514
+ message: errors.join("; "),
515
+ });
516
+ return;
517
+ }
518
+ }
519
+
520
+ const stream = buildStream(msg.call_id);
521
+ const scheduler = buildScheduler(msg.call_id);
522
+
523
+ // Normalize the Rust-side auth envelope (snake_case) to the camelCase
524
+ // shape that AuthInfo documents. Handlers read `ctx.auth.userId`; the
525
+ // wire uses `user_id`. Without this adapter every handler's
526
+ // `if (!ctx.auth.userId)` check fires and authenticated calls come
527
+ // back as UNAUTHENTICATED. Accept both shapes so old TS runtimes that
528
+ // already got camelCase don't regress.
529
+ const rawAuth = msg.auth as unknown as Record<string, unknown>;
530
+ const auth: AuthInfo = {
531
+ userId: ((rawAuth.userId ?? rawAuth.user_id) as string | null | undefined) ?? null,
532
+ isAdmin: Boolean(rawAuth.isAdmin ?? rawAuth.is_admin),
533
+ tenantId:
534
+ ((rawAuth.tenantId ?? rawAuth.tenant_id) as string | null | undefined) ??
535
+ null,
536
+ };
537
+
538
+ let ctx: QueryCtx | MutationCtx | ActionCtx;
539
+ switch (def.type) {
540
+ case "query":
541
+ ctx = { db: buildDbReader(msg.call_id), auth };
542
+ break;
543
+ case "mutation":
544
+ ctx = {
545
+ db: buildDbWriter(msg.call_id),
546
+ auth,
547
+ stream,
548
+ scheduler,
549
+ error(code, message) {
550
+ const err = new Error(message);
551
+ (err as any).code = code;
552
+ return err;
553
+ },
554
+ };
555
+ break;
556
+ case "action":
557
+ // Pass `msg.request` so actions invoked via `defineRoute` HTTP
558
+ // bindings can reach raw headers + body (for webhook signature
559
+ // verification). Programmatic invocations (runAction, jobs) get
560
+ // undefined here and `ctx.request` reads as undefined — the type
561
+ // is optional on purpose.
562
+ ctx = buildActionCtx(
563
+ msg.call_id,
564
+ auth,
565
+ stream,
566
+ scheduler,
567
+ (msg as unknown as { request?: unknown }).request,
568
+ );
569
+ break;
570
+ }
571
+
572
+ try {
573
+ const result = await def.handler(ctx, msg.args);
574
+ send({
575
+ type: "return",
576
+ call_id: msg.call_id,
577
+ value: result ?? null,
578
+ });
579
+ } catch (err: any) {
580
+ // Redact. Handler errors historically shipped raw `err.message` to the
581
+ // caller, which leaked DB error text, stack-trace-looking strings, and
582
+ // internal concurrency-invariant messages. Authors can still surface a
583
+ // caller-safe message by throwing with an explicit `code` AND a message
584
+ // they're willing to disclose: `ctx.error(code, message)` uses that
585
+ // pattern. Anything else gets a generic message; the full error is
586
+ // logged to stderr where the operator can see it.
587
+ const hasExplicitCode = typeof err?.code === "string" && err.code.length > 0;
588
+ if (hasExplicitCode) {
589
+ send({
590
+ type: "error",
591
+ call_id: msg.call_id,
592
+ code: err.code,
593
+ message:
594
+ typeof err.message === "string" && err.message.length > 0
595
+ ? err.message
596
+ : "Handler error",
597
+ });
598
+ } else {
599
+ // No explicit code — assume it's an unexpected Error/thrown value.
600
+ // Log the real error to stderr (server operator visible) and return
601
+ // a safe placeholder to the client.
602
+ console.error(
603
+ `[functions] unhandled error in ${msg.fn_name} (${msg.call_id}):`,
604
+ err,
605
+ );
606
+ send({
607
+ type: "error",
608
+ call_id: msg.call_id,
609
+ code: "HANDLER_ERROR",
610
+ message: "Internal handler error",
611
+ });
612
+ }
613
+ }
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Startup: scan functions dir, send ready, then start reader loop
618
+ // ---------------------------------------------------------------------------
619
+
620
+ async function main() {
621
+ // Fence user `console.*` away from stdout BEFORE any user code is
622
+ // imported — the import side-effects alone could print a stray line
623
+ // that the host parses as a protocol frame.
624
+ fenceStdout();
625
+
626
+ const fnDir = process.argv[2] || "./functions";
627
+
628
+ let files: string[];
629
+ try {
630
+ files = readdirSync(fnDir).filter(
631
+ (f) => f.endsWith(".ts") || f.endsWith(".js")
632
+ );
633
+ } catch {
634
+ send({
635
+ type: "ready",
636
+ functions: [],
637
+ error: `Cannot read functions directory: ${fnDir}`,
638
+ });
639
+ return;
640
+ }
641
+
642
+ for (const file of files) {
643
+ const name = basename(file, file.endsWith(".ts") ? ".ts" : ".js");
644
+ try {
645
+ const mod = await import(join(process.cwd(), fnDir, file));
646
+ const def = mod.default as FnDefinition;
647
+ if (def && def.type && def.handler) {
648
+ registry.set(name, def);
649
+ }
650
+ } catch (err) {
651
+ console.error(`[functions] Failed to load ${file}:`, err);
652
+ }
653
+ }
654
+
655
+ const functions = Array.from(registry.entries()).map(([name, def]) => ({
656
+ name,
657
+ fn_type: def.type,
658
+ args_schema: def.args || null,
659
+ }));
660
+ send({ type: "ready", functions });
661
+
662
+ await readerLoop();
663
+ }
664
+
665
+ main().catch((err) => {
666
+ console.error("[functions] Fatal error:", err);
667
+ process.exit(1);
668
+ });
package/src/testing.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Test-side helpers for `pylon test`.
3
+ *
4
+ * The CLI already starts each test FILE with a fresh in-memory database
5
+ * (`PYLON_IN_MEMORY=1`), so tests across files never cross-contaminate.
6
+ * This module covers the finer-grained case: isolating individual
7
+ * `test(...)` blocks within a single file.
8
+ *
9
+ * Two integration patterns are supported:
10
+ *
11
+ * 1. **Manual** — call `resetDb()` from a `beforeEach` hook.
12
+ * 2. **Automatic** — call `installTestIsolation()` at the top of the file
13
+ * and every `test()` block runs with a reset store.
14
+ *
15
+ * Both require the test file to run under `pylon test` (not raw
16
+ * `bun test`), because resetDb talks to the server via HTTP.
17
+ */
18
+
19
+ const DEFAULT_BASE_URL = "http://localhost:4321";
20
+
21
+ /**
22
+ * Reset the in-memory database to empty. Returns when the server confirms.
23
+ *
24
+ * Only works when the server is running in in-memory dev mode — production
25
+ * deployments refuse this call. Safe to no-op when the reset endpoint is
26
+ * unreachable so tests using this helper still work under raw `bun test`
27
+ * (they just won't reset between cases).
28
+ */
29
+ export async function resetDb(
30
+ baseUrl: string = DEFAULT_BASE_URL,
31
+ ): Promise<void> {
32
+ try {
33
+ const res = await fetch(`${baseUrl}/api/__test__/reset`, {
34
+ method: "POST",
35
+ headers: {
36
+ "Content-Type": "application/json",
37
+ },
38
+ });
39
+ if (!res.ok && res.status !== 404) {
40
+ const body = await res.text().catch(() => "");
41
+ throw new Error(
42
+ `resetDb failed: ${res.status} ${body.slice(0, 200)}`,
43
+ );
44
+ }
45
+ } catch (err: any) {
46
+ // Fetch may throw if the server isn't up yet. Tests that don't need
47
+ // isolation still succeed; tests that do will see pollution and fail
48
+ // loudly on their own assertions. Log so authors can debug.
49
+ // eslint-disable-next-line no-console
50
+ console.warn("[pylon-test] resetDb skipped:", err?.message ?? err);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Bun-friendly `beforeEach(resetDb)` installer. Looks up Bun's global
56
+ * `beforeEach` via `globalThis`; no-ops under other runners.
57
+ */
58
+ export function installTestIsolation(
59
+ baseUrl: string = DEFAULT_BASE_URL,
60
+ ): void {
61
+ const g = globalThis as any;
62
+ if (typeof g.beforeEach === "function") {
63
+ g.beforeEach(() => resetDb(baseUrl));
64
+ } else {
65
+ // eslint-disable-next-line no-console
66
+ console.warn(
67
+ "[pylon-test] installTestIsolation: no global beforeEach found — run via `pylon test` or `bun test`",
68
+ );
69
+ }
70
+ }
package/src/types.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Type definitions for the function system.
3
+ */
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Auth
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export interface AuthInfo {
10
+ userId: string | null;
11
+ isAdmin: boolean;
12
+ /** Active tenant id (selected organization) for multi-tenant apps.
13
+ * Null when the session hasn't selected one. */
14
+ tenantId: string | null;
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Database — read operations
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface DbReader {
22
+ /** Get a single row by ID. Returns null if not found. */
23
+ get(entity: string, id: string): Promise<Record<string, unknown> | null>;
24
+
25
+ /** List all rows for an entity. */
26
+ list(entity: string): Promise<Record<string, unknown>[]>;
27
+
28
+ /** Lookup a row by a field value (e.g., email). */
29
+ lookup(
30
+ entity: string,
31
+ field: string,
32
+ value: string
33
+ ): Promise<Record<string, unknown> | null>;
34
+
35
+ /** Query with filters ($gt, $lt, $in, $like, $order, $limit, etc.). */
36
+ query(
37
+ entity: string,
38
+ filter: Record<string, unknown>
39
+ ): Promise<Record<string, unknown>[]>;
40
+
41
+ /** Execute a graph query with nested relation includes. */
42
+ queryGraph(
43
+ query: Record<string, unknown>
44
+ ): Promise<Record<string, unknown>>;
45
+
46
+ /**
47
+ * Cursor-paginated list. Pass `cursor` from a previous page's `nextCursor`
48
+ * to continue; pass `null` for the first page.
49
+ *
50
+ * ```ts
51
+ * const { page, nextCursor, isDone } =
52
+ * await ctx.db.paginate("Order", { cursor: null, numItems: 50 });
53
+ * ```
54
+ *
55
+ * `numItems` is clamped to [1, 1000]; the server honors the clamp.
56
+ */
57
+ paginate(
58
+ entity: string,
59
+ opts: { cursor: string | null; numItems: number }
60
+ ): Promise<PaginationResult>;
61
+ }
62
+
63
+ /** Result shape for [`DbReader.paginate`]. */
64
+ export interface PaginationResult<T = Record<string, unknown>> {
65
+ /** Rows in this page. */
66
+ page: T[];
67
+ /** Cursor to pass to the next `paginate` call. `null` when exhausted. */
68
+ nextCursor: string | null;
69
+ /** True when there are no more rows after this page. */
70
+ isDone: boolean;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Database — write operations (extends read)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export interface DbWriter extends DbReader {
78
+ /** Insert a new row. Returns the generated ID. */
79
+ insert(entity: string, data: Record<string, unknown>): Promise<string>;
80
+
81
+ /** Update a row by ID. Returns true if the row existed. */
82
+ update(
83
+ entity: string,
84
+ id: string,
85
+ data: Record<string, unknown>
86
+ ): Promise<boolean>;
87
+
88
+ /** Delete a row by ID. Returns true if the row existed. */
89
+ delete(entity: string, id: string): Promise<boolean>;
90
+
91
+ /** Link two entities via a relation. */
92
+ link(
93
+ entity: string,
94
+ id: string,
95
+ relation: string,
96
+ targetId: string
97
+ ): Promise<boolean>;
98
+
99
+ /** Unlink a relation (set FK to null). */
100
+ unlink(entity: string, id: string, relation: string): Promise<boolean>;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Streaming
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export interface Stream {
108
+ /** Write a text chunk to the client (SSE). */
109
+ write(data: string): void;
110
+
111
+ /** Write a typed SSE event. */
112
+ writeEvent(event: string, data: string): void;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Scheduler
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export interface Scheduler {
120
+ /** Schedule a function to run after a delay (milliseconds). */
121
+ runAfter(
122
+ delayMs: number,
123
+ fnName: string,
124
+ args: Record<string, unknown>
125
+ ): Promise<string>;
126
+
127
+ /** Schedule a function to run at a specific time (Unix ms). */
128
+ runAt(
129
+ timestamp: number,
130
+ fnName: string,
131
+ args: Record<string, unknown>
132
+ ): Promise<string>;
133
+
134
+ /** Cancel a previously scheduled function. */
135
+ cancel(scheduleId: string): Promise<void>;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Context objects — what handlers receive
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /** Context for query handlers (read-only). */
143
+ export interface QueryCtx {
144
+ db: DbReader;
145
+ auth: AuthInfo;
146
+ }
147
+
148
+ /** Context for mutation handlers (read + write, transactional). */
149
+ export interface MutationCtx {
150
+ db: DbWriter;
151
+ auth: AuthInfo;
152
+ stream: Stream;
153
+ scheduler: Scheduler;
154
+ /** Create a typed error that triggers rollback. */
155
+ error(code: string, message: string): Error;
156
+ }
157
+
158
+ /** Context for action handlers (external I/O, non-transactional). */
159
+ export interface ActionCtx {
160
+ auth: AuthInfo;
161
+ stream: Stream;
162
+ scheduler: Scheduler;
163
+ /** Environment variables / secrets. */
164
+ env: Record<string, string>;
165
+ /** Run a registered query within its own read transaction. */
166
+ runQuery<T = unknown>(
167
+ fnName: string,
168
+ args: Record<string, unknown>
169
+ ): Promise<T>;
170
+ /** Run a registered mutation within its own write transaction. */
171
+ runMutation<T = unknown>(
172
+ fnName: string,
173
+ args: Record<string, unknown>
174
+ ): Promise<T>;
175
+ /** Create a typed error. */
176
+ error(code: string, message: string): Error;
177
+ /**
178
+ * HTTP request metadata — present only when the action was invoked via
179
+ * a `defineRoute` HTTP binding. Missing when the action is called from
180
+ * another action (`ctx.runAction`), a job, or the function dashboard.
181
+ *
182
+ * Use this to verify webhook signatures (Stripe, GitHub, Slack) that
183
+ * require the raw request body — `rawBody` is the exact bytes the
184
+ * signer signed, NOT the parsed JSON.
185
+ *
186
+ * ```ts
187
+ * export default action({
188
+ * async handler(ctx) {
189
+ * const sig = ctx.request?.headers["stripe-signature"];
190
+ * stripe.webhooks.constructEvent(ctx.request!.rawBody, sig!, secret);
191
+ * },
192
+ * });
193
+ * ```
194
+ */
195
+ request?: RequestInfo;
196
+ }
197
+
198
+ /** HTTP request metadata available on an action's ctx when invoked via an
199
+ * HTTP route binding. Header names are lowercased. */
200
+ export interface RequestInfo {
201
+ method: string;
202
+ path: string;
203
+ headers: Record<string, string>;
204
+ rawBody: string;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Function definition types
209
+ // ---------------------------------------------------------------------------
210
+
211
+ export type FnType = "query" | "mutation" | "action";
212
+
213
+ export interface FnDefinition<TArgs = unknown, TReturn = unknown> {
214
+ type: FnType;
215
+ args?: Record<string, Validator>;
216
+ handler: (ctx: any, args: TArgs) => Promise<TReturn>;
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Validators
221
+ // ---------------------------------------------------------------------------
222
+
223
+ export interface Validator {
224
+ type: string;
225
+ optional?: boolean;
226
+ /** For v.id("tableName") */
227
+ table?: string;
228
+ /** For v.array(v.string()) */
229
+ items?: Validator;
230
+ /** For v.object({...}) */
231
+ fields?: Record<string, Validator>;
232
+ /** For v.union(...) */
233
+ variants?: Validator[];
234
+ /** For v.literal("value") */
235
+ value?: unknown;
236
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Argument validators for function definitions.
3
+ *
4
+ * These serve double duty:
5
+ * 1. Runtime validation — reject bad input before the handler runs.
6
+ * 2. Type inference — TypeScript infers handler arg types from validators.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { mutation, v } from "@pylonsync/functions";
11
+ *
12
+ * export default mutation({
13
+ * args: {
14
+ * name: v.string(),
15
+ * age: v.optional(v.number()),
16
+ * tags: v.array(v.string()),
17
+ * },
18
+ * async handler(ctx, args) {
19
+ * // args is typed as { name: string, age?: number, tags: string[] }
20
+ * },
21
+ * });
22
+ * ```
23
+ */
24
+
25
+ import type { Validator } from "./types";
26
+
27
+ function validator(type: string, extra?: Partial<Validator>): Validator {
28
+ return { type, ...extra };
29
+ }
30
+
31
+ export const v = {
32
+ /** String value. */
33
+ string: (): Validator => validator("string"),
34
+
35
+ /** Number (float64). Same as `v.float()`. */
36
+ number: (): Validator => validator("number"),
37
+
38
+ /**
39
+ * 64-bit float. Alias for `v.number()` so the validator API matches the
40
+ * schema DSL (which uses `field.float()`). Prefer this in new code.
41
+ */
42
+ float: (): Validator => validator("number"),
43
+
44
+ /** Integer. */
45
+ int: (): Validator => validator("int"),
46
+
47
+ /** Boolean. Same as `v.bool()`. */
48
+ boolean: (): Validator => validator("boolean"),
49
+
50
+ /**
51
+ * Boolean. Alias for `v.boolean()` so the validator API matches the
52
+ * schema DSL (which uses `field.bool()`). Prefer this in new code.
53
+ */
54
+ bool: (): Validator => validator("boolean"),
55
+
56
+ /**
57
+ * ISO-8601 datetime string. Validates the shape of a string value; the
58
+ * stored column type comes from the schema (`field.datetime()`).
59
+ */
60
+ datetime: (): Validator => validator("string"),
61
+
62
+ /**
63
+ * Richtext string. Same runtime validation as `v.string()`; named
64
+ * explicitly so server functions read as the matching schema type.
65
+ */
66
+ richtext: (): Validator => validator("string"),
67
+
68
+ /** ID reference to another entity. */
69
+ id: (table: string): Validator => validator("id", { table }),
70
+
71
+ /** Null value. */
72
+ null: (): Validator => validator("null"),
73
+
74
+ /** Array of values. */
75
+ array: (items: Validator): Validator => validator("array", { items }),
76
+
77
+ /** Object with typed fields. */
78
+ object: (fields: Record<string, Validator>): Validator =>
79
+ validator("object", { fields }),
80
+
81
+ /** Optional value (may be omitted). */
82
+ optional: (inner: Validator): Validator => ({ ...inner, optional: true }),
83
+
84
+ /** Union of multiple types. */
85
+ union: (...variants: Validator[]): Validator =>
86
+ validator("union", { variants }),
87
+
88
+ /** Exact literal value. */
89
+ literal: (value: string | number | boolean): Validator =>
90
+ validator("literal", { value }),
91
+
92
+ /** Any valid JSON value. */
93
+ any: (): Validator => validator("any"),
94
+ };
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Runtime validation
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export function validateArgs(
101
+ args: unknown,
102
+ schema: Record<string, Validator>
103
+ ): { valid: boolean; errors: string[] } {
104
+ const errors: string[] = [];
105
+
106
+ if (typeof args !== "object" || args === null) {
107
+ return { valid: false, errors: ["args must be an object"] };
108
+ }
109
+
110
+ const obj = args as Record<string, unknown>;
111
+
112
+ for (const [key, validator] of Object.entries(schema)) {
113
+ const value = obj[key];
114
+
115
+ if (value === undefined || value === null) {
116
+ if (!validator.optional) {
117
+ errors.push(`Missing required field "${key}" (type: ${validator.type})`);
118
+ }
119
+ continue;
120
+ }
121
+
122
+ const err = validateValue(value, validator, key);
123
+ if (err) errors.push(err);
124
+ }
125
+
126
+ return { valid: errors.length === 0, errors };
127
+ }
128
+
129
+ function validateValue(
130
+ value: unknown,
131
+ validator: Validator,
132
+ path: string
133
+ ): string | null {
134
+ switch (validator.type) {
135
+ case "string":
136
+ return typeof value === "string"
137
+ ? null
138
+ : `${path}: expected string, got ${typeof value}`;
139
+ case "number":
140
+ case "int":
141
+ return typeof value === "number"
142
+ ? null
143
+ : `${path}: expected number, got ${typeof value}`;
144
+ case "boolean":
145
+ return typeof value === "boolean"
146
+ ? null
147
+ : `${path}: expected boolean, got ${typeof value}`;
148
+ case "id":
149
+ return typeof value === "string"
150
+ ? null
151
+ : `${path}: expected id string, got ${typeof value}`;
152
+ case "null":
153
+ return value === null
154
+ ? null
155
+ : `${path}: expected null, got ${typeof value}`;
156
+ case "any":
157
+ return null;
158
+ case "literal":
159
+ return value === validator.value
160
+ ? null
161
+ : `${path}: expected literal ${JSON.stringify(validator.value)}, got ${JSON.stringify(value)}`;
162
+ case "array":
163
+ if (!Array.isArray(value))
164
+ return `${path}: expected array, got ${typeof value}`;
165
+ if (validator.items) {
166
+ for (let i = 0; i < value.length; i++) {
167
+ const err = validateValue(value[i], validator.items, `${path}[${i}]`);
168
+ if (err) return err;
169
+ }
170
+ }
171
+ return null;
172
+ case "object":
173
+ if (typeof value !== "object" || value === null || Array.isArray(value))
174
+ return `${path}: expected object`;
175
+ if (validator.fields) {
176
+ for (const [k, v] of Object.entries(validator.fields)) {
177
+ const fieldVal = (value as Record<string, unknown>)[k];
178
+ if (fieldVal === undefined && !v.optional) {
179
+ return `${path}.${k}: required field missing`;
180
+ }
181
+ if (fieldVal !== undefined) {
182
+ const err = validateValue(fieldVal, v, `${path}.${k}`);
183
+ if (err) return err;
184
+ }
185
+ }
186
+ }
187
+ return null;
188
+ case "union":
189
+ if (validator.variants) {
190
+ for (const variant of validator.variants) {
191
+ if (validateValue(value, variant, path) === null) return null;
192
+ }
193
+ return `${path}: value does not match any variant`;
194
+ }
195
+ return null;
196
+ default:
197
+ return null;
198
+ }
199
+ }