@pylonsync/functions 0.4.17 → 0.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/define.d.ts CHANGED
@@ -41,18 +41,28 @@ interface CommonDef<TSchema extends ValidatorSchema | undefined = ValidatorSchem
41
41
  */
42
42
  internal?: boolean;
43
43
  /**
44
- * Max wall-clock SECONDS this function may run before the runtime
45
- * recycles its worker as wedged. Defaults to `PYLON_FN_CALL_TIMEOUT`
46
- * (30s). Raise it for legitimately long-running work — heavy renders,
47
- * big batch jobs, slow external calls — so the call isn't force-killed
48
- * mid-flight. This also lifts the runtime's wedge backstop for the
49
- * worker while such a call is in flight, so a busy-but-progressing
50
- * worker (e.g. one doing synchronous canvas/image work that blocks the
51
- * event loop) isn't respawned out from under the work.
44
+ * IDLE-timeout SECONDS for this function: how long it may go without
45
+ * producing any activity (a stream chunk, a `ctx.db` op, an LLM
46
+ * event) before the host cancels the call. Defaults to
47
+ * `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
48
+ * streaming agent run stays alive as long as it keeps producing; a
49
+ * silent hang is cancelled at the budget. Total lifetime is capped at
50
+ * 10× this value however chatty the call is.
52
51
  *
53
- * Keep it as small as the work honestly needs: a genuinely stuck call
54
- * still ties up its worker until this deadline. Prefer offloading very
55
- * heavy CPU work to a dedicated service over setting a huge timeout.
52
+ * Cancellation is per-call: the handler's next `ctx.*` call throws
53
+ * `CALL_CANCELLED` and `ctx.signal` aborts — other in-flight calls on
54
+ * the same worker are untouched. CAVEAT: the database transaction
55
+ * rolls back on cancel, but non-ctx work already in flight (a `fetch`,
56
+ * a payment SDK call) runs to completion unless you pass
57
+ * `{ signal: ctx.signal }` — a cancelled call can otherwise leave an
58
+ * external effect committed with its DB writes rolled back. Thread
59
+ * ctx.signal into every external call that must not outlive the
60
+ * handler. Raise the timeout for legitimately long
61
+ * SILENT work (a single slow external call, synchronous CPU work);
62
+ * this also lifts the runtime's wedge backstop for the worker while
63
+ * such a call is in flight, so a busy-but-progressing worker (e.g.
64
+ * one doing synchronous canvas/image work that blocks the event loop)
65
+ * isn't respawned out from under the work.
56
66
  */
57
67
  timeout?: number;
58
68
  }
package/dist/index.d.ts CHANGED
@@ -19,7 +19,9 @@
19
19
  */
20
20
  export { query, mutation, action } from "./define";
21
21
  export { v } from "./validators";
22
+ export { workflow } from "./workflows";
23
+ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunnerResponse, WorkflowStepResult, } from "./workflows";
22
24
  export { resetDb, installTestIsolation } from "./testing";
23
25
  export { slugifyName, availableSlug } from "./slugify";
24
26
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
25
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
27
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Workflows, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
package/dist/runtime.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * each ctx.db / ctx.scheduler / ctx.runMutation call), so the map never
15
15
  * needs to queue multiple RPCs per call_id.
16
16
  */
17
- import type { DbReader, DbWriter, Llm, Rooms } from "./types";
17
+ import type { DbReader, DbWriter, Llm, Rooms, Workflows } from "./types";
18
18
  export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
19
19
  export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
20
20
  export declare function buildDbWriter(callId: string): DbWriter;
@@ -39,3 +39,11 @@ export declare function buildLlm(callId: string): Llm;
39
39
  * on subscribers identically.
40
40
  */
41
41
  export declare function buildRooms(callId: string): Rooms;
