@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 +44 -0
- package/src/define.ts +109 -0
- package/src/index.ts +34 -0
- package/src/runtime.ts +668 -0
- package/src/testing.ts +70 -0
- package/src/types.ts +236 -0
- package/src/validators.ts +199 -0
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
|
+
}
|