@pylonsync/functions 0.4.19 → 0.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -24,4 +24,4 @@ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunne
24
24
  export { resetDb, installTestIsolation } from "./testing";
25
25
  export { slugifyName, availableSlug } from "./slugify";
26
26
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
27
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Workflows, 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/types.d.ts CHANGED
@@ -128,6 +128,32 @@ 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
+ * Available wherever `ctx.db` is — queries and mutations. Actions
138
+ * have no `ctx.db`: embed there, then `ctx.runQuery` a query that
139
+ * searches with the vector.
140
+ *
141
+ * ```ts
142
+ * // In a query: the vector arrives as an arg (an action embedded it).
143
+ * const { hits } = await ctx.db.vectorSearch("Doc", {
144
+ * field: "embedding",
145
+ * vector,
146
+ * limit: 5,
147
+ * filter: { status: "published" }, // equality / IN pre-filter
148
+ * });
149
+ * ```
150
+ *
151
+ * Exact scan, not ANN: every non-NULL embedding is scored. Fine to
152
+ * ~100k rows per entity; past that, a dedicated vector store wins.
153
+ * Throws `VECTOR_FIELD_NOT_FOUND` when `field` isn't a vector field
154
+ * and `INVALID_QUERY` on dimension mismatch or bad filters.
155
+ */
156
+ vectorSearch(entity: string, query: VectorSearchQuery): Promise<VectorSearchResult>;
131
157
  /**
132
158
  * Cursor-paginated list. Pass `cursor` from a previous page's `nextCursor`
133
159
  * to continue; pass `null` for the first page.
@@ -165,6 +191,32 @@ export interface SearchResult<T = Record<string, unknown>> {
165
191
  /** Milliseconds spent in the search engine. */
166
192
  tookMs: number;
167
193
  }
194
+ /** Request shape for [`DbReader.vectorSearch`]. */
195
+ export interface VectorSearchQuery {
196
+ /** The `vector(dims)` field to search. */
197
+ field: string;
198
+ /** Query embedding; length must equal the field's declared dims. */
199
+ vector: number[];
200
+ /** Max hits. Default 10, capped at 200. */
201
+ limit?: number;
202
+ /** Similarity metric. Default "cosine" (higher = closer);
203
+ * "dot" (higher = closer); "l2" (Euclidean distance, lower = closer). */
204
+ metric?: "cosine" | "dot" | "l2";
205
+ /** Equality pre-filter applied in SQL before scoring. A plain value
206
+ * means equality; an array means SQL `IN`; `null` means IS NULL. */
207
+ filter?: Record<string, unknown>;
208
+ }
209
+ /** Result shape for [`DbReader.vectorSearch`]. */
210
+ export interface VectorSearchResult<T = Record<string, unknown>> {
211
+ /** Best-first hits for the chosen metric. */
212
+ hits: Array<{
213
+ id: string;
214
+ score: number;
215
+ doc: T;
216
+ }>;
217
+ /** Milliseconds spent scanning + scoring. */
218
+ tookMs: number;
219
+ }
168
220
  export interface DbWriter extends DbReader {
169
221
  /**
170
222
  * Escape hatch — same shape as [`DbReader.unsafe`] but with the
@@ -339,6 +391,34 @@ export interface Llm {
339
391
  * `complete` would refuse.
340
392
  */
341
393
  stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void): Promise<LlmCompleteResponse>;