42
+ /**
43
+ * Build `ctx.workflows` — start a durable workflow or deliver an event
44
+ * to a waiting one, from mutation/action code. Round-trips a
45
+ * `workflow_op` frame to the host's WorkflowEngine; the engine's driver
46
+ * takes it from there, so `start` returns the instance id immediately.
47
+ * Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
48
+ */
49
+ export declare function buildWorkflows(callId: string): Workflows;
package/dist/types.d.ts CHANGED
@@ -128,6 +128,28 @@ export interface DbReader {
128
128
  * entities without a `search:` config (`SEARCH_NOT_CONFIGURED`).
129
129
  */
130
130
  search(entity: string, query: Record<string, unknown>): Promise<SearchResult>;
131
+ /**
132
+ * Exact k-nearest-neighbor search over a `field.vector(dims)` field.
133
+ * Cosine similarity by default (`metric: "dot" | "l2"` to change);
134
+ * hits come back best-first with the full row on `doc` (vector
135
+ * fields stripped — re-fetch by id if you need the embedding).
136
+ *
137
+ * ```ts
138
+ * const [embedding] = await ctx.llm.embed(["how do I reset my password?"]);
139
+ * const { hits } = await ctx.db.vectorSearch("Doc", {
140
+ * field: "embedding",
141
+ * vector: embedding,
142
+ * limit: 5,
143
+ * filter: { status: "published" }, // equality / IN pre-filter
144
+ * });
145
+ * ```
146
+ *
147
+ * Exact scan, not ANN: every non-NULL embedding is scored. Fine to
148
+ * ~100k rows per entity; past that, a dedicated vector store wins.
149
+ * Throws `VECTOR_FIELD_NOT_FOUND` when `field` isn't a vector field
150
+ * and `INVALID_QUERY` on dimension mismatch or bad filters.
151
+ */
152
+ vectorSearch(entity: string, query: VectorSearchQuery): Promise<VectorSearchResult>;
131
153
  /**
132
154
  * Cursor-paginated list. Pass `cursor` from a previous page's `nextCursor`
133
155
  * to continue; pass `null` for the first page.
@@ -165,6 +187,32 @@ export interface SearchResult<T = Record<string, unknown>> {
165
187
  /** Milliseconds spent in the search engine. */
166
188
  tookMs: number;
167
189
  }
190
+ /** Request shape for [`DbReader.vectorSearch`]. */
191
+ export interface VectorSearchQuery {
192
+ /** The `vector(dims)` field to search. */
193
+ field: string;
194
+ /** Query embedding; length must equal the field's declared dims. */
195
+ vector: number[];
196
+ /** Max hits. Default 10, capped at 200. */
197
+ limit?: number;
198
+ /** Similarity metric. Default "cosine" (higher = closer);
199
+ * "dot" (higher = closer); "l2" (Euclidean distance, lower = closer). */
200
+ metric?: "cosine" | "dot" | "l2";
201
+ /** Equality pre-filter applied in SQL before scoring. A plain value
202
+ * means equality; an array means SQL `IN`; `null` means IS NULL. */
203
+ filter?: Record<string, unknown>;
204
+ }
205
+ /** Result shape for [`DbReader.vectorSearch`]. */
206
+ export interface VectorSearchResult<T = Record<string, unknown>> {
207
+ /** Best-first hits for the chosen metric. */
208
+ hits: Array<{
209
+ id: string;
210
+ score: number;
211
+ doc: T;
212
+ }>;
213
+ /** Milliseconds spent scanning + scoring. */
214
+ tookMs: number;
215
+ }
168
216
  export interface DbWriter extends DbReader {
169
217
  /**
170
218
  * Escape hatch — same shape as [`DbReader.unsafe`] but with the
@@ -339,6 +387,33 @@ export interface Llm {
339
387
  * `complete` would refuse.
340
388
  */
341
389
  stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void): Promise<LlmCompleteResponse>;
390
+ /**
391
+ * Batch-embed texts via the configured embeddings provider. One
392
+ * embedding per input, in input order. Pair with a
393
+ * `field.vector(dims)` field and `ctx.db.vectorSearch` for
394
+ * retrieval:
395
+ *
396
+ * ```ts
397
+ * const [vec] = await ctx.llm.embed([doc.body]);
398
+ * await ctx.db.update("Doc", doc.id, { embedding: vec });
399
+ * ```
400
+ *
401
+ * The embeddings provider is a separate axis from chat: with
402
+ * `OPENAI_API_KEY` set it defaults to OpenAI
403
+ * `text-embedding-3-small` (1536 dims) even when chat runs
404
+ * Anthropic; `PYLON_EMBEDDINGS_PROVIDER=voyage` + `VOYAGE_API_KEY`
405
+ * selects Voyage `voyage-3.5` (1024 dims).
406
+ * `PYLON_EMBEDDINGS_MODEL` overrides the model.
407
+ *
408
+ * Not available in queries (reactive re-runs would re-bill the
409
+ * provider) — embed in a mutation/action and store the vector.
410
+ * Errors carry `err.code`: `EMBEDDINGS_NOT_CONFIGURED`,
411
+ * `PROVIDER_HTTP_<code>`, `PROVIDER_UNREACHABLE`,
412
+ * `INVALID_REQUEST`.
413
+ */
414
+ embed(input: string[], opts?: {
415
+ model?: string;
416
+ }): Promise<number[][]>;
342
417
  }
343
418
  /**
344
419
  * One event from an in-flight {@link Llm.stream} call.
@@ -392,6 +467,29 @@ export interface Rooms {
392
467
  delivered: boolean;
393
468
  }>;
394
469
  }
470
+ /**
471
+ * Durable workflows, driven from app code (`ctx.workflows` on mutations
472
+ * and actions — not queries, whose reactive re-runs would re-start).
473
+ * Workflows are declared in the app's `workflows/` directory; see the
474
+ * `workflow()` export.
475
+ */
476
+ export interface Workflows {
477
+ /**
478
+ * Start a workflow instance by name. Returns immediately with the
479
+ * instance id — the engine's background driver executes the steps.
480
+ */
481
+ start(name: string, input?: unknown): Promise<{
482
+ id: string;
483
+ }>;
484
+ /**
485
+ * Deliver an event to an instance paused on
486
+ * `wf.waitForEvent(event)`. Rejects when the instance isn't waiting
487
+ * for that event.
488
+ */
489
+ sendEvent(workflowId: string, event: string, data?: unknown): Promise<{
490
+ delivered: boolean;
491
+ }>;
492
+ }
395
493
  export interface LlmMessage {
396
494
  role: "user" | "assistant" | "system" | "tool";
397
495
  content: string | LlmContentBlock[];
@@ -607,6 +705,14 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
607
705
  requireMember: RequireMember;
608
706
  /** Signed file-download URLs — see {@link Files}. */
609
707
  files: Files;
708
+ /**
709
+ * Fires when the host cancels this call (idle timeout exceeded).
710
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
711
+ * a cancelled call stops its outbound work too — the runtime already
712
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
713
+ * because older hosts don't send cancel frames.
714
+ */
715
+ signal?: AbortSignal;
610
716
  }
611
717
  /** Context for mutation handlers (read + write, transactional). */
612
718
  export interface MutationCtx<R extends AuthRequirement = "optional"> {
@@ -622,12 +728,22 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
622
728
  rooms: Rooms;
623
729
  /** Per-user OAuth connection registry. */
624
730
  connections: Connections;
731
+ /** Durable workflows: start / deliver events — see {@link Workflows}. */
732
+ workflows: Workflows;
625
733
  /** Signed file-download URLs — see {@link Files}. */
626
734
  files: Files;
627
735
  /** Create a typed error that triggers rollback. */
628
736
  error(code: string, message: string): Error;
629
737
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
630
738
  requireMember: RequireMember;
739
+ /**
740
+ * Fires when the host cancels this call (idle timeout exceeded).
741
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
742
+ * a cancelled call stops its outbound work too — the runtime already
743
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
744
+ * because older hosts don't send cancel frames.
745
+ */
746
+ signal?: AbortSignal;
631
747
  }
632
748
  /** Context for action handlers (external I/O, non-transactional). */