394
+ /**
395
+ * Batch-embed texts via the configured embeddings provider. One
396
+ * embedding per input, in input order. Pair with a
397
+ * `field.vector(dims)` field and `ctx.db.vectorSearch` for
398
+ * retrieval. From an action (which has no `ctx.db`), store via a
399
+ * mutation:
400
+ *
401
+ * ```ts
402
+ * const [vec] = await ctx.llm.embed([doc.body]);
403
+ * await ctx.runMutation("saveEmbedding", { docId: doc.id, embedding: vec });
404
+ * ```
405
+ *
406
+ * The embeddings provider is a separate axis from chat: with
407
+ * `OPENAI_API_KEY` set it defaults to OpenAI
408
+ * `text-embedding-3-small` (1536 dims) even when chat runs
409
+ * Anthropic; `PYLON_EMBEDDINGS_PROVIDER=voyage` + `VOYAGE_API_KEY`
410
+ * selects Voyage `voyage-3.5` (1024 dims).
411
+ * `PYLON_EMBEDDINGS_MODEL` overrides the model.
412
+ *
413
+ * Not available in queries (reactive re-runs would re-bill the
414
+ * provider) — embed in a mutation/action and store the vector.
415
+ * Errors carry `err.code`: `EMBEDDINGS_NOT_CONFIGURED`,
416
+ * `PROVIDER_HTTP_<code>`, `PROVIDER_UNREACHABLE`,
417
+ * `INVALID_REQUEST`.
418
+ */
419
+ embed(input: string[], opts?: {
420
+ model?: string;
421
+ }): Promise<number[][]>;
342
422
  }
343
423
  /**
344
424
  * One event from an in-flight {@link Llm.stream} call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
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/index.ts CHANGED
@@ -63,6 +63,10 @@ export type {
63
63
  RequireMemberOptions,
64
64
  MemberRow,
65
65
  Workflows,
66
+ VectorSearchQuery,
67
+ VectorSearchResult,
68
+ SearchResult,
69
+ PaginationResult,
66
70
  // LLM + realtime surfaces. A handler writing an agent tool loop
67
71
  // builds its own message array and branches on stream events, so
68
72
  // these have to be nameable from app code.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Wire-shape tests for the vector surfaces (runtime.ts):
3
+ *
4
+ * 1. `ctx.db.vectorSearch` emits `{type:"db", op:"vector_search"}`
5
+ * frames with the query on `data` and the unsafe_op / ssr_read
6
+ * flags the Rust policy gate keys on.
7
+ * 2. `ctx.llm.embed` emits `{type:"llm_embed", request:{input,model}}`
8
+ * with an op_id so concurrent embeds demux.
9
+ *
10
+ * Same child-process harness as runtime-db.test.ts — runtime.ts runs
11
+ * main() on import, so the builders are exercised in a probe script
12
+ * whose emitted NDJSON frames are the assertion target.
13
+ */
14
+ import { expect, test } from "bun:test";
15
+ import { mkdtempSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
20
+
21
+ const SCRIPT = `
22
+ import { buildDbReader, buildLlm } from ${JSON.stringify(RUNTIME)};
23
+
24
+ const reader = buildDbReader("c_r");
25
+ const llm = buildLlm("c_l");
26
+
27
+ // No host replies — don't await; the emitted frames are the target.
28
+ reader.vectorSearch("Doc", {
29
+ field: "embedding",
30
+ vector: [1, 0, 0],
31
+ limit: 5,
32
+ metric: "l2",
33
+ filter: { kind: "a" },
34
+ }).catch(() => {});
35
+ reader.unsafe.vectorSearch("Doc", { field: "embedding", vector: [1] }).catch(() => {});
36
+ llm.embed(["hello", "world"], { model: "text-embedding-3-large" }).catch(() => {});
37
+
38
+ setTimeout(() => process.exit(0), 300);
39
+ `;
40
+
41
+ test("vectorSearch + llm.embed emit the exact wire shapes the host parses", async () => {
42
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-vec-"));
43
+ const scriptPath = join(dir, "probe.ts");
44
+ writeFileSync(scriptPath, SCRIPT);
45
+
46
+ const proc = Bun.spawn(["bun", scriptPath], {
47
+ stdin: "pipe",
48
+ stdout: "pipe",
49
+ stderr: "pipe",
50
+ });
51
+ const [stdout] = await Promise.all([
52
+ new Response(proc.stdout).text(),
53
+ proc.exited,
54
+ ]);
55
+
56
+ const frames = stdout
57
+ .split("\n")
58
+ .filter((l) => l.trim().startsWith("{"))
59
+ .map((l) => JSON.parse(l) as Record<string, any>);
60
+
61
+ const vs = frames.filter((f) => f.type === "db" && f.op === "vector_search");
62
+ expect(vs.length).toBe(2);
63
+ const safe = vs.find((f) => f.unsafe_op === false)!;
64
+ expect(safe).toMatchObject({
65
+ entity: "Doc",
66
+ ssr_read: false,
67
+ data: {
68
+ field: "embedding",
69
+ vector: [1, 0, 0],
70
+ limit: 5,
71
+ metric: "l2",
72
+ filter: { kind: "a" },
73
+ },
74
+ });
75
+ expect(safe.op_id).toBeDefined();
76
+ const unsafe = vs.find((f) => f.unsafe_op === true)!;
77
+ expect(unsafe.data.field).toBe("embedding");
78
+
79
+ const embed = frames.find((f) => f.type === "llm_embed")!;
80
+ expect(embed).toBeDefined();
81
+ expect(embed.call_id).toBe("c_l");
82
+ expect(embed.op_id).toBeDefined();
83
+ expect(embed.request).toMatchObject({
84
+ input: ["hello", "world"],
85
+ model: "text-embedding-3-large",
86
+ });
87
+ });
package/src/runtime.ts CHANGED
@@ -689,6 +689,16 @@ function buildReaderOps(
689
689
  ssr_read: ssrRead,
690
690
  })) as any;
691
691
  },
692
+ async vectorSearch(entity, query) {
693
+ return (await rpcDb(callId, {
694
+ type: "db",
695
+ op: "vector_search",
696
+ entity,
697
+ data: query,
698
+ unsafe_op: unsafeOp,
699
+ ssr_read: ssrRead,
700
+ })) as any;
701
+ },
692
702
  };
693
703
  }
694
704
 
@@ -915,6 +925,18 @@ export function buildLlm(callId: string): Llm {
915
925
  (event) => onEvent(event as LlmStreamEvent),
916
926
  )) as LlmCompleteResponse;
917
927
  },
928
+
929
+ async embed(
930
+ input: string[],
931
+ opts?: { model?: string },
932
+ ): Promise<number[][]> {
933
+ // op_id-keyed rpc so concurrent embeds in one handler demux.
934
+ const resp = (await rpcDb(callId, {
935
+ type: "llm_embed",
936
+ request: { input, model: opts?.model },
937
+ })) as { embeddings: number[][] };
938
+ return resp.embeddings;
939
+ },
918
940
  };
919
941
  }
920
942
 
package/src/types.ts CHANGED
@@ -156,6 +156,36 @@ export interface DbReader {
156
156
  query: Record<string, unknown>
157
157
  ): Promise<SearchResult>;
158
158
 