633
749
  export interface ActionCtx<R extends AuthRequirement = "optional"> {
@@ -642,6 +758,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
642
758
  rooms: Rooms;
643
759
  /** Per-user OAuth connection registry. */
644
760
  connections: Connections;
761
+ /** Durable workflows: start / deliver events — see {@link Workflows}. */
762
+ workflows: Workflows;
645
763
  /** Environment variables / secrets. */
646
764
  env: Record<string, string>;
647
765
  /** Signed file-download URLs — see {@link Files}. */
@@ -654,6 +772,14 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
654
772
  error(code: string, message: string): Error;
655
773
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
656
774
  requireMember: RequireMember;
775
+ /**
776
+ * Fires when the host cancels this call (idle timeout exceeded).
777
+ * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
778
+ * a cancelled call stops its outbound work too — the runtime already
779
+ * makes every later `ctx.*` call throw `CALL_CANCELLED`. Optional
780
+ * because older hosts don't send cancel frames.
781
+ */
782
+ signal?: AbortSignal;
657
783
  /**
658
784
  * HTTP request metadata — present only when the action was invoked via
659
785
  * a `defineRoute` HTTP binding. Missing when the action is called from
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Durable workflows: authoring surface + the per-slice executor.
3
+ *
4
+ * A workflow is a TS function that composes `step` / `sleep` /
5
+ * `waitForEvent` calls. The Rust WorkflowEngine owns the durable state
6
+ * (instance, step results, wake timers — persisted in SQLite); this
7
+ * module executes ONE slice at a time by REPLAY: on every advance the
8
+ * whole workflow function re-runs, already-completed steps return their
9
+ * recorded outputs without executing, and the first not-yet-done
10
+ * primitive either executes (a `step` at the current index) or pauses
11
+ * the run (`sleep` / `waitForEvent`).
12
+ *
13
+ * Determinism contract for authors: the sequence of step/sleep/
14
+ * waitForEvent calls must be identical on every replay for the same
15
+ * input + recorded outputs. Branch on `input` and on step OUTPUTS all
16
+ * you like — never on wall-clock time, randomness, or external state
17
+ * read outside a step.
18
+ *
19
+ * Files live in the app's `workflows/` directory (sibling of
20
+ * `functions/`), one default-exported `workflow(...)` per file. The
21
+ * runtime reports them in its ready handshake; the host registers them
22
+ * and drives execution back through the function pool as an internal
23
+ * `__pylon_workflow_run` action call — so step code runs with a full
24
+ * ActionCtx (ctx.db, ctx.llm, ctx.scheduler, the idle timeout, and
25
+ * cancellation) rather than in a bespoke side-channel process.
26
+ */
27
+ import type { ActionCtx } from "./types";
28
+ /** Wire shape of one recorded step — mirrors Rust `StepResult` exactly. */
29
+ export interface WorkflowStepResult {
30
+ step_id: string;
31
+ name: string;
32
+ status: "pending" | "running" | "completed" | "failed" | "skipped";
33
+ output?: unknown;
34
+ error?: string | null;
35
+ started_at?: string | null;
36
+ completed_at?: string | null;
37
+ duration_ms?: number | null;
38
+ retry_count?: number;
39
+ }
40
+ /** The advance request the Rust engine sends for one slice. */
41
+ export interface WorkflowRunRequest {
42
+ workflow_id: string;
43
+ workflow_name: string;
44
+ input: unknown;
45
+ current_step: number;
46
+ completed_steps: WorkflowStepResult[];
47
+ }
48
+ /** The verdict of one slice — mirrors Rust `apply_response`'s actions. */
49
+ export type WorkflowRunnerResponse = {
50
+ action: "step_complete";
51
+ step_name: string;
52
+ output: unknown;
53
+ duration_ms: number;
54
+ } | {
55
+ action: "sleep";
56
+ duration: string;
57
+ } | {
58
+ action: "wait_event";
59
+ event: string;
60
+ } | {
61
+ action: "complete";
62
+ output: unknown;
63
+ } | {
64
+ action: "fail";
65
+ error: string;
66
+ step_name?: string;
67
+ };
68
+ /** What a workflow function receives, besides the per-slice ActionCtx. */
69
+ export interface WorkflowRun<TInput = unknown> {
70
+ /** The workflow instance id (stable across slices + restarts). */
71
+ id: string;
72
+ /** The input `start()` was called with. */
73
+ input: TInput;
74
+ /**
75
+ * Run a named step exactly once. On replay a completed step returns
76
+ * its recorded output without executing. Step names must be unique
77
+ * within one workflow run — the replay cache is name-keyed.
78
+ */
79
+ step<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
80
+ /** Pause the workflow for a duration ("30s", "5m", "24h", "7d"). */
81
+ sleep(duration: string): Promise<void>;
82
+ /**
83
+ * Pause until `POST /api/workflows/<id>/event` delivers this event.
84
+ * Resolves with the event's data payload.
85
+ */
86
+ waitForEvent<T = unknown>(eventName: string): Promise<T>;
87
+ }
88
+ export interface WorkflowDefinition<TInput = unknown> {
89
+ readonly __pylonWorkflow: true;
90
+ name: string;
91
+ description?: string;
92
+ /** Max retries per step before the workflow fails (engine default: 3). */
93
+ maxRetries?: number;
94
+ fn: (wf: WorkflowRun<TInput>, ctx: ActionCtx) => Promise<unknown>;
95
+ }
96
+ /**
97
+ * Declare a workflow. Default-export the result from a file in the
98
+ * app's `workflows/` directory:
99
+ *
100
+ * ```ts
101
+ * // workflows/onboarding.ts
102
+ * import { workflow } from "@pylonsync/functions";
103
+ *
104
+ * export default workflow("onboarding", async (wf, ctx) => {
105
+ * const user = await wf.step("load-user", () =>
106
+ * ctx.runQuery("getUser", { id: wf.input.userId }),
107
+ * );
108
+ * await wf.step("send-welcome", () =>
109
+ * ctx.email.send({ to: user.email, subject: "Welcome!", text: "..." }),
110
+ * );
111
+ * await wf.sleep("24h");
112
+ * const confirmed = await wf.waitForEvent("email_confirmed");
113
+ * return { done: true, confirmed };
114
+ * });
115
+ * ```
116
+ */
117
+ export declare function workflow<TInput = unknown>(name: string, fn: (wf: WorkflowRun<TInput>, ctx: ActionCtx) => Promise<unknown>, opts?: {
118
+ description?: string;
119
+ maxRetries?: number;
120
+ }): WorkflowDefinition<TInput>;
121
+ /** Runtime shape check for a `workflows/` file's default export. */
122
+ export declare function isWorkflowDefinition(v: unknown): v is WorkflowDefinition;
123
+ /**
124
+ * Execute one slice of `def` for `request`, returning the engine verdict.
125
+ * Never throws for handler errors — a step failure becomes
126
+ * `{action: "fail"}` so the engine's retry accounting runs.
127
+ */
128
+ export declare function executeWorkflowSlice(def: WorkflowDefinition, request: WorkflowRunRequest, ctx: ActionCtx): Promise<WorkflowRunnerResponse>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.17",
3
+ "version": "0.4.20",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/define.ts CHANGED
@@ -56,18 +56,28 @@ interface CommonDef<
56
56
  */
57
57
  internal?: boolean;
58
58
  /**
59
- * Max wall-clock SECONDS this function may run before the runtime
60
- * recycles its worker as wedged. Defaults to `PYLON_FN_CALL_TIMEOUT`
61
- * (30s). Raise it for legitimately long-running work — heavy renders,
62
- * big batch jobs, slow external calls — so the call isn't force-killed
63
- * mid-flight. This also lifts the runtime's wedge backstop for the
64
- * worker while such a call is in flight, so a busy-but-progressing
65
- * worker (e.g. one doing synchronous canvas/image work that blocks the
66
- * event loop) isn't respawned out from under the work.
59
+ * IDLE-timeout SECONDS for this function: how long it may go without
60
+ * producing any activity (a stream chunk, a `ctx.db` op, an LLM
61
+ * event) before the host cancels the call. Defaults to
62
+ * `PYLON_FN_CALL_TIMEOUT` (30s). Activity restarts the budget, so a
63
+ * streaming agent run stays alive as long as it keeps producing; a
64
+ * silent hang is cancelled at the budget. Total lifetime is capped at
65
+ * 10× this value however chatty the call is.
67
66
  *
68
- * Keep it as small as the work honestly needs: a genuinely stuck call
69
- * still ties up its worker until this deadline. Prefer offloading very
70
- * heavy CPU work to a dedicated service over setting a huge timeout.
67
+ * Cancellation is per-call: the handler's next `ctx.*` call throws
68
+ * `CALL_CANCELLED` and `ctx.signal` aborts — other in-flight calls on
69
+ * the same worker are untouched. CAVEAT: the database transaction
70
+ * rolls back on cancel, but non-ctx work already in flight (a `fetch`,
71
+ * a payment SDK call) runs to completion unless you pass
72
+ * `{ signal: ctx.signal }` — a cancelled call can otherwise leave an
73
+ * external effect committed with its DB writes rolled back. Thread
74
+ * ctx.signal into every external call that must not outlive the
75
+ * handler. Raise the timeout for legitimately long
76
+ * SILENT work (a single slow external call, synchronous CPU work);
77
+ * this also lifts the runtime's wedge backstop for the worker while
78
+ * such a call is in flight, so a busy-but-progressing worker (e.g.
79
+ * one doing synchronous canvas/image work that blocks the event loop)
80
+ * isn't respawned out from under the work.
71
81
  */
72
82
  timeout?: number;
73
83
  }
package/src/index.ts CHANGED
@@ -20,6 +20,14 @@
20
20
 
21
21
  export { query, mutation, action } from "./define";
22
22
  export { v } from "./validators";
23
+ export { workflow } from "./workflows";
24
+ export type {
25
+ WorkflowDefinition,
26
+ WorkflowRun,
27
+ WorkflowRunRequest,
28
+ WorkflowRunnerResponse,
29
+ WorkflowStepResult,
30
+ } from "./workflows";
23
31
  export { resetDb, installTestIsolation } from "./testing";
24
32
  export { slugifyName, availableSlug } from "./slugify";
25
33
  // SSR page response controller — pages/layouts receive `response` in
@@ -54,6 +62,11 @@ export type {
54
62
  RequireMember,
55
63
  RequireMemberOptions,
56
64
  MemberRow,
65
+ Workflows,
66
+ VectorSearchQuery,
67
+ VectorSearchResult,
68
+ SearchResult,
69
+ PaginationResult,
57
70
  // LLM + realtime surfaces. A handler writing an agent tool loop
58
71
  // builds its own message array and branches on stream events, so
59
72
  // these have to be nameable from app code.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tests for host-initiated call cancellation (runtime.ts `cancel` frames).
3
+ *
4
+ * The contract under test:
5
+ *
6
+ * 1. A `cancel` frame for a call REJECTS that call's in-flight RPC
7
+ * promises with code CALL_CANCELLED (the handler's `await ctx.db.*`
8
+ * unwinds instead of hanging on a reply that will never come).
9
+ * 2. Any LATER ctx RPC from the cancelled call throws CALL_CANCELLED —
10
+ * this is what actually stops a typical handler mid-loop.
11
+ * 3. A different call on the same runner is untouched: its RPCs still
12
+ * round-trip normally. This is the whole point of the change — the
13
+ * host used to kill the entire child on one call's timeout.
14
+ *
15
+ * Same child-process harness as runtime-llm-stream.test.ts: runtime.ts
16
+ * runs main() on import, so we drive the REAL dispatcher over NDJSON on
17
+ * stdin/stdout exactly as the Rust host would.
18
+ */
19
+ import { expect, test } from "bun:test";
20
+ import { mkdtempSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
25
+
26
+ const SCRIPT = `
27
+ import { buildDbReader } from ${JSON.stringify(RUNTIME)};
28
+
29
+ const victim = buildDbReader("c_1");
30
+ const bystander = buildDbReader("c_2");
31
+
32
+ const out = {};
33
+
34
+ // In-flight RPC on the call the host will cancel.
35
+ const victimFirst = victim.get("User", "u1").then(
36
+ () => ({ outcome: "resolved" }),
37
+ (e) => ({ outcome: "rejected", code: e.code }),
38
+ );
39
+ // Concurrent RPC on an UNRELATED call — must complete normally.
40
+ const bystanderGet = bystander.get("User", "u2").then(
41
+ (data) => ({ outcome: "resolved", data }),
42
+ (e) => ({ outcome: "rejected", code: e.code }),
43
+ );
44
+
45
+ out.victimFirst = await victimFirst;
46
+ out.bystander = await bystanderGet;
47
+
48
+ // A LATER RPC from the cancelled call must throw synchronously.
49
+ try {
50
+ await victim.get("User", "u3");
51
+ out.victimSecond = { outcome: "resolved" };
52
+ } catch (e) {
53
+ out.victimSecond = { outcome: "rejected", code: e.code };
54
+ }
55
+
56
+ console.error("RESULT " + JSON.stringify(out));
57
+ process.exit(0);
58
+ `;
59
+
60
+ function parseFrames(text: string): Record<string, unknown>[] {
61
+ return text
62
+ .split("\n")
63
+ .filter((l) => l.trim().startsWith("{"))
64
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
65
+ }
66
+
67
+ test("cancel rejects the call's RPCs, poisons later ones, and spares co-tenant calls", async () => {
68
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-cancel-"));
69
+ const scriptPath = join(dir, "probe.ts");
70
+ writeFileSync(scriptPath, SCRIPT);
71
+
72
+ const proc = Bun.spawn([process.execPath, scriptPath], {
73
+ stdin: "pipe",
74
+ stdout: "pipe",
75
+ stderr: "pipe",
76
+ });
77
+
78
+ // Wait for both db frames (c_1's and c_2's) so we know their op_ids.
79
+ const reader = proc.stdout.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buffered = "";
82
+ let requests: Record<string, unknown>[] = [];
83
+ const wanted = (fs: Record<string, unknown>[]) =>
84
+ fs.filter((f) => f.type === "db").length >= 2;
85
+ while (!wanted(requests)) {
86
+ const { done, value } = await reader.read();
87
+ if (done) break;
88
+ buffered += decoder.decode(value, { stream: true });
89
+ requests = parseFrames(buffered);
90
+ }
91
+
92
+ const dbFrames = requests.filter((f) => f.type === "db");
93
+ const victimOp = dbFrames.find((f) => f.call_id === "c_1")?.op_id as string;
94
+ const bystanderOp = dbFrames.find((f) => f.call_id === "c_2")
95
+ ?.op_id as string;
96
+ expect(victimOp).toBeTruthy();
97
+ expect(bystanderOp).toBeTruthy();
98
+
99
+ const send = (msg: Record<string, unknown>) =>
100
+ proc.stdin.write(JSON.stringify(msg) + "\n");
101
+
102
+ // Cancel c_1 while its RPC is in flight; answer c_2 normally.
103
+ send({ type: "cancel", call_id: "c_1", reason: "idle timeout 30s exceeded" });
104
+ send({
105
+ type: "result",
106
+ call_id: "c_2",
107
+ op_id: bystanderOp,
108
+ data: { id: "u2", name: "Bystander" },
109
+ });
110
+ await proc.stdin.flush();
111
+
112
+ const stderr = await new Response(proc.stderr).text();
113
+ await proc.exited;
114
+
115
+ const line = stderr.split("\n").find((l) => l.includes("RESULT "));
116
+ expect(line).toBeDefined();
117
+ const out = JSON.parse(line!.slice(line!.indexOf("RESULT ") + 7));
118
+
119
+ // 1. The in-flight RPC rejected with the cancellation code — no hang,
120
+ // no generic RPC-timeout 60s later.
121
+ expect(out.victimFirst).toEqual({
122
+ outcome: "rejected",
123
+ code: "CALL_CANCELLED",
124
+ });
125
+
126
+ // 2. The later RPC threw immediately with the same code.
127
+ expect(out.victimSecond).toEqual({
128
+ outcome: "rejected",
129
+ code: "CALL_CANCELLED",
130
+ });
131
+
132
+ // 3. The co-tenant call was untouched.
133
+ expect(out.bystander).toEqual({
134
+ outcome: "resolved",
135
+ data: { id: "u2", name: "Bystander" },
136
+ });
137
+ });