159
+ /**
160
+ * Exact k-nearest-neighbor search over a `field.vector(dims)` field.
161
+ * Cosine similarity by default (`metric: "dot" | "l2"` to change);
162
+ * hits come back best-first with the full row on `doc` (vector
163
+ * fields stripped — re-fetch by id if you need the embedding).
164
+ *
165
+ * Available wherever `ctx.db` is — queries and mutations. Actions
166
+ * have no `ctx.db`: embed there, then `ctx.runQuery` a query that
167
+ * searches with the vector.
168
+ *
169
+ * ```ts
170
+ * // In a query: the vector arrives as an arg (an action embedded it).
171
+ * const { hits } = await ctx.db.vectorSearch("Doc", {
172
+ * field: "embedding",
173
+ * vector,
174
+ * limit: 5,
175
+ * filter: { status: "published" }, // equality / IN pre-filter
176
+ * });
177
+ * ```
178
+ *
179
+ * Exact scan, not ANN: every non-NULL embedding is scored. Fine to
180
+ * ~100k rows per entity; past that, a dedicated vector store wins.
181
+ * Throws `VECTOR_FIELD_NOT_FOUND` when `field` isn't a vector field
182
+ * and `INVALID_QUERY` on dimension mismatch or bad filters.
183
+ */
184
+ vectorSearch(
185
+ entity: string,
186
+ query: VectorSearchQuery
187
+ ): Promise<VectorSearchResult>;
188
+
159
189
  /**
160
190
  * Cursor-paginated list. Pass `cursor` from a previous page's `nextCursor`
161
191
  * to continue; pass `null` for the first page.
@@ -196,6 +226,30 @@ export interface SearchResult<T = Record<string, unknown>> {
196
226
  tookMs: number;
197
227
  }
198
228
 
229
+ /** Request shape for [`DbReader.vectorSearch`]. */
230
+ export interface VectorSearchQuery {
231
+ /** The `vector(dims)` field to search. */
232
+ field: string;
233
+ /** Query embedding; length must equal the field's declared dims. */
234
+ vector: number[];
235
+ /** Max hits. Default 10, capped at 200. */
236
+ limit?: number;
237
+ /** Similarity metric. Default "cosine" (higher = closer);
238
+ * "dot" (higher = closer); "l2" (Euclidean distance, lower = closer). */
239
+ metric?: "cosine" | "dot" | "l2";
240
+ /** Equality pre-filter applied in SQL before scoring. A plain value
241
+ * means equality; an array means SQL `IN`; `null` means IS NULL. */
242
+ filter?: Record<string, unknown>;
243
+ }
244
+
245
+ /** Result shape for [`DbReader.vectorSearch`]. */
246
+ export interface VectorSearchResult<T = Record<string, unknown>> {
247
+ /** Best-first hits for the chosen metric. */
248
+ hits: Array<{ id: string; score: number; doc: T }>;
249
+ /** Milliseconds spent scanning + scoring. */
250
+ tookMs: number;
251
+ }
252
+
199
253
  // ---------------------------------------------------------------------------
200
254
  // Database — write operations (extends read)
201
255
  // ---------------------------------------------------------------------------
@@ -422,6 +476,33 @@ export interface Llm {
422
476
  request: LlmCompleteRequest,
423
477
  onEvent: (event: LlmStreamEvent) => void,
424
478
  ): Promise<LlmCompleteResponse>;
479
+
480
+ /**
481
+ * Batch-embed texts via the configured embeddings provider. One
482
+ * embedding per input, in input order. Pair with a
483
+ * `field.vector(dims)` field and `ctx.db.vectorSearch` for
484
+ * retrieval. From an action (which has no `ctx.db`), store via a
485
+ * mutation:
486
+ *
487
+ * ```ts
488
+ * const [vec] = await ctx.llm.embed([doc.body]);
489
+ * await ctx.runMutation("saveEmbedding", { docId: doc.id, embedding: vec });
490
+ * ```
491
+ *
492
+ * The embeddings provider is a separate axis from chat: with
493
+ * `OPENAI_API_KEY` set it defaults to OpenAI
494
+ * `text-embedding-3-small` (1536 dims) even when chat runs
495
+ * Anthropic; `PYLON_EMBEDDINGS_PROVIDER=voyage` + `VOYAGE_API_KEY`
496
+ * selects Voyage `voyage-3.5` (1024 dims).
497
+ * `PYLON_EMBEDDINGS_MODEL` overrides the model.
498
+ *
499
+ * Not available in queries (reactive re-runs would re-bill the
500
+ * provider) — embed in a mutation/action and store the vector.
501
+ * Errors carry `err.code`: `EMBEDDINGS_NOT_CONFIGURED`,
502
+ * `PROVIDER_HTTP_<code>`, `PROVIDER_UNREACHABLE`,
503
+ * `INVALID_REQUEST`.
504
+ */
505
+ embed(input: string[], opts?: { model?: string }): Promise<number[][]>;
425
506
  }
426
507
 
427
508
  /**